diff --git a/.github/scripts/build_javadocs.sh b/.github/scripts/build_javadocs.sh index 7b6da117263..5035acba99b 100755 --- a/.github/scripts/build_javadocs.sh +++ b/.github/scripts/build_javadocs.sh @@ -172,9 +172,25 @@ done < <("$BACKEND_DIR/shared-sources.sh") # server as for an app. The website's doclet marks the whole tree shared with # --shared-sources. BACKEND_SOURCES_ARGFILE="$CN1_DIR/build/backend-javadoc-sources.txt" +# The vm/JavaAPI classes the backend's public API exposes beyond the CLDC set -- +# @Async's Future and what its get() throws, Config's Properties. The backend +# compiles against vm/JavaAPI, so these work there; listing them here is what makes +# them documented, supported API rather than an accident of the class library. +# check-backend-jdk-surface.py fails the build when the backend exposes a JDK type +# that is neither CLDC nor in this list. Keep the two lists in step. +BACKEND_PROMOTED_JDK="java/util/Properties.java +java/util/concurrent/CancellationException.java +java/util/concurrent/ExecutionException.java +java/util/concurrent/Future.java +java/util/concurrent/TimeUnit.java +java/util/concurrent/TimeoutException.java" +python3 "$ROOT_DIR/scripts/check-backend-jdk-surface.py" { find "$BACKEND_STAGE" -name "*.java" | grep -v '/com/codename1/impl/' find "$ROOT_DIR/Ports/CLDC11/src" -name "*.java" + echo "$BACKEND_PROMOTED_JDK" | while IFS= read -r promoted; do + echo "$ROOT_DIR/vm/JavaAPI/src/$promoted" + done } | LC_ALL=C sort > "$BACKEND_SOURCES_ARGFILE" # Held to the same doclint as the client API above. --release 8 matches the backend module's @@ -185,7 +201,7 @@ BACKEND_SOURCES_ARGFILE="$CN1_DIR/build/backend-javadoc-sources.txt" --add-script "$ROOT_DIR/maven/javadoc-resources/highlight.min.js" \ --add-script "$ROOT_DIR/maven/javadoc-resources/javadoc-highlight-init.js" \ --release 8 \ - -sourcepath "$BACKEND_STAGE:$ROOT_DIR/Ports/CLDC11/src" \ + -sourcepath "$BACKEND_STAGE:$ROOT_DIR/Ports/CLDC11/src:$ROOT_DIR/vm/JavaAPI/src" \ -Xdoclint:all,-missing \ -Xmaxerrs 10000 \ -Xmaxwarns 10000 \ diff --git a/CodenameOne/src/com/codename1/annotations/JsonIgnore.java b/CodenameOne/src/com/codename1/annotations/JsonIgnore.java index 5e4c4495dcd..e98a8c95f28 100644 --- a/CodenameOne/src/com/codename1/annotations/JsonIgnore.java +++ b/CodenameOne/src/com/codename1/annotations/JsonIgnore.java @@ -29,6 +29,11 @@ /// Excludes a `@Mapped` field from the JSON projection. The same field still /// participates in XML mapping unless `@XmlTransient` is also present. +/// +/// The server's generated codecs honour it too: a field that points back at its +/// owner is the usual candidate, since writing both ends of that loop never +/// finishes. +@com.codename1.impl.SharedWithBackend @Retention(RetentionPolicy.CLASS) @Target(ElementType.FIELD) public @interface JsonIgnore { diff --git a/CodenameOne/src/com/codename1/annotations/JsonProperty.java b/CodenameOne/src/com/codename1/annotations/JsonProperty.java index d3e477349dd..d36bde0dd0e 100644 --- a/CodenameOne/src/com/codename1/annotations/JsonProperty.java +++ b/CodenameOne/src/com/codename1/annotations/JsonProperty.java @@ -30,6 +30,10 @@ /// Renames a `@Mapped` field in the JSON projection. The default JSON key is /// the field name; `@JsonProperty` lets a field map to `snake_case` or any /// alternative spelling without touching the Java identifier. +/// +/// The server's generated codecs read it too, so a class shared between an app +/// and its backend has one JSON form on both sides. +@com.codename1.impl.SharedWithBackend @Retention(RetentionPolicy.CLASS) @Target(ElementType.FIELD) public @interface JsonProperty { diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java index 5280f26ffa1..255e5bde7d9 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java @@ -34,10 +34,9 @@ public final class DatabaseSnippets { private DatabaseSnippets() { } - public static List open() throws IOException { + public static List open(DataSource db) throws IOException { // tag::backend-database[] -DataSource db = DataSource.open(System.getenv("DATABASE_URL")); // or ":memory:" - +// db is the pool the server opened from cn1.datasource.url and injected List rows = db.query("SELECT id, body FROM note WHERE id > ?", new Object[] { Integer.valueOf(10) }); // end::backend-database[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Notes.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Notes.java index a5c1f9edbc6..873f997a0e5 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Notes.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Notes.java @@ -32,17 +32,18 @@ import com.codename1.backend.annotations.ResponseStatus; import com.codename1.backend.annotations.RestController; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; // tag::backend-first-server[] @RestController @RequestMapping("/notes") public class Notes { - private final Map store = new ConcurrentHashMap(); + private final Map store = + Collections.synchronizedMap(new LinkedHashMap()); private final AtomicLong nextId = new AtomicLong(1); @GetMapping("/healthz") @@ -58,11 +59,13 @@ public Map read(@PathVariable("id") long id) { @GetMapping public List list(@RequestParam(value = "limit", defaultValue = "20") int limit) { List page = new ArrayList(); - for (Map note : store.values()) { - if (page.size() >= limit) { - break; + synchronized (store) { // iterating needs the map's own lock + for (Map note : store.values()) { + if (page.size() >= limit) { + break; + } + page.add(note); } - page.add(note); } return page; } diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesEndpoint.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesEndpoint.java index fd79b067cac..8191147d922 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesEndpoint.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesEndpoint.java @@ -22,12 +22,14 @@ */ package com.codenameone.developerguide.backend; +import java.util.Collections; +import java.util.HashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; // tag::backend-contract-server[] public class NotesEndpoint implements NotesApiServer { - private final Map notes = new ConcurrentHashMap(); + private final Map notes = + Collections.synchronizedMap(new HashMap()); public Note note(String id) { // no callback: this IS the server return notes.get(id); diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Products.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Products.java new file mode 100644 index 00000000000..4bdfe702f39 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Products.java @@ -0,0 +1,91 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.DeleteMapping; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PathVariable; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.RequestBody; +import com.codename1.backend.annotations.RequestHeader; +import com.codename1.backend.annotations.RequestMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.ResponseStatus; +import com.codename1.backend.annotations.RestController; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +// tag::backend-web-mappings[] +@RestController +@RequestMapping("/api/products") +public class Products { + private final Map> products = + new LinkedHashMap>(); + + @GetMapping("/{id}") // GET /api/products/42 + public synchronized Map get(@PathVariable("id") long id) { + return products.get(Long.valueOf(id)); // null answers 404 + } + + @GetMapping("/search") // GET /api/products/search?q=mug + public synchronized List> search( + @RequestParam(value = "q", required = false) String query, + @RequestParam(value = "limit", defaultValue = "20") int limit) { + List> out = new ArrayList>(); + for (Map p : products.values()) { + if (out.size() < limit + && (query == null || String.valueOf(p.get("name")).contains(query))) { + out.add(p); + } + } + return out; + } + + @PostMapping // POST /api/products + @ResponseStatus(201) + public synchronized Map create( + @RequestBody Map body, + @RequestHeader(value = "Idempotency-Key", required = false) String key) { + Long id = Long.valueOf(products.size() + 1); + Map product = new LinkedHashMap(body); + product.put("id", id); + products.put(id, product); + return product; + } + + @DeleteMapping("/{id}") // void answers 204 + public synchronized void delete(@PathVariable("id") long id) { + products.remove(Long.valueOf(id)); + } + + @GetMapping("/{id}/label") + public HttpServer.Response label(HttpServer.Request request, @PathVariable("id") long id) { + byte[] text = ("product " + id).getBytes(); + return request.respond(200, "text/plain; charset=utf-8", text); + } +} +// end::backend-web-mappings[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/PushFeedback.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/PushFeedback.java index 75a5c662f7a..069120eebc0 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/PushFeedback.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/PushFeedback.java @@ -24,10 +24,11 @@ import com.codename1.backend.Crypto; import com.codename1.backend.HttpServer; +import com.codename1.backend.Json; import com.codename1.backend.annotations.PostMapping; import com.codename1.backend.annotations.RequestMapping; import com.codename1.backend.annotations.RestController; -import com.codename1.io.JSONParser; +import com.codename1.backend.annotations.Value; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -57,11 +58,21 @@ void removeTargetAndMarkApplied(String eventKey, String provider, String target) throws Exception; } - /** Assigned once at start-up; there is no dependency injection here. */ - static DeviceStore store; + private final DeviceStore store; - /** The signing secret shown in Push > Settings. Read it from configuration. */ - private static final String SECRET = System.getenv("CN1_PUSH_CALLBACK_SECRET"); + /** The signing secret shown in Push > Settings. */ + private final String secret; + + /** + * Both are injected: the store is your bean implementing DeviceStore, and + * the secret is read from cn1.push.callbackSecret, which the environment + * variable CN1_PUSH_CALLBACKSECRET overrides. + */ + public PushFeedback(DeviceStore store, + @Value("${cn1.push.callbackSecret}") String secret) { + this.store = store; + this.secret = secret; + } /** Reject a digest whose timestamp is older than this, to bound replay. */ private static final long MAX_AGE_MS = 5 * 60 * 1000L; @@ -69,13 +80,13 @@ void removeTargetAndMarkApplied(String eventKey, String provider, String target) @PostMapping("/feedback") public HttpServer.Response feedback(HttpServer.Request request) throws Exception { String body = request.getBody(); - if (!verified(request.getHeader("X-CN1-Signature"), body)) { + if (!verified(request.getHeader("X-CN1-Signature"), body, secret)) { // Anything but 2xx keeps the window at the sender and resends it, // which is what you want while a secret rotation is half-applied. return new HttpServer.Response(401, "text/plain", "bad signature".getBytes(StandardCharsets.UTF_8)); } - Map digest = JSONParser.parseJSON(body); + Map digest = Json.parseObject(body); List events = (List) digest.get("events"); if (events != null) { for (Object entry : events) { @@ -118,8 +129,9 @@ public HttpServer.Response feedback(HttpServer.Request request) throws Exception // throws, because String.getBytes(Charset) is a CHECKED throw in the // ParparVM class library even though it is not one on a JVM. - private static boolean verified(String header, String body) throws Exception { - if (header == null || SECRET == null) { + private static boolean verified(String header, String body, String secret) + throws Exception { + if (header == null || secret == null || secret.length() == 0) { return false; } long timestamp = 0; @@ -156,7 +168,7 @@ private static boolean verified(String header, String body) throws Exception { // no JCE. equalsConstantTime is here for the same reason a hand-written // loop would be -- an early exit on the first differing byte lets a MAC // be forged one byte at a time. - byte[] expected = Crypto.hmacSha256(SECRET.getBytes(StandardCharsets.UTF_8), + byte[] expected = Crypto.hmacSha256(secret.getBytes(StandardCharsets.UTF_8), (timestamp + "." + body).getBytes(StandardCharsets.UTF_8)); return Crypto.equalsConstantTime(expected, decodeHex(provided)); } diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/TracingSnippets.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/TracingSnippets.java index fad6dba2be2..7225ac6c103 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/TracingSnippets.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/TracingSnippets.java @@ -22,13 +22,10 @@ */ package com.codenameone.developerguide.backend; -import com.codename1.backend.Config; -import com.codename1.backend.Tracing; import com.codename1.backend.annotations.GetMapping; import com.codename1.backend.annotations.OpenTelemetry; import com.codename1.backend.annotations.PathVariable; import com.codename1.backend.annotations.RestController; -import com.codename1.backend.otel.OtlpTracer; /// The Backend chapter's tracing examples, compiled so they cannot drift. This /// module runs no annotation processing, which is what lets `@OpenTelemetry` appear @@ -51,10 +48,4 @@ public String note(@PathVariable("id") String id) { // end::backend-otel-annotation[] } - public static void installByHand() throws Exception { -// tag::backend-otel-install[] -Tracing.install(OtlpTracer.open(Config.load(), "notes")); -// end::backend-otel-install[] - } - } diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java index e5809505175..373b25616c7 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java @@ -22,10 +22,6 @@ */ package com.codenameone.developerguide.backend; -import com.codename1.backend.Backend; -import com.codename1.backend.DataSource; -import com.codename1.backend.orm.EntityManager; -import com.codename1.backend.HttpServer; import com.codename1.backend.WebSocket; import com.codename1.backend.WebSocketSession; import com.codename1.backend.annotations.WebSocketMapping; @@ -58,19 +54,6 @@ public void onBinary(WebSocketSession session, byte[] message, int offset, int l } // end::backend-websocket-echo[] -// tag::backend-websocket-register[] -public static void main(String[] args) throws Exception { - Backend.builder() - .webSockets(new Backend.WebSocketEndpoints() { - public void register(HttpServer.WebSocketRegistry registry, - DataSource dataSource, EntityManager entities) { - registry.route("/echo", new Echo()); - } - }) - .run(); -} -// end::backend-websocket-register[] - // tag::backend-websocket-annotated[] @WebSocketMapping("/chat") public static final class ChatEndpoint implements WebSocket { @@ -143,18 +126,4 @@ public void onBinary(WebSocketSession session, byte[] message, int offset, int l } // end::backend-websocket-subprotocol[] -// tag::backend-websocket-raw[] -public static void serveForever() throws Exception { - HttpServer server = HttpServer.start(null, 8080, 512, 16, new HttpServer.Handler() { - public HttpServer.Response handle(HttpServer.Request request) { - return HttpServer.Response.text(200, "ok"); - } - }, null, new HttpServer.WebSocketRoutes() { - public void register(HttpServer.WebSocketRegistry registry) { - registry.route("/echo", new Echo()); - } - }); - server.awaitTermination(); -} -// end::backend-websocket-raw[] } diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/ServerSnippets.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Account.java similarity index 55% rename from docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/ServerSnippets.java rename to docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Account.java index bc5805a603c..62a7b16b833 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/ServerSnippets.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Account.java @@ -20,33 +20,33 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codenameone.developerguide.backend; +package com.codenameone.developerguide.backend.beans; -import com.codename1.backend.Backend; import com.codename1.backend.HttpServer; +import com.codename1.backend.HttpSession; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.RestController; -/** The Backend chapter's server examples, compiled so they cannot drift. */ -public final class ServerSnippets { - - private ServerSnippets() { - } - - public static void serve() throws Exception { -// tag::backend-builder[] -Backend.builder() - .port(9000) // otherwise cn1.server.port, or PORT, or 8080 - .handler(new Health()) - .run(); -// end::backend-builder[] +@RestController +public class Account { +// tag::backend-session-read[] + @GetMapping("/me") + public String me(HttpServer.Request request) { + HttpSession session = request.getSession(false); // never creates one + Object user = session == null ? null : session.getAttribute("user"); + return user == null ? "nobody" : String.valueOf(user); } +// end::backend-session-read[] - /** A handler with nothing injected into it, for the example above. */ - public static final class Health implements HttpServer.Handler { - public HttpServer.Response handle(HttpServer.Request request) throws Exception { - if (!"/healthz".equals(request.getTarget())) { - return null; // null means "not mine", and then a 404 - } - return new HttpServer.Response(200, "text/plain", "ok".getBytes("UTF-8")); +// tag::backend-session-logout[] + @PostMapping("/logout") + public String logout(HttpServer.Request request) { + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); // the response clears the cookie } + return "bye"; } +// end::backend-session-logout[] } diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AppSettings.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AppSettings.java new file mode 100644 index 00000000000..ce2a9a9e09a --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AppSettings.java @@ -0,0 +1,36 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.ServerConfig; +import com.codename1.backend.annotations.SessionConfig; + +/** The Backend chapter's settings-annotation example, compiled so it cannot drift. */ +// tag::backend-settings-annotations[] +@Configuration +@ServerConfig(port = 8080, workers = 32) +@SessionConfig(store = "db", timeoutSeconds = 3600) +public class AppSettings { +} +// end::backend-settings-annotations[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Audit.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Audit.java new file mode 100644 index 00000000000..22e7a1e817c --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Audit.java @@ -0,0 +1,28 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +/** Records what happened. The application may supply its own. */ +public interface Audit { + void record(String event); +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AuditLog.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AuditLog.java new file mode 100644 index 00000000000..44c8206792f --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AuditLog.java @@ -0,0 +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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Propagation; +import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.Transactional; + +import java.io.IOException; + +// tag::backend-tx-requires-new[] +@Service +public class AuditLog { + private final DataSource db; + + public AuditLog(DataSource db) { + this.db = db; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void record(String event) throws IOException { + // Commits on its own connection, whatever the caller's transaction does. + db.execute("INSERT INTO audit (event) VALUES (?)", new Object[] {event}); + } +} +// end::backend-tx-requires-new[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/CardPayments.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/CardPayments.java new file mode 100644 index 00000000000..459e60c2215 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/CardPayments.java @@ -0,0 +1,37 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Primary; + +// tag::backend-bean-primary[] +@Component +@Primary +public class CardPayments implements PaymentProvider { + public String name() { + return "card"; + } +} + +// end::backend-bean-primary[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Cart.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Cart.java new file mode 100644 index 00000000000..274a4830ac8 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Cart.java @@ -0,0 +1,46 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.SessionScope; + +import java.util.ArrayList; +import java.util.List; + +// tag::backend-session-bean[] +@Component +@SessionScope +public class Cart { + private final List items = new ArrayList(); + + public synchronized int add(String sku) { + items.add(sku); + return items.size(); + } + + public synchronized List items() { + return new ArrayList(items); + } +} +// end::backend-session-bean[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/CartApi.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/CartApi.java new file mode 100644 index 00000000000..851ea921a07 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/CartApi.java @@ -0,0 +1,51 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +import java.util.List; + +// tag::backend-session-bean-api[] +@RestController +public class CartApi { + private final Cart cart; // a stand-in: each call reaches the caller's own cart + + public CartApi(Cart cart) { + this.cart = cart; + } + + @PostMapping("/cart") + public String add(@RequestParam("sku") String sku) { + return String.valueOf(cart.add(sku)); + } + + @GetMapping("/cart") + public List list() { + return cart.items(); + } +} +// end::backend-session-bean-api[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Checkout.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Checkout.java new file mode 100644 index 00000000000..0da7d11c442 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Checkout.java @@ -0,0 +1,55 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.annotations.Qualifier; +import com.codename1.backend.annotations.Service; + +import java.util.List; + +// tag::backend-bean-candidates[] +@Service +public class Checkout { + private final PaymentProvider preferred; // the @Primary one: CardPayments + + @Autowired + @Qualifier("invoice") + private PaymentProvider invoice; // the one named "invoice" + + @Autowired + private List all; // every PaymentProvider bean + + @Autowired(required = false) + private Audit audit; // null when no bean provides one + + public Checkout(PaymentProvider preferred) { + this.preferred = preferred; + } +// end::backend-bean-candidates[] + + public String describe() { + return preferred.name() + " / " + invoice.name() + " of " + all.size() + + (audit == null ? "" : ", audited"); + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ConsoleMailer.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ConsoleMailer.java new file mode 100644 index 00000000000..e8aa852e2c5 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ConsoleMailer.java @@ -0,0 +1,36 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Profile; + +// tag::backend-bean-profile[] +@Component +@Profile("dev") +public class ConsoleMailer implements Mailer { + public void send(String to, String subject, String body) { + System.out.println("mail to " + to + ": " + subject); + } +} +// end::backend-bean-profile[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Defaults.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Defaults.java new file mode 100644 index 00000000000..cdb2c6e049e --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Defaults.java @@ -0,0 +1,38 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.ConditionalOnMissingBean; +import com.codename1.backend.annotations.Configuration; + +// tag::backend-bean-on-missing[] +@Configuration +public class Defaults { + @Bean + @ConditionalOnMissingBean + public Audit audit() { + return new LogAudit(); // used only when the application declares no Audit + } +} +// end::backend-bean-on-missing[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Digest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Digest.java new file mode 100644 index 00000000000..6fe2fd75027 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Digest.java @@ -0,0 +1,54 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.Tasks; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.ThreadKind; + +// tag::backend-scheduled-config[] +@Service +public class Digest { + @Scheduled(cron = "${digest.cron:0 0 7 * * MON-FRI}", zone = "America/New_York") + public void weekdayMorning() { + // 07:00 New York time on weekdays, unless digest.cron says otherwise + } + + @Scheduled(fixedRateString = "${digest.pollMillis:30000}", initialDelay = 5000, + executor = "polling", thread = ThreadKind.VIRTUAL) + public void poll() { + // every 30 seconds after a 5 second start-up delay, on a virtual thread + } +// end::backend-scheduled-config[] + +// tag::backend-tasks[] + public void rebuildLater() { + Tasks.platform(new Runnable() { + public void run() { + // runs once, on the default pool of platform threads + } + }); + } +// end::backend-tasks[] +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ExpensiveIndex.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ExpensiveIndex.java new file mode 100644 index 00000000000..6fe418286db --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ExpensiveIndex.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * 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.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Lazy; +import com.codename1.backend.annotations.PostConstruct; + +import java.util.HashMap; +import java.util.Map; + +// tag::backend-bean-lazy[] +@Component +@Lazy +public class ExpensiveIndex { + private Map index; + + public ExpensiveIndex() { + // Runs at start-up for the generated stand-in too: keep it cheap. + } + + @PostConstruct + void load() { + // Runs once, on the real instance, the first time something calls it. + index = new HashMap(); + } + + public String lookup(String key) { + return index.get(key); + } +} +// end::backend-bean-lazy[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Imports.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Imports.java new file mode 100644 index 00000000000..8a6633c36e9 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Imports.java @@ -0,0 +1,63 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Propagation; +import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.Transactional; + +import java.io.IOException; +import java.util.List; + +// tag::backend-tx-nested[] +@Service +public class Imports { + private final DataSource db; + + public Imports(DataSource db) { + this.db = db; + } + + @Transactional + public int importAll(List lines) { + int imported = 0; + for (String line : lines) { + try { + importLine(line); // a call through this: still transactional + imported++; + } catch (Exception bad) { + // Only this line's rows were rolled back, to its savepoint. + } + } + return imported; // the good lines commit together + } + + @Transactional(propagation = Propagation.NESTED) + void importLine(String line) throws IOException { + String[] fields = line.split(","); + db.execute("INSERT INTO contact (name) VALUES (?)", new Object[] {fields[0]}); + db.execute("INSERT INTO phone (number) VALUES (?)", new Object[] {fields[1]}); + } +} +// end::backend-tx-nested[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/InvoicePayments.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/InvoicePayments.java new file mode 100644 index 00000000000..449c749c9d2 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/InvoicePayments.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * 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.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Component; + +/** Named "invoice", so an injection point can ask for it with @Qualifier. */ +@Component("invoice") +public class InvoicePayments implements PaymentProvider { + public String name() { + return "invoice"; + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Limits.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Limits.java new file mode 100644 index 00000000000..af62a53fa84 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Limits.java @@ -0,0 +1,37 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Value; + +// tag::backend-bean-factory[] +@Configuration +public class Limits { + @Bean + public RateLimiter signupLimiter(@Value("${signups.perMinute:30}") int perMinute) { + return new RateLimiter(perMinute); + } +} +// end::backend-bean-factory[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/LogAudit.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/LogAudit.java new file mode 100644 index 00000000000..deccb9c07a9 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/LogAudit.java @@ -0,0 +1,30 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +/** The fallback Audit: it writes to the console. */ +public class LogAudit implements Audit { + public void record(String event) { + System.out.println("audit: " + event); + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Login.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Login.java new file mode 100644 index 00000000000..22ac0c4b079 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Login.java @@ -0,0 +1,43 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.HttpSession; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +// tag::backend-session[] +@RestController +public class Login { + @PostMapping("/login") + public String login(HttpServer.Request request, @RequestParam("user") String user) { + // ... check the credentials ... + HttpSession session = request.getSession(true); + session.changeSessionId(); // never keep an id from before the login + session.setAttribute("user", user); + return "ok"; + } +} +// end::backend-session[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/MailSettings.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/MailSettings.java new file mode 100644 index 00000000000..1203214ee81 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/MailSettings.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * 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.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.ConfigurationProperties; + +// tag::backend-bean-properties[] +@Component +@ConfigurationProperties("mail") +public class MailSettings { + private String host = "localhost"; // kept when mail.host is not set + private int port = 25; + private boolean startTls; + + public void setHost(String host) { // mail.host + this.host = host; + } + + public void setPort(int port) { // mail.port + this.port = port; + } + + public void setStartTls(boolean startTls) { // mail.startTls or mail.start-tls + this.startTls = startTls; + } +// end::backend-bean-properties[] + + public String describe() { + return host + ":" + port + (startTls ? " (STARTTLS)" : ""); + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Mailer.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Mailer.java new file mode 100644 index 00000000000..18eea49e1a5 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Mailer.java @@ -0,0 +1,28 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +/** Sends mail. An interface, so a test can hand a service a fake one. */ +public interface Mailer { + void send(String to, String subject, String body) throws java.io.IOException; +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OpsSettings.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OpsSettings.java new file mode 100644 index 00000000000..e786525cae0 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OpsSettings.java @@ -0,0 +1,34 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.EnableManagement; + +/** The Backend chapters' management example, compiled so it cannot drift. */ +// tag::backend-enable-management[] +@Configuration +@EnableManagement(path = "/ops") +public class OpsSettings { +} +// end::backend-enable-management[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OrderMetrics.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OrderMetrics.java new file mode 100644 index 00000000000..cc181331a19 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OrderMetrics.java @@ -0,0 +1,63 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.metrics.Counter; +import com.codename1.backend.metrics.Gauge; +import com.codename1.backend.metrics.Histogram; +import com.codename1.backend.metrics.Metrics; + +import java.util.ArrayList; +import java.util.List; + +// tag::backend-metrics-custom[] +public final class OrderMetrics { + private static final Counter PLACED = + Metrics.counter("orders.placed", "Orders placed", "{order}"); + private static final Histogram TOTALS = + Metrics.histogram("orders.total", "Order totals", "USD"); + private static final List QUEUE = new ArrayList(); + + static { + Metrics.gauge("orders.queued", "Orders waiting to ship", "{order}", + new Gauge.Source() { + public double read() { // read when metrics are collected + synchronized (QUEUE) { + return QUEUE.size(); + } + } + }); + } + + public static void placed(String id, double total) { + PLACED.increment(); + TOTALS.record(total); + synchronized (QUEUE) { + QUEUE.add(id); + } + } +// end::backend-metrics-custom[] + + private OrderMetrics() { + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Orders.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Orders.java new file mode 100644 index 00000000000..e748362aafd --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Orders.java @@ -0,0 +1,57 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.Transactional; + +import java.io.IOException; + +// tag::backend-tx-rules[] +@Service +public class Orders { + private final DataSource db; + private final AuditLog audit; + + public Orders(DataSource db, AuditLog audit) { + this.db = db; + this.audit = audit; + } + + @Transactional(rollbackFor = PaymentDeclined.class, timeout = 10) + public long place(String sku, int quantity) throws IOException, PaymentDeclined { + long id = db.insert("INSERT INTO orders (sku, quantity) VALUES (?, ?)", + new Object[] {sku, Integer.valueOf(quantity)}, "id"); + audit.record("order " + id + " attempted"); // kept even if this rolls back + charge(id); // may throw PaymentDeclined + return id; + } +// end::backend-tx-rules[] + + private void charge(long order) throws PaymentDeclined { + if (order < 0) { + throw new PaymentDeclined("declined"); + } + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Outbox.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Outbox.java new file mode 100644 index 00000000000..c1156744675 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Outbox.java @@ -0,0 +1,63 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.PreDestroy; +import com.codename1.backend.annotations.Repository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +// tag::backend-bean-lifecycle[] +@Repository +public class Outbox { + private final DataSource db; + private final List pending = new ArrayList(); + + public Outbox(DataSource db) { + this.db = db; + } + + @PostConstruct + void createTable() throws IOException { + // Every dependency is built, injected and initialized by now. + db.execute("CREATE TABLE IF NOT EXISTS outbox (message VARCHAR(500))", null); + } + + public synchronized void add(String message) { + pending.add(message); + } + + @PreDestroy + synchronized void flush() throws IOException { + // After the last request has finished, before the pool closes. + for (String message : pending) { + db.execute("INSERT INTO outbox (message) VALUES (?)", new Object[] {message}); + } + pending.clear(); + } +} +// end::backend-bean-lifecycle[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PaymentDeclined.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PaymentDeclined.java new file mode 100644 index 00000000000..8872e873238 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PaymentDeclined.java @@ -0,0 +1,30 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +/** A checked exception that should still undo the order. */ +public class PaymentDeclined extends Exception { + public PaymentDeclined(String message) { + super(message); + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PaymentProvider.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PaymentProvider.java new file mode 100644 index 00000000000..c6041831554 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PaymentProvider.java @@ -0,0 +1,28 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +/** Something that takes a payment. Several beans implement it. */ +public interface PaymentProvider { + String name(); +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PriceCache.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PriceCache.java new file mode 100644 index 00000000000..aaa0bc4a41b --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PriceCache.java @@ -0,0 +1,57 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Counted; +import com.codename1.backend.annotations.ManagedAttribute; +import com.codename1.backend.annotations.ManagedOperation; +import com.codename1.backend.annotations.ManagedResource; +import com.codename1.backend.annotations.Timed; + +import java.util.LinkedHashMap; +import java.util.Map; + +// tag::backend-managed[] +@Component +@ManagedResource(objectName = "prices") +public class PriceCache { + private final Map prices = new LinkedHashMap(); + + @ManagedAttribute(description = "Cached prices", unit = "{entry}") + public synchronized int getSize() { + return prices.size(); + } + + @ManagedOperation(description = "Drops every cached price") + public synchronized void clear() { + prices.clear(); + } + + @Timed + @Counted + public synchronized Double price(String sku) { + return prices.get(sku); + } +} +// end::backend-managed[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RateLimiter.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RateLimiter.java new file mode 100644 index 00000000000..2c690970583 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RateLimiter.java @@ -0,0 +1,39 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +/** + * A class from a library: it knows nothing about beans, so it cannot carry + * annotations, and a @Bean method is how it becomes one. + */ +public final class RateLimiter { + private final int perMinute; + + public RateLimiter(int perMinute) { + this.perMinute = perMinute; + } + + public int getPerMinute() { + return perMinute; + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ReminderService.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ReminderService.java new file mode 100644 index 00000000000..f28004946ff --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ReminderService.java @@ -0,0 +1,55 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.session.Session; +import com.codenameone.developerguide.backend.Reminder; + +import java.util.Date; + +// tag::backend-tx-session[] +@Service +public class ReminderService { + private final Session session; // the current transaction's session + + public ReminderService(Session session) { + this.session = session; + } + + @Transactional + public long remind(String title) { + Reminder reminder = new Reminder(); + reminder.title = title; + reminder.due = new Date(); + session.persist(reminder); // written by the time the transaction commits + return reminder.id; + } + + @Transactional(readOnly = true) + public Reminder find(long id) { + return session.find(Reminder.class, Long.valueOf(id)); + } +} +// end::backend-tx-session[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Reports.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Reports.java new file mode 100644 index 00000000000..b92e226f9f1 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Reports.java @@ -0,0 +1,60 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.AsyncResult; +import com.codename1.backend.annotations.Async; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.ThreadKind; + +import java.util.concurrent.Future; + +// tag::backend-background[] +@Service +public class Reports { + @Async + public Future monthly(String month) { + return AsyncResult.of(render(month)); + } + + @Async(thread = ThreadKind.VIRTUAL) + public void notifyWebhooks(String event) { + // outbound calls that mostly wait + } + + @Scheduled(cron = "0 0 3 * * *", zone = "Europe/Berlin") + public void nightly() { + // every day at 03:00 Berlin time + } + + @Scheduled(fixedDelay = 60000, lock = "purge") + public void purge() { + // once a minute, on one instance at a time + } +// end::backend-background[] + + private String render(String month) { + return "report for " + month; + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RequestClock.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RequestClock.java new file mode 100644 index 00000000000..5e07bca107d --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RequestClock.java @@ -0,0 +1,38 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.RequestScope; + +// tag::backend-bean-request-scope[] +@Component +@RequestScope +public class RequestClock { + private final long started = System.currentTimeMillis(); + + public long elapsed() { + return System.currentTimeMillis() - started; // since THIS request's instance + } +} +// end::backend-bean-request-scope[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchClient.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchClient.java new file mode 100644 index 00000000000..df41fabd320 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchClient.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * 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.codenameone.developerguide.backend.beans; + +/** + * A client from a library: it has a lifecycle, but it knows nothing about + * beans and carries no annotations. + */ +public class SearchClient { + private final String url; + private boolean open; + + public SearchClient(String url) { + this.url = url; + } + + public void connect() { + open = true; + } + + public void close() { + open = false; + } + + public boolean isOpen() { + return open; + } + + public String getUrl() { + return url; + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchConfig.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchConfig.java new file mode 100644 index 00000000000..f0b25845826 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchConfig.java @@ -0,0 +1,37 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Value; + +// tag::backend-bean-init-destroy[] +@Configuration +public class SearchConfig { + @Bean(initMethod = "connect", destroyMethod = "close") + public SearchClient searchClient(@Value("${search.url:http://localhost:9200}") String url) { + return new SearchClient(url); + } +} +// end::backend-bean-init-destroy[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchIndexer.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchIndexer.java new file mode 100644 index 00000000000..879f9a96d64 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchIndexer.java @@ -0,0 +1,36 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.ConditionalOnProperty; + +// tag::backend-bean-on-property[] +@Component +@ConditionalOnProperty(value = "search.enabled", havingValue = "true") +public class SearchIndexer { + public void index(String document) { + // ... + } +} +// end::backend-bean-on-property[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SignupApi.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SignupApi.java new file mode 100644 index 00000000000..0d4b23f8b9e --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SignupApi.java @@ -0,0 +1,50 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +// tag::backend-bean-controller[] +@RestController +public class SignupApi { + private final Signups signups; + + public SignupApi(Signups signups) { + this.signups = signups; + } + + @PostMapping("/signups") + public String signUp(@RequestParam("email") String email) throws Exception { + signups.register(email); + return "ok"; + } + + @GetMapping("/signups/count") + public String count() throws Exception { + return String.valueOf(signups.count()); + } +} +// end::backend-bean-controller[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java new file mode 100644 index 00000000000..a29906c7c1b --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java @@ -0,0 +1,68 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.Transactional; + +import java.io.IOException; + +// tag::backend-bean-service[] +@Service +public class Signups { + private final DataSource db; + + @Autowired + private Mailer mailer; + + public Signups(DataSource db) { + this.db = db; + } + + @PostConstruct + void createTable() throws IOException { + db.execute("CREATE TABLE IF NOT EXISTS signup (email VARCHAR(200))", null); + } +// end::backend-bean-service[] + +// tag::backend-transactional[] + @Transactional(rollbackFor = IOException.class) + public void register(String email) throws IOException { + db.execute("INSERT INTO signup (email) VALUES (?)", new Object[] {email}); + // Throws when the mail server refuses: the insert above is rolled back, + // because both run in the one transaction this method began. A failed + // statement would roll back on its own -- it is a DataAccessException -- + // but the mailer's IOException is checked, so it takes rollbackFor. + mailer.send(email, "Welcome", "Thanks for signing up."); + } +// end::backend-transactional[] + + @Transactional(readOnly = true) + public int count() throws IOException { + java.util.Map row = db.queryOne("SELECT COUNT(*) AS n FROM signup", null); + return ((Number) row.get("n")).intValue(); + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SmtpMailer.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SmtpMailer.java new file mode 100644 index 00000000000..c635175186c --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SmtpMailer.java @@ -0,0 +1,43 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.Profile; +import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.Value; + +// tag::backend-bean-value[] +@Service +@Profile("!dev") +public class SmtpMailer implements Mailer { + @Value("${mail.host:localhost}") + private String host; + + @Value("${mail.port:25}") + private int port; + + public void send(String to, String subject, String body) { + // ... talk to host:port ... + } +} +// end::backend-bean-value[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SupportTools.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SupportTools.java new file mode 100644 index 00000000000..a5ca6fbef36 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SupportTools.java @@ -0,0 +1,51 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.annotations.McpParam; +import com.codename1.backend.annotations.McpTool; +import com.codename1.backend.annotations.Service; + +// tag::backend-mcp-tool[] +@Service +public class SupportTools { + private final Signups signups; + + public SupportTools(Signups signups) { + this.signups = signups; + } + + @McpTool(description = "Counts the people who signed up. Use it before " + + "answering a question about growth.") + public int signupCount() throws Exception { + return signups.count(); + } + + @McpTool(description = "Signs someone up, as the form on the site would.") + public String signUp(@McpParam(value = "email", description = "Their email address") + String email) throws Exception { + signups.register(email); + return "signed up"; + } +} +// end::backend-mcp-tool[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/WiringTest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/WiringTest.java new file mode 100644 index 00000000000..94f5cbc1e32 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/WiringTest.java @@ -0,0 +1,53 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.beans; + +import com.codename1.backend.Backend; +import com.codename1.backend.Config; + +import java.util.Properties; + +/** How a test starts the build's wiring, as a Spring Boot test starts a context. */ +public final class WiringTest { + private WiringTest() { + } + + // `wiring` is `new BackendWiring()`: the class the build generates beside the + // entry point, which a test in the backend module can name directly. + static void run(Backend.Application wiring) throws Exception { +// tag::backend-wiring-test[] +Properties settings = new Properties(); +settings.setProperty("cn1.server.port", "0"); // any free port +Backend server = Backend.builder(Config.of(settings, "test")) + .quiet() + .application(wiring) + .start(); +try { + int port = server.getServer().getPort(); + // ... send requests to http://127.0.0.1: and assert on the answers ... +} finally { + server.stop(); +} +// end::backend-wiring-test[] + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/Order.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/Order.java new file mode 100644 index 00000000000..f4be1f84784 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/Order.java @@ -0,0 +1,39 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.orders; + +import com.codename1.annotations.JsonProperty; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** The Backend chapter's order example, compiled so it cannot drift. */ +// tag::backend-dto-json[] +public class Order { + public long id; + public String customer; + @JsonProperty("placed_at") + public Date placedAt; + public List lines = new ArrayList(); +} +// end::backend-dto-json[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/OrderLine.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/OrderLine.java new file mode 100644 index 00000000000..d9c0836b271 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/OrderLine.java @@ -0,0 +1,31 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.orders; + +/** One line of the Backend chapter's order example. */ +// tag::backend-dto-json[] +public class OrderLine { + public String sku; + public int quantity; +} +// end::backend-dto-json[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/OrdersApi.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/OrdersApi.java new file mode 100644 index 00000000000..b3eb2eebdcb --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/OrdersApi.java @@ -0,0 +1,59 @@ +/* + * 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. + */ +package com.codenameone.developerguide.backend.orders; + +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PathVariable; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.RequestBody; +import com.codename1.backend.annotations.RequestMapping; +import com.codename1.backend.annotations.RestController; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +/** The Backend chapter's DTO controller example, compiled so it cannot drift. */ +// tag::backend-dto-json[] +@RestController +@RequestMapping("/orders") +public class OrdersApi { + private final Map orders = + Collections.synchronizedMap(new LinkedHashMap()); + private final AtomicLong ids = new AtomicLong(); + + @PostMapping + public Order place(@RequestBody Order order) { + order.id = ids.incrementAndGet(); + order.placedAt = new Date(); + orders.put(Long.valueOf(order.id), order); + return order; + } + + @GetMapping("/{id}") + public Order find(@PathVariable("id") long id) { + return orders.get(Long.valueOf(id)); // null is a 404 + } +} +// end::backend-dto-json[] diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc new file mode 100644 index 00000000000..e7ddbe7f11e --- /dev/null +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -0,0 +1,462 @@ +[[backend-beans]] +== Backend beans and dependency injection + +A server that does everything in its controllers turns into classes nobody can +test. This chapter covers the alternative Spring made standard: split the work +into beans, let each one declare what it depends on, and have something else +build them and hand them over. Here that something is the build, which is the +difference worth keeping in mind throughout: every decision below is made while +the project compiles, and a decision the build can't make is an error before the +server ever starts. + +=== Services and dependency injection + +A controller that does everything itself turns into a class nobody can test. +The usual split is Spring's: controllers translate HTTP, services hold the logic, +repositories hold the SQL, and each one receives what it depends on instead of +building it. The annotations are Spring's too, under +`com.codename1.backend.annotations`: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java[tag=backend-bean-service,indent=0] +---- + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SignupApi.java[tag=backend-bean-controller,indent=0] +---- + +`@Service`, `@Component` and `@Repository` mark a bean; a `@RestController` or +`@WebSocketMapping` class is one too. A class with one constructor gets that +constructor; with several, the one marked `@Autowired`. Fields and setters are +injected only when marked `@Autowired`, and a private field is fine. + +What makes this different from Spring is when it happens. The build finds every +bean, decides which constructor each gets and which bean fills each injection +point, and writes that down as a class called `BackendWiring` -- a list of `new` +calls, setter calls and `@PostConstruct` calls in dependency order. At run time +there is no container, no classpath scan and no reflection, so a server with +twenty beans starts as fast as one with none, and the translator can still drop +every class nothing reaches. A dependency that can't be satisfied is a build +error that names the injection point, rather than a server that fails its first +request: + +---- +constructor parameter 1 of com.example.SignupApi needs a com.example.Signups, +and no bean has that type. Annotate the implementing class @Component, +@Service or @Repository, or declare a @Bean method returning one. +---- + +Two beans of one type are an error too, until one is marked `@Primary` or the +injection point names one with `@Qualifier`. Constructors that depend on each +other in a circle are refused; the same circle through `@Autowired` fields is +fine, because fields are set after every bean exists. + +=== Declaring beans + +A bean is a class the build constructs once per server and hands to everything +that needs it. These annotations make a class one: + +[cols="2,3", options="header"] +|=== +| Annotation | Use it for + +| `@Component` +| Anything the application wires together. + +| `@Service`, `@Repository` +| The same as `@Component`. They exist so a class can say which layer it + belongs to, as in Spring. + +| `@Configuration` +| A class whose `@Bean` methods produce beans, described under + <>. It's a bean itself, so it's injected too. + +| `@RestController`, `@WebSocketMapping` +| A controller or a WebSocket endpoint. Both are beans, and both are always + singletons. +|=== + +A bean's name is the simple class name with its first letter lowercased -- +`smtpMailer` for `SmtpMailer` -- unless the annotation gives one, as +`@Component("invoice")` does. Names matter only to `@Qualifier`, and two beans +with the same name are a build error. + +A bean has to be a class the build can construct: concrete, and static if it's +nested. It also has to be public unless it's in the package the generated entry +point lives in, since the generated code has to name it. + +=== Injection points + +A bean receives its dependencies in three places, all resolved by type: + +* **The constructor.** A class with one constructor gets that one. With several, + the build takes the one marked `@Autowired`, or failing that the one with no + arguments; with neither it can't choose and says so. Constructor injection is + the one to prefer, because the object is complete the moment it exists and a + test builds it with `new`. +* **A field** marked `@Autowired`. A private field is fine: the build adds a + setter to the compiled class and the generated code calls it, so there is no + reflection. A `final` field can only be set by the constructor, and marking one + `@Autowired` is a build error that says so. +* **A method** marked `@Autowired`, called once with its parameters resolved like + a constructor's. + +Injection points and lifecycle methods declared in a superclass count too, and are +handled before the subclass's own, and the same goes for `@Scheduled`, `@McpTool` and managed +methods: a job a base class declares runs for every bean that inherits it. A method the subclass overrides follows the +subclass's annotations, not the superclass's. The build can only do this for a +superclass compiled from the project's sources: a base class from a jar that has +injected fields, or lifecycle methods that aren't public, is a build error telling +you to take those dependencies in the subclass instead. + +`@Autowired(required = false)` leaves the point `null` when no bean has its type, +where a required point is a build error. A `List` or `Collection` receives +every bean of type `T`: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Checkout.java[tag=backend-bean-candidates,indent=0] +---- + +When several beans have the injected type and the point names none, the build +takes the one marked `@Primary`: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/CardPayments.java[tag=backend-bean-primary,indent=0] +---- + +Without one it's an error listing the candidates, and two `@Primary` beans of one +type are an error too. `@Qualifier("invoice")` names the bean a point wants and +overrides `@Primary`. The single exception is a set of candidates that are all +conditional -- a `@Profile("dev")` bean and a `@Profile("!dev")` one -- where at +most one exists at run time and the generated code picks whichever does. + +Some types aren't beans the application declares, but the server provides them +and they're injected the same way: + +[cols="2,3", options="header"] +|=== +| Type | What it receives + +| `Config` +| The server's configuration, for keys read at run time. + +| `DataSource` +| The connection pool. A bean that takes one makes a database required, and a + server with none configured refuses to start with a message naming the bean. + +| `orm.EntityManager` +| The entity manager over that pool, when the module has entities. + +| `com.codename1.orm.session.Session` +| The current transaction's persistence session, described under + <>. + +| `HttpServer.Request` +| The current request. Only a `@RequestScope` bean can take it. + +| `HttpSession` +| The current session. Only a `@RequestScope` bean can take it. +|=== + +=== Configuration values + +`@Value` reads a setting, with a fallback after the colon, and converts it to the +field's type: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SmtpMailer.java[tag=backend-bean-value,indent=0] +---- + +The value comes from the layers described under <>, +so `MAIL_HOST` in the environment overrides the file. A +key with no fallback that nothing sets stops the server at startup with the key's +name. `@ConfigurationProperties("mail")` on a bean does the same for every one of +its setters at once: `setHost` reads `mail.host`, and `setMaxSize` reads +`mail.maxSize` or `mail.max-size`. + +`@Value` converts to a `String`, a primitive or its box, or an enum; a field +of any other type is a build error. A key that has no fallback and isn't set in +`application.properties` gets a build warning, since it can still come from the +environment at run time. + +`@ConfigurationProperties` suits a group of related settings better than a +`@Value` per field, and a bean that holds them is an ordinary bean that others +inject: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/MailSettings.java[tag=backend-bean-properties,indent=0] +---- + +Each setter taking a `String`, a number, a boolean or an enum is bound from the +prefix plus the property name, in camel case or kebab case. A setter whose key +isn't set is never called, so the field keeps its initial value -- which is where +a default belongs. A prefix that binds no setter at all is a build error, since it +means the class and the prefix disagree. + +=== Profiles and factory methods + +`@Profile` and `@ConditionalOnProperty` make a bean depend on the deployment: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ConsoleMailer.java[tag=backend-bean-profile,indent=0] +---- + +The profile is the one choice the build can't make for you, so it becomes a +single `if` in the generated startup code. `@ConditionalOnMissingBean` is decided +entirely at build time: the bean steps aside when the application declares +another of its type. + +A class from a library can't carry annotations. A `@Bean` method in a +`@Configuration` class makes one: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Limits.java[tag=backend-bean-factory,indent=0] +---- + +The build calls the method once, directly. There is no proxy around the +configuration class, so one `@Bean` method calling another gets a second object; +take the other bean as a parameter instead, which the build fills like a +constructor's. + +==== Conditions in detail + +`@Profile` takes one or more profile names, and the bean exists when any of them +is active. A name starting with `!` is negated, so `@Profile("!prod")` covers +every profile but `prod`. + +`@ConditionalOnProperty` makes a bean depend on a key: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchIndexer.java[tag=backend-bean-on-property,indent=0] +---- + +With `havingValue`, the key must equal that value, compared ignoring case. +Without it, any value but `false` counts. `matchIfMissing = true` makes an unset +key count as a match, and several keys in `value` must all match. `prefix` is put +in front of each key with a dot. + +Both are evaluated once, at start-up, as one `if` around the bean's construction. +Which beans are on depends on the deployment's configuration, so it's the start that +checks it, as Spring's does: a bean that requires one whose conditions are all off +under the running configuration stops the server from starting, with an error +naming the injection point. Cover the other case with a bean of the same type under +the opposite condition, make the dependency optional, or give the dependent bean +the same condition. + +`@ConditionalOnMissingBean` is the one condition decided entirely at build time. +It marks a default that steps aside when the application declares another bean it +would compete with, which is how a module offers a fallback: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Defaults.java[tag=backend-bean-on-missing,indent=0] +---- + +With no arguments, "another bean it would compete with" means one that has any of +the types the default can be injected as, apart from the JDK's own: its class, its +superclasses and its interfaces. On a class, `DefaultMailer implements Mailer` +steps aside for any other `Mailer`. On a `@Bean` method they're the declared +return type and the types above it, which is why the method above returns `Audit` +rather than `LogAudit`. `@ConditionalOnMissingBean(Audit.class)` names the types +to compete on explicitly, for a class whose default set is too wide or too narrow. + +A `@Bean` method inherits the conditions of the `@Configuration` class it's +declared in, static methods included. A `@Profile("prod")` configuration class +therefore contributes nothing on any other profile, which is what the annotation on +the class says. The same holds for `@ConditionalOnMissingBean` on the class: a +configuration class that steps aside takes its `@Bean` methods with it. + +==== Beans with a lifecycle of their own + +A library class often has methods to start and stop it, and no annotations to say +so. `@Bean` names them: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchConfig.java[tag=backend-bean-init-destroy,indent=0] +---- + +`initMethod` runs after the factory method returns, where a class's +`@PostConstruct` would. `destroyMethod` runs where its `@PreDestroy` would: at +shutdown for a singleton, at the end of the request for a request-scoped bean, and +when the session ends for a session-scoped one. + +=== Scopes + +A bean is a singleton unless it says otherwise. `@Scope("prototype")` gives each +injection point its own instance. `@RequestScope` and `@SessionScope` give one +per HTTP request or session; injected into a singleton, such a bean is reached +through a small generated subclass that finds the current request's instance on +each call, so the class can't be final and needs a constructor that takes no +arguments. `@Lazy` builds a bean the first time it's used, through the same kind +of subclass. That subclass has to run the bean's constructor once at startup to +exist at all, so the expensive part of setting a bean up belongs in its +`@PostConstruct` method, which runs only on the real instance. + +[cols="2,3", options="header"] +|=== +| Scope | Instances + +| singleton, the default +| One per server, built at start-up in dependency order. + +| `@Scope("prototype")` +| A new instance for each injection point. Two fields of type `Ticket` in one + class get two tickets. + +| `@RequestScope`, or `@Scope("request")` +| One per HTTP request, built the first time the request uses it and destroyed + when the request ends. + +| `@SessionScope`, or `@Scope("session")` +| One per HTTP session, kept by the server for as long as the session lives and + destroyed when it ends. See <>. + +| `@Lazy`, on a singleton +| One per server, built the first time something calls it. +|=== + +A request-scoped bean looks like any other to the code that uses it: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RequestClock.java[tag=backend-bean-request-scope,indent=0] +---- + +A controller that injects `RequestClock` gets the generated stand-in, and each +call on it reaches the instance belonging to the request being served. Two +requests served at once see two instances. + +The stand-in extends the bean's class and overrides its methods, which is where the +rules for a scoped or lazy bean come from. The class can't be final, a method it +declares or inherits can't be final either -- a call to it would run on the stand-in +instead of the bean -- and it needs a constructor with no arguments, which may be +protected. Methods inherited from a library class are forwarded too, so the library +has to be on the build's classpath. Inject anything else through fields. Each rule +is checked by the build. + +`@Lazy` suits a bean that's expensive to prepare and not always needed: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ExpensiveIndex.java[tag=backend-bean-lazy,indent=0] +---- + +A WebSocket endpoint must be a singleton, since it serves the whole life of the +server rather than one request. A bean with `@Scheduled` methods can't be +request- or session-scoped, because a job runs outside any request. A +prototype-scoped one only draws a warning, as in Spring: one instance is built to +run its jobs and is never destroyed, so make it a singleton if it needs +`@PreDestroy`. + +=== Lifecycle + +Beans are built in dependency order, and a bean's `@PostConstruct` method runs +once it and everything it depends on has been built and injected. At shutdown, +`@PreDestroy` methods run in the reverse order, so a bean's dependencies are still +there when its own method runs. Within one bean the same rule holds for its class +hierarchy: a superclass's `@PostConstruct` runs before the subclass's, and a +subclass's `@PreDestroy` runs before the superclass's: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Outbox.java[tag=backend-bean-lifecycle,indent=0] +---- + +Both methods take no arguments and may have any visibility; the build adds a +public bridge to reach a private one. Stopping a server goes in this order, which +is what makes the `flush` above safe: + +. Scheduled jobs stop starting, so none begins while the server drains. +. The server stops accepting and waits, up to + `cn1.server.shutdownTimeoutMillis`, for the requests in flight. +. `@Async` calls and scheduled runs that haven't finished get the same grace. A + task still waiting in a queue when that time is up is dropped rather than + started against beans about to be destroyed, and the threads of the ones still + running are interrupted. +. `@PreDestroy` and `destroyMethod` run, in reverse dependency order. +. The connection pool closes. +. The last metrics and spans are exported. + +A start-up that fails partway runs the destroy methods of the beans already +built, so a server a supervisor restarts doesn't leak what their constructors +opened. + +=== What the build refuses + +Everything a container would discover at start-up is checked while the module +compiles, and each refusal names the class and member at fault: + +[cols="2,3", options="header"] +|=== +| Message fragment | What to do + +| `needs a X, and no bean has that type` +| Annotate the implementation `@Component`, `@Service` or `@Repository`, or add + a `@Bean` method returning one. + +| `could receive any of ...` +| Mark one `@Primary`, or inject with `@Qualifier("name")`. + +| `The constructors form a cycle` +| Inject one side through an `@Autowired` field or setter, which is set after + every bean exists. + +| `is final, so it can only be set by the constructor` +| Take the dependency as a constructor parameter. + +| `and none is marked @Autowired or takes no arguments` +| Mark the constructor the build should call. + +| `is reached through a class the build generates to stand in for it` +| A scoped or lazy bean: make the class and its methods non-final and give it a + constructor with no arguments. + +| `asks for the current request` +| Only a `@RequestScope` bean can inject the request. Take it as a parameter of + the handler method instead. + +| `Two beans are named` +| Give one a name of its own in its annotation. +|=== + +=== How this differs from Spring + +The annotations mean what they mean in Spring, and most code reads the same. The +differences all follow from doing the work at build time: + +* **No scanning.** A bean is a class in the module, or the result of a `@Bean` + method. A jar on the class path contributes nothing unless a `@Bean` method + builds something from it. +* **No proxies for aspects.** `@Transactional`, `@Async`, `@Timed` and `@Counted` + rewrite the method itself, so they apply to a call through `this`, to a private + method, and to an object built with `new`. In Spring each of those skips the + aspect, and nothing says so. +* **No proxy around `@Configuration`.** One `@Bean` method calling another gets a + second object. Take the other bean as a parameter instead. +* **Errors at build time.** A missing or ambiguous dependency, a constructor + cycle, a scoped bean that can't be subclassed and a malformed cron expression + all fail the build. +* **No `ApplicationContext`.** Nothing looks a bean up by name or type at run + time. A class that needs a bean declares it as a dependency. + +=== Testing beans and the wired server + +Constructor injection is a constructor, so a unit test builds the object with +fakes and needs nothing else. For the wired server -- what `@SpringBootTest` +does in Spring -- a test in the backend module starts the generated wiring on a +free port: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/WiringTest.java[tag=backend-wiring-test,indent=0] +---- diff --git a/docs/developer-guide/Backend-Data.asciidoc b/docs/developer-guide/Backend-Data.asciidoc new file mode 100644 index 00000000000..763c3cbb2e2 --- /dev/null +++ b/docs/developer-guide/Backend-Data.asciidoc @@ -0,0 +1,397 @@ +[[backend-data]] +== Backend data access and transactions + +A server's data lives in a database, and this chapter is about reaching it: the +connection pool, which speaks to SQLite, PostgreSQL and MySQL with one dialect of +SQL, the object mapping the build generates daos from, and the transactions that +make several statements one unit of work. The last of those gets the most room, +because declarative transactions are where a Spring developer's intuitions are +most likely to be almost right. + +=== Talking to a database + +The server opens a connection pool from `cn1.datasource.url` -- a SQLite path or +a PostgreSQL or MySQL URL -- and injects it as a `DataSource` into any bean that +asks for one. It returns rows as the same Java types whichever engine answered: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java[tag=backend-database,indent=0] +---- + +There is no JDBC driver involved. SQLite is linked into the binary, and the +PostgreSQL and MySQL clients speak their wire protocols directly, and a +`mariadb://` URL is served by the same client. MySQL 8.0 and MariaDB 10.4 and +newer are supported: a string key needs a case-sensitive NO PAD collation, so +`"A"` and `"a"` are two keys and `"token "` keeps its space, and the two server +families spell that collation differently. Which one is used comes from the +server rather than from the URL scheme, so a `mysql://` URL pointed at MariaDB +is still correct. + +Statements are written once, in one portable form: `?` for every parameter, and +plain unquoted names. PostgreSQL binds `$1` rather than `?`, and that difference +stops inside `execute` and `query` rather than at every call site. SQL already +written for one engine keeps working, because a statement carrying no `?` at all +is passed through untouched -- so hand-written `$1` is left alone. A literal +question mark that isn't a parameter is written `??`, which matters on +PostgreSQL, whose `jsonb` operators are spelled `?`, `?|` and `?&`. + +A parameter count that doesn't match the statement is refused before the +statement is sent. The three engines answer a mismatch three different ways, and +SQLite's answer is to bind the missing parameters to NULL and commit the row. + +The other thing the engines disagree about is the key an insert generated. +`insert` asks whichever way this engine answers -- `last_insert_rowid()` on +SQLite, `LAST_INSERT_ID()` on MySQL, and `INSERT ... RETURNING` on PostgreSQL, +which has no last-insert-id concept at all: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java[tag=backend-database-insert,indent=0] +---- + +Several statements that have to be one go through `inTransaction`, which holds a +single connection for the whole body and rolls back if it throws: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java[tag=backend-database-transaction,indent=0] +---- + +Everything each engine spells differently is reachable through `db.dialect()`, +which is what code that generates schema needs: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java[tag=backend-database-dialect,indent=0] +---- + +Quoting every identifier isn't caution. PostgreSQL folds an unquoted name to +lower case while SQLite and MySQL preserve it, so a column called `createdAt` +becomes `createdat` on one engine of the three, and code that reads rows by name +stops finding it there. + +A `Database` -- one connection rather than a pool -- is still available through +`Database.open` for code that wants exactly one, and every one of its operations +is synchronized, so sharing one across handlers is safe and serialized. That's the +right shape for a single-file SQLite server and the wrong one for a database +that's a machine across a network: there the single connection isn't a safety +property, it's the bottleneck. The pool is the default for that reason. + +=== Storing objects + +A class with `@Entity` on it gets a data access object written for it at build +time. The annotations are the ones the SQLite ORM chapter documents, and they +are the same annotations in the same package, because an entity is the one class +both halves of an application own: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Reminder.java[tag=backend-orm-entity,indent=0] +---- + +What differs between the app's copy and the server's is what the build generates +from it: a module compiled against the Codename One core gets a dao over the +local SQLite database, and a module compiled against this runtime gets one whose +statements are built for whichever engine the connection turns out to be. The +class says what the data is; the module says where its rows live. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/OrmSnippets.java[tag=backend-orm-dao,indent=0] +---- + +The entity manager comes from the entry point, which opens one when the build +generated at least one entity. A controller asks for it by declaring a +constructor that takes one, and the generated entry point calls that constructor: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/ReminderApi.java[tag=backend-orm-controller,indent=0] +---- + +A controller is a bean, so its constructor can take the entity manager, the pool, +or any service built on them, as <> describes. The generated entry +point holds a `new` with the argument written into it, and a controller that needs +a database nothing configured is refused at start-up rather than handed a null to +fail on later. + +The build writes the entry point, and the daos are registered there: this +runtime has no reflection and the translator drops a class nothing references, +so the generated code's direct reference to every dao is what keeps them in the +binary. Put start-up work in a bean's `@PostConstruct` method. + +Outside the server -- in a unit test, say -- an entity manager is a single call +over a pool: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/OrmSnippets.java[tag=backend-orm-open,indent=0] +---- + +Queries name JAVA FIELDS rather than columns, and the builder quotes the column +each one maps to: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/OrmSnippets.java[tag=backend-orm-query,indent=0] +---- + +A name that isn't a field of the entity is refused at once, listing the +ones that are, rather than reaching the server as a column it doesn't have. +`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `like`, `in`, `isNull` and `isNotNull` are +joined with AND in the order they were added, and `list`, `first`, `count` and +`delete` end the chain. A query the builder can't express takes SQL instead, +through `dao.find(where, params)`, which is the point at which portability +becomes yours to keep. + +Transactions take the same shape as everywhere else: the entity manager the body +is handed is pinned to one connection, so every dao reached through it runs inside +the transaction. Called inside a `@Transactional` method, it joins that +transaction instead of starting another. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/OrmSnippets.java[tag=backend-orm-transaction,indent=0] +---- + +Three things this doesn't do, on purpose. Relationships aren't supported -- +`@OneToMany` and friends don't exist, and a field referencing another entity +fails the build with a message saying to persist the foreign key as a scalar. +`createTable` creates a table that isn't there and does nothing at all to one +that is, so it's a convenience for development and for tests rather than a +migration tool. And a boolean, a date and a char are stored as integers on every +engine -- 0 or 1, epoch milliseconds, and the UTF-16 code unit -- because a +native timestamp comes back as text whose format follows the server's own time +zone and would not round-trip the same way on three engines, and because a char +that was never assigned holds NUL, which PostgreSQL refuses inside a text value. +A field declared as a primitive gets a `NOT NULL` column, because a primitive has +no null to read: a nullable column would load as `0`, `false` or `\0`, which is +indistinguishable from a row that holds those. Declare the field as its boxed +type -- `Integer` rather than `int` -- when the column can be empty. + +=== Transactions + +A transaction makes several statements succeed or fail together. `@Transactional` +declares one around a method, and the build writes the code that begins, commits +and rolls back the transaction around the method's body: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java[tag=backend-transactional,indent=0] +---- + +Everything the method does through the server's `DataSource` joins the +transaction without being handed anything, and so does every method it calls on +the same thread. On a class, `@Transactional` applies to each public method the +class declares. As in Spring, a method it inherits from a superclass isn't +covered until the class overrides it, and the build warns about each one. The +same holds for `@Async` on a class. + +==== How a method joins a transaction + +The transaction belongs to the thread that began it. When a thread inside one +asks the pool for a connection -- directly, through an entity manager's daos, or +through a `DataSource` method -- it gets the transaction's connection back instead +of a pooled one. That's why a service three calls deep takes part without a +parameter for it, and why the programmatic forms, `DataSource.inTransaction` and +`EntityManager.transaction`, run as part of an open transaction rather than +starting a second one. + +The connection is borrowed, and `BEGIN` sent, only when the method first touches +the database. A `@Transactional` method that returns early, or that turns out to +have nothing to write, never takes a connection at all. The same laziness settles +which database a transaction is on: the first pool it touches. Statements through +any other pool run outside it, each committing on its own. + +A transaction doesn't follow work to another thread. An `@Async` method, a task +given to `Tasks`, or a scheduled job runs outside the caller's transaction, and +begins its own if it's `@Transactional` itself. + +==== Propagation + +`propagation` says what a method does when it's called with a transaction already +open. The default, `REQUIRED`, is right for most methods: join the open +transaction, or begin one if there is none. + +.What each propagation does when a transaction is already open +image::img/backend-transaction-propagation.svg["Three timelines: REQUIRED sharing one connection, REQUIRES_NEW suspending the outer transaction and committing on a second connection, NESTED using a savepoint",scaledwidth=95%] + +[cols="2,2,2", options="header"] +|=== +| Propagation | With a transaction open | With none open + +| `REQUIRED` +| Joins it. +| Begins one. + +| `REQUIRES_NEW` +| Suspends it and begins another, on a second connection. +| Begins one. + +| `NESTED` +| Sets a savepoint in it. +| Begins one. + +| `SUPPORTS` +| Joins it. +| Runs without one. + +| `MANDATORY` +| Joins it. +| Throws `TransactionException.IllegalState`. + +| `NOT_SUPPORTED` +| Suspends it and runs without one. +| Runs without one. + +| `NEVER` +| Throws `TransactionException.IllegalState`. +| Runs without one. +|=== + +`REQUIRES_NEW` suits work that must be kept whatever happens to the caller, an +audit record being the usual case: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AuditLog.java[tag=backend-tx-requires-new,indent=0] +---- + +It costs a second connection for as long as it runs, taken from the same pool +while the first one is still held. See the pitfalls at the end of this chapter +before using it on SQLite. + +==== Rollback rules + +The rules are Spring's. An unchecked exception or an `Error` leaving the method +rolls the transaction back, and a checked exception commits it -- with the one +addition that keeps the outcome Spring's, described in the note below. `rollbackFor` +adds exception types that roll back, `noRollbackFor` adds types that commit, and +when several listed types match the thrown one the most specific wins. The +decision is written into the method as a chain of `instanceof` tests, so nothing +reads the annotation when the method runs: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Orders.java[tag=backend-tx-rules,indent=0] +---- + +[IMPORTANT] +==== +In Spring, a statement that fails throws the unchecked `DataAccessException`, so +it rolls the transaction back. The methods of `DataSource`, `Database` and the daos +declare the checked `IOException`, and the failures they report -- a statement the +engine refused, a connection that couldn't be opened, a query that returned more +than one row where one was expected -- are +`com.codename1.backend.DataAccessException`, a subclass of it. The default rule +rolls back for that type as well, so a failed statement undoes the transaction as +it would in Spring. Any other `IOException` -- a file that couldn't be read, a +mail server that refused a message -- is checked and commits, as it would in +Spring; list it in `rollbackFor` to make it roll back, as the `Signups` example +does. `noRollbackFor = DataAccessException.class` restores plain commit-on-checked. +==== + +A method that joined a transaction can't roll back what the method that began it +did before it, so when it fails with an exception that rolls back, it marks the +whole transaction rollback-only instead. The method that began the transaction +then rolls back when it ends. If it ends normally -- because it caught the +exception -- the rollback is reported by throwing +`TransactionException.UnexpectedRollback`, so the caller doesn't take a +rolled-back transaction for a committed one. + +`Transactions.setRollbackOnly()` undoes a transaction without an exception. Called +in the method that began the transaction, it lets that method return normally, +and the transaction rolls back without an error when the method ends. Called in a method +that joined the transaction, it counts as that method failing: the transaction +becomes rollback-only, and the method that began it throws +`TransactionException.UnexpectedRollback` when it ends normally. Called in a +`NESTED` method, it undoes only that method's work: its savepoint is rolled back +when it returns, and the surrounding transaction carries on. + +`Transactions.isActive()` and `Transactions.isRollbackOnly()` answer the obvious +questions about the calling thread. + +==== Read-only transactions and timeouts + +`readOnly = true` begins a transaction the engine is told only reads. PostgreSQL +and MySQL then refuse any write inside it, which turns a read path that writes by +mistake into an error. SQLite accepts the flag and enforces nothing. + +`timeout` is in seconds, counted from the start of the method. It's checked when +the transaction first touches the database, where a transaction already past its +limit fails with an `IOException`, and again at commit, where it's rolled back and +reported with `TransactionException.TimedOut`. It doesn't interrupt a statement +that's running, so it bounds how long a transaction can take to commit rather than +how long any one query may run. + +==== Savepoints + +`NESTED` runs a method inside the open transaction, behind a savepoint. A failure +rolls back to the savepoint and nothing more, and the outer method can go on and +commit. That suits a batch in which one bad item shouldn't cost the rest: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Imports.java[tag=backend-tx-nested,indent=0] +---- + +The loop calls `importLine` through `this`, which in Spring would bypass the +annotation entirely and run every line in the outer transaction without a +savepoint. Here the annotation is part of the method, so the call gets its +savepoint however it's made. + +The savepoints are named by the runtime and released when the method returns +normally. All three engines support them. + +==== The transaction's session + +A bean can inject `com.codename1.orm.session.Session`, the persistence session of +the ORM, and use it inside a transaction: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ReminderService.java[tag=backend-tx-session,indent=0] +---- + +The injected object stands in for the session of whichever transaction is open on +the calling thread. The session is opened on the transaction's connection the +first time it's used, its pending changes are flushed into the transaction before +it commits, and it's closed when the transaction ends. A savepoint that rolls back +also clears the session, since the rows it had loaded since the savepoint no longer +exist. Used outside a transaction, it throws with a message saying to annotate the +method, or to open a session with `EntityManager.openSession()`. + +==== Why a call through this works + +Spring applies `@Transactional` with a proxy: a wrapper object stands between a +caller and the bean, and the transaction happens in the wrapper. A call that +doesn't go through the wrapper -- one from the bean to itself, to a private method, +or on an object the application built with `new` -- gets no transaction, and +nothing warns about it. + +Here the build rewrites the compiled method. Its body moves to a method of its own +and the method keeps its name, now starting the transaction, calling that body, +and committing, with the rollback decision written in. The transaction is +therefore a property of the method, and every call gets it. The same rewriting +gives `@Async`, `@Timed` and `@Counted` the same property. + +==== Pitfalls + +* **Checked exceptions other than database failures commit.** A failed statement + rolls back, but an `IOException` from anything else -- a file, a mail server, an + outbound HTTP call -- commits unless it's listed in `rollbackFor`. +* **`REQUIRES_NEW` needs a second connection.** The outer transaction keeps its + connection while the inner one borrows another, so on a pool of one -- the + default for an in-memory SQLite database -- the inner borrow waits for + `cn1.datasource.pool.borrowTimeoutMillis` and fails. +* **SQLite has one writer.** A transaction that has written holds the database's + write lock until it ends, so a `REQUIRES_NEW` method that writes while its caller + holds the lock waits `cn1.datasource.busyTimeoutMillis` and fails. Reading works. + On SQLite, record the audit row after the outer transaction, or in it. +* **Transactions don't cross threads.** Work handed to `@Async` or `Tasks` isn't + part of the caller's transaction and can't see its uncommitted rows. +* **A transaction holds a connection.** From its first statement until it ends, + the connection is out of the pool. An outbound HTTP call inside a transaction + keeps it there for the length of the call, and enough of those at once starve the + pool. Make the call before the transaction begins, or after it ends. diff --git a/docs/developer-guide/Backend-MCP.asciidoc b/docs/developer-guide/Backend-MCP.asciidoc new file mode 100644 index 00000000000..f2f2efd0b52 --- /dev/null +++ b/docs/developer-guide/Backend-MCP.asciidoc @@ -0,0 +1,188 @@ +[[backend-mcp]] +== Backend MCP and the agent loop + +The Model Context Protocol lets an AI agent call tools on a server. A Codename One +backend serves it at `/mcp`, for two different readers. In production the agent +is a user of the application, and the tools are the ones the application chooses +to publish. In development the agent is the one writing the server, and the tools +let it look inside the running server and exercise it, the way a developer uses a +debugger and a database console. + +=== Serving MCP + +The first reason is the application's own tools. An `@McpTool` method becomes a +tool an agent can call, with its JSON Schema written by the build from the +parameter types: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SupportTools.java[tag=backend-mcp-tool,indent=0] +---- + +The description is what an agent chooses tools by, so it says when to use the +tool as well as what it does. A parameter needs `@McpParam` to name it, since a +Java parameter name doesn't survive compilation. Outside a development profile +the endpoint refuses to start without `cn1.mcp.token`. On a development profile +the token is optional, but a server without one listens on `127.0.0.1` only, since +its tools reach the database and every handler; set a token to serve other +machines, and a server bound to another address refuses to start without one. +It also refuses a browser +request unless its origin is a loopback address or is listed in +`cn1.mcp.allowedOrigins`, which keeps a web page from driving it. A page the +server serves itself has to be listed too. An allowed origin gets the CORS +answers a browser needs, the preflight included. + +==== Writing a tool + +A tool is an instance method of a bean. Its name is the method's name unless +`@McpTool(name = ...)` gives one, and a name is letters, digits, `_`, `-` and `.` +only. Each parameter carries `@McpParam` with the argument's name, which has to +be unique within the tool, an optional `description` that goes into the schema, +and `required`, true by default. A tool can't also be `@Async`: the agent gets +what the method returns, so the method has to run to the end. A +parameter may be a `String`, a number, a boolean, an enum, a `Map` or a `List`; +the build writes the matching JSON Schema type, and refuses any other type. A +number that doesn't fit the parameter -- too large for an `int`, or for a +`float` -- is refused rather than narrowed. + +When an agent calls the tool, the arguments are converted to the parameter types +and the method is called directly, with no reflection. A value that doesn't fit -- +text where a number belongs, or a number outside the range of a `short` -- is +answered as a tool error rather than converted into something the agent didn't +send. The return value is sent as the call's text: a `String` unchanged, anything +else as JSON. + +A method that throws is answered with a result marked as an error, which an agent +reads and can correct, rather than with a protocol failure. An +`IllegalArgumentException` sends its message, so that's the exception to throw for +arguments the tool can't use, with a message saying what would work. + +==== The endpoint + +The endpoint speaks JSON-RPC 2.0 over MCP's Streamable HTTP transport, and +negotiates protocol versions 2025-06-18, 2025-03-26 and 2024-11-05. It answers +`initialize`, `ping`, `tools/list` and `tools/call`, and answers the resource +and prompt listings with empty lists. It keeps no session and opens no event stream, which +the transport allows, so each call is one POST with one JSON answer and a GET is +answered 405. + +[cols="2,3", options="header"] +|=== +| Key | What it sets + +| `cn1.mcp.enabled` +| Whether the endpoint is served. On by default when the application has tools, + or when the development tools are available; set `false` to turn it off. + +| `cn1.mcp.token` +| A bearer token every call must carry. Required outside a development profile; + without it a development server listens on `127.0.0.1` only. + +| `cn1.mcp.allowedOrigins` +| Comma-separated browser origins allowed besides loopback ones, or `*`. + +| `cn1.mcp.path` +| Where the endpoint lives, `/mcp` by default. + +| `cn1.mcp.devTools` +| Whether a development build serves the development tools. On by default on a + development profile. +|=== + +A server restarted in a test serves the tools its own beans provide and nothing +a previous one registered. + +IMPORTANT: A packaged server has no MCP code at all unless its build asks for it: +a class with an `@McpTool` method, `@EnableMcpServer` on a class, or +`cn1.mcp.enabled=true` in a properties file. Without any of those, the generated +entry point never names the endpoint and the translator leaves it out of the +binary, development tools included; `cn1:backend` builds the development tools in +for the development run only. `@EnableMcpServer(path = ..., allowedOrigins = ...)` +sets the two keys it names, and a server built with the endpoint turns it off at +start-up with `cn1.mcp.enabled=false`. + +=== Tools for developing the server + +The second reason is development. When the server runs through `cn1:backend` on a +development profile, the same endpoint also serves tools for inspecting and +exercising it, and the server prints the endpoint's address when it starts: + +[cols="1,3", options="header"] +|=== +| Tool | Use it to + +| `backend_routes` +| List every route: method, path and the controller method behind it. + +| `backend_beans` +| List every bean, its scope and what was injected into it, and which conditional + beans are active. + +| `backend_config` +| See the active profile and every configured key, with values that look secret + masked. + +| `backend_call` +| Send the server a request -- `method`, `path`, `body`, `headers` -- and read the + status, headers and body. + +| `backend_requests` +| See the last requests served, newest first, with status and time; + `failuresOnly` shows only the ones that failed, with the exception each threw. + +| `backend_logs` +| Read the last lines the server printed. + +| `backend_sql` +| Run SQL against the server's database. Unless `write` is true, the statement + runs in a read-only transaction the database enforces, one statement at a time, + so a statement that writes fails even when it begins like a read. + +| `backend_schema` +| List the entities and their tables and columns. + +| `backend_jobs`, `backend_run_job` +| List the scheduled jobs; start one now. + +| `backend_metrics` +| Read every metric's current value, optionally only those with a prefix. + +| `backend_managed`, `backend_invoke` +| List the managed beans; call an operation. +|=== + +Claude Code connects to the endpoint with one command: + +---- +claude mcp add --transport http cn1-backend http://127.0.0.1:8080/mcp +---- + +A host that only speaks stdio runs `com.codename1.backend.mcp.StdioBridge` from +the backend jar with that address instead, and the bridge relays each message. None +of these tools are compiled into a server built with `cn1:backend-package`, unless +`-Dcn1.backend.devTools=true` asks for them. + +=== The loop an agent works in + +The development tools are meant to be used in a loop that checks each change +against the running server rather than against the code alone: + +. Start the server on the development profile. It gets an in-memory SQLite + database with the entity tables created, so it needs nothing installed: ++ +---- +CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend +---- +. Learn the server with `backend_routes`, `backend_beans` and `backend_schema`. +. Change the code, restart the server, and exercise the endpoint with + `backend_call` before touching any UI. A failing endpoint is much cheaper to + diagnose here than through a screen. +. Check what the server saw with `backend_requests`, and what it stored with + `backend_sql`. +. For a change that spans the app as well, run the app in the simulator and drive + it through the simulator's own MCP server, described in <>, + then check the requests and rows the same way. + +There is no hot reload: after a change to backend code, stop the server and start +it again. That takes seconds, and the build regenerates the wiring on the way. + diff --git a/docs/developer-guide/Backend-Observability.asciidoc b/docs/developer-guide/Backend-Observability.asciidoc new file mode 100644 index 00000000000..6132c89b28c --- /dev/null +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -0,0 +1,325 @@ +[[backend-observability]] +== Backend tracing, metrics and management + +A server in production has to answer three questions for whoever runs it: what did +this request do, how's the server doing, and what can be changed without +redeploying. Traces answer the first, metrics the second, and managed beans the third. +All three follow the OpenTelemetry conventions, so they reach whatever +observability stack a deployment already runs, and all three are linked into the +binary only when the build is asked for them. + +=== Tracing with OpenTelemetry + +A server can report every request it serves to any OpenTelemetry collector, and +the reporting needs no code in the handlers. Turn it on at build time with the +annotation, on any class in the backend module: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/TracingSnippets.java[tag=backend-otel-annotation,indent=0] +---- + +Without touching the source, set it in `application.properties` instead: + +---- +cn1.otel.enabled=true +---- + +Either one makes the generated entry point install a tracer. From then on: + +* every request is a server span, named after the route that matched + (`GET /notes/{id}`), with its status, method and path; +* every outbound `Web` call is a client span, and sends the W3C `traceparent` + and `tracestate` headers so the service it reaches joins the same trace; +* every `Database` statement is a client span carrying the SQL as the code wrote + it, placeholders and all -- the bound values are never recorded; +* an incoming `traceparent` makes the request part of the caller's trace, which is + how a Codename One app's spans connect to the backend's own (see + <>). + +A WebSocket connection isn't a span. It can stay open for hours and carry any +number of messages, so it has no single start and end to time, and its upgrade +request is answered before tracing begins. + +Without the annotation or the property, nothing refers to the tracer and the +translator leaves it out of the binary. + +Spans go out over OTLP/HTTP, as binary protobuf by default, batched on a +thread of their own so a slow collector never holds up a request. A queue that +fills because the collector is down drops spans and counts them, and +`HttpServer.getMetrics()` reports those counts. The settings are the standard +OpenTelemetry environment variables, so a deployment configures this server the +same way it configures everything else it runs: + +[cols="2,2,3"] +|=== +| Variable | Property | Meaning + +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `cn1.otel.endpoint` | The collector's base URL; `/v1/traces` is appended. Defaults to `http://localhost:4318`. +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `cn1.otel.traces.endpoint` | The full URL, used as it is. +| `OTEL_EXPORTER_OTLP_HEADERS` | `cn1.otel.headers` | `name=value` pairs, comma separated, with each value URL-encoded. +| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `cn1.otel.traces.headers` | Headers for traces only, in place of the shared ones. +| `OTEL_EXPORTER_OTLP_PROTOCOL` | `cn1.otel.protocol` | `http/protobuf` or `http/json`. gRPC isn't supported. +| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | `cn1.otel.traces.protocol` | The protocol for traces only. +| `OTEL_SERVICE_NAME` | `cn1.otel.service.name` | Overrides the annotation's `serviceName`. +| `OTEL_RESOURCE_ATTRIBUTES` | `cn1.otel.resource.attributes` | Extra resource attributes, `key=value` pairs. +| `OTEL_TRACES_SAMPLER` | `cn1.otel.sampler` | `parentbased_always_on` by default; also `always_on`, `always_off`, `traceidratio` and the other `parentbased_` forms. +| `OTEL_TRACES_SAMPLER_ARG` | `cn1.otel.sampler.arg` | The ratio for the ratio samplers. +| `OTEL_SDK_DISABLED` | `cn1.otel.disabled` | `true` turns tracing off at start-up without a rebuild. +| `OTEL_BSP_MAX_QUEUE_SIZE` | `cn1.otel.queue.size` | Spans held for export before new ones are dropped. Defaults to 2048. +| `OTEL_BSP_MAX_EXPORT_BATCH_SIZE` | `cn1.otel.batch.size` | Spans sent in one export. Defaults to 512. +| `OTEL_BSP_SCHEDULE_DELAY` | `cn1.otel.export.delayMillis` | Milliseconds between exports. Defaults to 5000. +|=== + +`cn1.otel.attributes.exclude` names attributes never to record, for example +`db.query.text` in a code base that builds SQL by concatenating values. + +Sending spans to Dynatrace, which accepts OTLP over HTTP as protobuf, is a +matter of pointing the endpoint at the environment's OTLP API and passing an +ingest token: + +---- +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://abc12345.live.dynatrace.com/api/v2/otlp/v1/traces +OTEL_EXPORTER_OTLP_HEADERS=Authorization=Api-Token%20dt0c01.XXXX +---- + +Under Lambda, the invocation continues the trace the host passes in its X-Ray +header, and the loop waits for the export before it polls again, because the host +freezes the process between invocations. + +`Tracing.current()` returns the request's span, for adding an attribute of the +application's own, and `Tracing.inSpan` times a block of work as a child span. + +==== Relaying the app's spans + +A mobile app shouldn't carry the collector's credentials: anything inside an app +package can be read by whoever installs it. With `cn1.otel.relay=true` the backend +accepts the app's spans at `/otel/v1/traces` (`cn1.otel.relay.path` moves it), adds its own credentials and +forwards them to the same collector: + +---- +cn1.otel.relay=true +# Optional: a shared secret the app sends in X-CN1-Telemetry-Token. +cn1.otel.relay.token=${RELAY_TOKEN} +# Only for a web app served from another origin. +cn1.otel.relay.corsOrigin=https://app.example.com +---- + +The relay takes OTLP/JSON, rebuilds it against the OTLP schema -- a field the +schema doesn't name is dropped and a malformed id is refused -- and queues it for +export in whatever protocol the server exports with. It answers as soon as the +spans are queued, and answers 503 when the queue is full so the app backs off. +`cn1.otel.relay.maxBytes` and `cn1.otel.relay.maxSpans` bound a single export. + +=== Metrics and management + +Traces say what one request did; metrics say how the server is doing. With +OpenTelemetry turned on, the server exports metrics over OTLP on the same endpoint +and with the same settings as its traces, every minute by default, with cumulative +temporality. `cn1.otel.metrics.enabled=false` turns metrics off while leaving +traces on. The management endpoints described below record and serve the same +metrics whether anything exports them or not. + +==== What the server records + +[cols="3,1,2", options="header"] +|=== +| Instrument | Kind | Attributes + +| `http.server.request.duration`, in milliseconds +| histogram +| `http.route` (the route template), `http.request.method`, + `http.response.status_code` + +| `http.server.active_requests` +| gauge +| + +| `http.server.open_connections` +| gauge +| + +| `cn1.server.websocket_connections` +| gauge +| + +| `cn1.server.requests_served`, `cn1.server.connections_refused` +| gauge +| + +| `db.client.connection.count`, `db.client.connection.idle` +| gauge +| (when the server has a database) + +| `cn1.task.queue_depth` +| gauge +| `cn1.executor` + +| `cn1.scheduler.run.duration`, in milliseconds +| histogram +| `cn1.job`, `cn1.outcome` (`success` or `failure`) + +| `process.runtime.memory.used`, `process.uptime` +| gauge +| +|=== + +The route attribute is the template -- `GET /notes/{id}` is recorded as +`/notes/{id}` -- so a server's metric count doesn't grow with the ids its clients +ask for. A request that fails, whether in a handler or in the session store, is +recorded with status 500. + +==== Managed beans + +The application's own metrics follow the JMX model -- attributes to watch and +operations to call -- with Spring's annotations for it: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PriceCache.java[tag=backend-managed,indent=0] +---- + +`@ManagedResource` marks the bean, and `objectName` names it; without one it's +the simple class name. The name is a segment of the management URL, so it's made +of letters, digits, `_`, `-` and `.` only. Each `@ManagedAttribute` is an instance getter with no +parameters, and a numeric one becomes a gauge named after the resource and the +attribute -- `prices.size` here -- read each time metrics are collected. Every +attribute, numeric or not, appears in the management listing. A +`@ManagedOperation` is a method an operator can call, whose parameters may be +strings, numbers, booleans, and enums. An operation's parameters are named by +`@McpParam` when they carry one, and otherwise `arg0`, `arg1`, in order. The build +checks each of these rules and refuses a managed method on a class with no +`@ManagedResource`. It also refuses a managed resource that isn't a singleton, +because metrics are read outside any request or session. An operation is invoked +by name, so it refuses an overloaded one too. + +`@Timed` records each call's duration as a histogram, and `@Counted` counts calls, +with a second counter for the failures. Their instruments are named after the class and method +unless the annotation's `value` names them: + +[cols="1,2", options="header"] +|=== +| Annotation | Instruments + +| `@Timed` +| `..duration`, a histogram in milliseconds + +| `@Counted` +| `..calls`, and `..calls.failures` for calls that threw +|=== + +`` is the fully qualified class name. Like `@Transactional`, they're woven +into the method at build time, so a call through `this` is measured too. + +==== Instruments of your own + +`Metrics.counter`, `Metrics.histogram` and `Metrics.gauge` create an instrument +for anything the annotations don't cover: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OrderMetrics.java[tag=backend-metrics-custom,indent=0] +---- + +Asking for the same name twice returns the same instrument, so two classes may +declare it, and asking for an existing name as a different kind is refused. Keep +instruments in static fields, so recording a value looks nothing up. A gauge is a +callback read when metrics are collected, which suits a value that already exists +somewhere, such as a queue's length. A histogram's second form takes bucket +boundaries and up to three attribute keys, recorded with +`record(value, first, second, third)`. + +==== Exporting + +Metrics use the tracing settings from <> unless a +metrics-specific one is set, which is the precedence the OpenTelemetry +specification gives: + +[cols="2,2,3"] +|=== +| Variable | Property | Meaning + +| `OTEL_METRIC_EXPORT_INTERVAL` | `cn1.otel.metrics.intervalMillis` | How often to export, in milliseconds. A minute by default. +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `cn1.otel.metrics.endpoint` | The full URL. By default the tracing endpoint's base with `/v1/metrics` appended. +| `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `cn1.otel.metrics.headers` | Headers for the metrics requests; by default the tracing ones. +| `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | `cn1.otel.metrics.protocol` | `http/protobuf` or `http/json`; by default the tracing protocol. +| | `cn1.otel.metrics.enabled` | `false` exports traces only. +|=== + +When the server stops, it exports once more so the last interval isn't lost, bounded +by the shutdown timeout. + +==== Management endpoints + +The management endpoints make the same information available over HTTP, for a +load balancer and for an operator: + +---- +GET /manage/health public; 503 while starting or draining +GET /manage/metrics every metric, as JSON +GET /manage/prometheus every metric, in the Prometheus text format +GET /manage/jobs the scheduled jobs and their last runs +GET /manage/managed the managed beans and their attributes +POST /manage/managed/{bean}/{operation} +---- + +An operation's body is a JSON object of its arguments by parameter name. A body +that isn't one, or an argument the operation refuses, is answered with 400; 404 +means only that no managed bean or operation has that name. Two managed resources +with the same `objectName`, or two `@McpTool` methods with the same name, are a +build error, since each is found by that name. + +Health reports `STARTING` until the server has finished starting, including the +application's scheduled jobs and exporters, `UP` while it serves, and `DRAINING` +once it's stopping. The listener accepts connections before start-up finishes, so +a load balancer that checks health doesn't send traffic to a server that may +still fail to start. + +[cols="2,3", options="header"] +|=== +| Key | What it sets + +| `cn1.management.enabled` +| Whether the endpoints are served. On by default on a development profile, off + everywhere else. + +| `cn1.management.token` +| The bearer token every endpoint but health requires. Outside a development + profile the server refuses to start with the endpoints on and no token, since + the metrics and managed beans would be readable by anyone who can reach the + port. + +| `cn1.management.path` +| Where the endpoints live, `/manage` by default. +|=== + +IMPORTANT: A packaged server has no management code at all unless its build asks +for it. The generated entry point is the only code that names the endpoints, and +it names them only when the build sees `@EnableManagement` on a class, or +`cn1.management.enabled=true` in `application.properties` or a profile's file. +Without either, the translator leaves the endpoints out of the binary, so there's +no `/manage` to find or to misconfigure, whatever profile the server later runs +under. The development run through `cn1:backend` always builds them in. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OpsSettings.java[tag=backend-enable-management,indent=0] +---- + +A server built with them can still turn them off at start-up with +`cn1.management.enabled=false`, and one built without them ignores the key, +because there's nothing to turn on. + +On a development profile without a token, the read-only views are open to anyone +who can reach the port, which is a laptop. An operation needs the token on every +profile, because it changes the running server. + +Health answers 503 while the server drains, so a load balancer stops sending it +new requests before it stops accepting them. The Prometheus view turns each name +into a Prometheus one, replacing the dots with underscores and adding `_total` to a +counter, so `http.server.request.duration` is scraped as +`http_server_request_duration`. + +The management endpoints are served before the application's routes, so a +catch-all route can't answer them. A running server's managed beans are also +available to code: inject `Backend` and call its `getManagedBeans()`. diff --git a/docs/developer-guide/Backend-Operations.asciidoc b/docs/developer-guide/Backend-Operations.asciidoc new file mode 100644 index 00000000000..c3c5256215a --- /dev/null +++ b/docs/developer-guide/Backend-Operations.asciidoc @@ -0,0 +1,176 @@ +[[backend-operations]] +== Backend performance, deployment and limits + +This chapter is for deciding whether the backend fits a service, and for putting +it into production once it does: what it costs in time and memory compared with +the alternatives, how the binary ships, and the limits worth knowing first. + +=== What it costs + +Same handler, three ways, plus Go for an outside reference. Two pinned cores, 64 +connections, medians of three interleaved runs on the plaintext route: + +[cols="2,1,1,1,1"] +|=== +| Runtime | Requests/sec | p50 | p99 | Cold start + +| Codename One native, musl +| 595,610 +| 0.090 ms +| 0.249 ms +| 0.77 ms + +| Codename One native, glibc +| 547,761 +| 0.065 ms +| 4.06 ms +| 2.88 ms + +| Go, fasthttp +| 496,293 +| 0.104 ms +| 2.63 ms +| 2.39 ms + +| The same handler on the JVM +| 187,745 +| 0.260 ms +| 1.60 ms +| 82.5 ms +|=== + +Cold start is the interesting column. It's measured from process spawn to the +first accepted connection, and the static binary reaches it in under a +millisecond -- about a hundred times faster than the same code on a JVM, and +three times faster than Go. That number is the whole serverless argument. + +Throughput is the least interesting one. Beating a tuned Go server by a fifth on +a microbenchmark isn't a reason to move a service; it's only evidence that the +translation doesn't cost you anything. + +.Latency at the median against the 99th percentile +image::img/backend-latency-slope.svg[A slope chart showing that the musl build's tail stays close to its median while the others fan out,scaledwidth=90%] + +The slope chart is the one that matters. Every runtime here has a similar +median. What differs is the distance to the 99th percentile, and that distance is +garbage collection. With the response pooled the plaintext route allocates about +0.1 bytes per request, the collector never runs, and the tail stays at 2.8 times +the median. Give the same server a handler that allocates a map per request and +its tail goes to 80 ms, because the collector shares the cores with the server. + +The honest rule is that the tail follows your allocation rate, not the runtime +badge. The runtime gives you the tools to allocate nothing on the hot path; it +doesn't do it for you. + +==== Memory and size + +[cols="2,1,1"] +|=== +| Runtime | Binary or artifact | Resident under load + +| Codename One native, musl +| 7.95 MB static +| 10-40 MB + +| Codename One native, glibc +| 3.19 MB dynamic +| 14 MB + +| Go, fasthttp +| 5.63 MB static +| 6.3 MB + +| The same handler on the JVM +| 0.13 MB jar, plus a JRE +| 190 MB +|=== + +The JVM row is the same handler and the same protocol code. Everything it costs +above the native rows is the runtime underneath it. + +The resident figures move around more than the latency ones, because the +collector keeps a pool of pages sized to the busiest moment the process has seen +and gives them back gradually. At rest the native builds sit near 3 MB. + +==== musl or glibc + +Both are supported, and they aren't equivalent. The static musl build starts +faster and has a far shorter tail. The glibc build has a better median, because +its allocator is better under contention, and a smaller binary, because it links +the system libraries instead of carrying them. + +The reason to pick musl isn't the median. It's that the artifact is one file +with nothing underneath it, which is what makes the container the binary and the +cold start a process exec. + +==== What isn't measured here + +GraalVM is missing from these tables on purpose. A fair comparison would have to +run the same handler, and this handler can't run on GraalVM: the runtime's +native methods are ParparVM's, so comparing would mean benchmarking a different +server written against a different framework and reporting it as though the +toolchains had been compared. That's a benchmark worth building, and it isn't +this one. + +=== Deploying it + +The musl build is a single static file, so the container that carries it can be +empty: + +---- +FROM scratch +COPY bench-linux-musl-arm64 /server +ENTRYPOINT ["/server"] +---- + +There is no base image to patch, because there is no base image. `cn1:backend-package` +builds for the machine it runs on; the cross-compiled targets +(`musl-x86_64`, `musl-arm64`, `glibc-x86_64`, `glibc-arm64`) are produced by +`vm/backend/package.sh` in the Codename One repository, which drives one builder +image per target. + +For AWS Lambda, `LambdaRuntime` implements the custom runtime loop. The Lambda +Runtime API is a plaintext poll over loopback, so it needs no listening socket and +no TLS, and what it does need is exactly what a translated binary is good at. + +=== Limits worth knowing + +* The class library is the Codename One runtime, not Java SE. A server dependency + that assumes the full JDK won't translate, and Maven Central isn't the + ecosystem this draws on. +* Beans, injection, transactions, scheduling and metrics are resolved at build + time, so what a runtime container discovers on its own -- beans in a jar found + by scanning, a proxy created for a class decided at run time -- isn't + available. There is no starter ecosystem, and validation and error mapping are + written by hand or generated from the REST contract. +* The packaging goal compiles Java. It recompiles the module's sources against the + backend's class library instead of reusing the jar Maven built, which is what + keeps a server off classes the runtime doesn't have, and is also why Kotlin is + not wired into this path yet even though the client ports support it. It uses + the JDK running Maven -- any release from 8 up -- and `-Dcn1.backend.jdk` names + a different one. What it can't change is the language level: the module's + sources are compiled at Java 8, because that's the bytecode the translator + reads. +* A virtual thread parks on sockets and on nothing else. A PostgreSQL or MySQL + query, a `Web` call and a TLS handshake all park it, and its host serves other + connections meanwhile. SQLite, file access, resolving a host name and + `Object.wait()` block the host thread that's running them, and with one host per + core, that many concurrent slow calls of that kind occupy every host while other + connections wait. A server whose handlers spend their time in SQLite should size + for that, or run the thread pool with `CN1_HTTP_POLL_MODE=0`. +* TLS runs on the thread pool, whatever the poll mode says. The TLS layer can't + park a read yet, and a blocking read on a virtual thread holds its host for the + duration, so one idle TLS client per core would occupy every one of them. The + server says so at startup when it makes that choice. +* Native builds target Linux. Development happens anywhere a JVM runs. +* WebSockets are RFC 6455 over HTTP/1.1. There is no permessage-deflate, so + messages go out uncompressed no matter how compressible, and no RFC 8441, so + a browser that reaches this server over HTTP/2 opens a second connection for its + WebSocket rather than carrying it on the first. Both are what every client + already falls back to. `wss` works on the packaged runtime through the same TLS + the rest of the server uses, and not in the local loop, which terminates no + TLS. + +The reasons to choose this are cold start, footprint, deployment shape, and one +language across the app and its server. If none of those matter for the service in +front of you, use a JVM framework. diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc new file mode 100644 index 00000000000..c98d5c78545 --- /dev/null +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -0,0 +1,294 @@ +[[backend-scheduling]] +== Backend scheduling and background work + +Not everything a server does fits inside a request. A report takes a minute to +build, webhooks go out after the response, a cleanup runs every night. This chapter +covers the three ways to run work outside a request: `@Async` methods, which +return before their work is done; `@Scheduled` methods, which run on a timetable; +and `Tasks`, for a one-off piece of work. All three run on named executors, and +each chooses between platform and virtual threads. + +=== Background work + +The annotations are Spring's, on ordinary bean methods: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Reports.java[tag=backend-background,indent=0] +---- + +The build rewrites each `@Async` method so that it packages its arguments into a +task, hands the task to an executor and returns. Each `@Scheduled` method is +registered with the server's scheduler, with a literal cron expression already +compiled into bit masks. Neither involves a proxy, so both work however the +method is called. + +=== Asynchronous methods + +An `@Async` method returns `void` or a `java.util.concurrent.Future`; any other +return type is a build error, because the caller returns before there is anything +to return. `Future`, the `ExecutionException`, `TimeoutException` and +`CancellationException` its `get()` throws, and `TimeUnit` aren't part of the +client's Java subset: they're backend API, documented in the backend API reference +and tested on the translated runtime. The body returns `AsyncResult.of(value)`, +and the caller receives, immediately, a `Future` that completes with that value +when the body has run: + +* `get()` waits for the body and returns its value. On a virtual thread it yields + its host while it waits rather than blocking it, so a request handler can wait + for an `@Async(thread = VIRTUAL)` task without stalling the other connections + that host serves. +* An exception the body throws comes out of `get()` wrapped in an + `ExecutionException`. +* `cancel()` succeeds only before the body starts. A running body is never + interrupted. + +A `void` method has no `Future` to deliver a failure through, so an exception it +throws is logged, counted against its executor, and recorded on the task's span. +The task runs in a span of its own whose parent is the caller's, so background work +shows up in a trace under the request that started it. + +A `synchronized` `@Async` method holds its monitor while the body runs on the +executor's thread, not merely while it hands the task over, so two calls still +never run the body at once. + +`@Async` on a `@Scheduled` method is a build error: it would end the run as soon +as the work was queued, so runs could overlap and a lock be released early; +`@Scheduled` already runs off the request thread, on the thread its `thread` +attribute picks. `@Async` on a `@RequestScope` or `@SessionScope` bean builds with +a warning, as it runs in Spring: the task calls the bean itself, which is destroyed +when its request or session ends, possibly before the task runs. + +Arguments are captured when the method is called, and the body runs later on +another thread. Pass values rather than objects the caller goes on changing, and +remember that the task runs outside the caller's transaction and outside its +request: a request-scoped bean isn't available to it. + +=== Executors + +Background work runs on named executors, created on first use: + +[cols="2,3", options="header"] +|=== +| Executor | Used by + +| `default` +| `@Async` methods that name no executor, and `Tasks.platform`. + +| `default-virtual` +| `@Async(thread = VIRTUAL)` methods that name none, and `Tasks.virtual`. + +| `scheduling`, `scheduling-virtual` +| `@Scheduled` methods that name none, by thread kind. + +| any other name +| `@Async("reports")`, or `@Scheduled(executor = "reports")`. +|=== + +Each is configured under its own name: + +---- +cn1.task.executor.reports.threads=4 # pool size; 8 by default, 2 for scheduling +cn1.task.executor.reports.kind=platform # platform or virtual; overrides the code +---- + +A pool's threads start with its first task, so an unused executor costs +nothing. A task submitted while every thread is busy waits in the executor's +queue, and `cn1.task.queue_depth` reports how many are waiting, by executor. + +=== Platform or virtual threads + +`thread` on `@Async` and `@Scheduled` chooses the kind of thread, from +`ThreadKind`: + +[cols="1,3", options="header"] +|=== +| Value | Runs on + +| `PLATFORM`, the default +| A thread of the executor's pool. + +| `VIRTUAL` +| A virtual thread of its own on the server's hosts. Where there are none -- the + development run on the JVM, a TLS server, Windows -- on the executor's pool + instead. + +| `AUTO` +| A virtual thread where the server runs them, a platform thread otherwise. The + choice is logged once at start-up. +|=== + +`VIRTUAL` means ParparVM's own virtual threads, which the native build has, and +not the virtual threads of Java 21: the two behave differently, and the JVM +development run has neither, so it runs `VIRTUAL` work on platform threads. The +choice matters because of how virtual threads work on this runtime, explained +under <>. A virtual thread parks while it +waits on a socket, so work that queries PostgreSQL or MySQL or calls another +service suits `VIRTUAL` well: thousands of such waits cost thousands of parked +stacks rather than thousands of threads. What it must not do is block outside a +socket. SQLite and file access are local calls that hold the host thread running +them, and with it every connection that host serves, so work that uses them +belongs on `PLATFORM`. `PLATFORM` stays the default, as a pool is in Spring. + +`Tasks` runs a single piece of work without a bean method: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Digest.java[tag=backend-tasks,indent=0] +---- + +`Tasks.virtual` does the same on a virtual thread. + +=== Scheduled jobs + +A `@Scheduled` method takes no arguments and gives exactly one of `cron`, +`fixedRate` or `fixedDelay`. Any of them can come from configuration: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Digest.java[tag=backend-scheduled-config,indent=0] +---- + +==== Cron expressions + +A cron expression has Spring's six fields, seconds first, separated by spaces: + +[cols="1,1,2", options="header"] +|=== +| Field | Values | Examples + +| second +| 0-59 +| `0`, `*/10` + +| minute +| 0-59 +| `30`, `0,15,30,45` + +| hour +| 0-23 +| `9-17`, `*/2` + +| day of month +| 1-31, or `L` for the last day +| `1`, `L`, `?` + +| month +| 1-12, or `JAN` to `DEC` +| `JAN,JUL`, `*/3` + +| day of week +| 0-7, or `SUN` to `SAT`; both 0 and 7 are Sunday +| `MON-FRI`, `?` +|=== + +Each field is `*` (or `?` for the two day fields), a value, a range `a-b`, a step +`*/n`, `a-b/n` or `a/n`, or a comma-separated list of those. The macros +`@yearly` (or `@annually`), `@monthly`, `@weekly`, `@daily` (or `@midnight`) and +`@hourly` stand for the usual expressions. + +When both day fields are restricted, a day has to match both. That's Spring's +rule, and it differs from Unix cron, where matching either is enough: +`0 0 12 13 * FRI` fires on Friday the 13th and not on every Friday. + +A literal expression is parsed by the build, so a mistake -- `0 0 25 * * *` names +an hour that doesn't exist -- fails the build with the field at fault, rather than +producing a job that never runs, or runs every second. An expression that reads +configuration, `${digest.cron:...}` above, is parsed at start-up instead, and a +bad one stops the server. + +`zone` is the time zone the expression is read in: a zone ID such as +`Europe/Berlin`, a fixed offset such as `+02:00`, or `UTC`. Without one it's UTC, +which is what a server's clock should be set to regardless. The build checks the name, +and a zone on a job that isn't a cron job is an error. A zone that comes from +configuration is checked when the schedule is made -- against the host's time-zone +database where it has one -- so a misspelled name stops the server instead of +running the job on UTC without a word. + +In a zone with daylight saving time, a time the clocks skip doesn't exist, and a +job due then doesn't run that day: `0 30 2 * * *` in `America/New_York` skips the +spring-forward night instead of running at 03:30. A time the clocks pass twice, on +the night they go back, runs once, at the first of the two. Once that has passed +the day's run is spent, so a server started inside the repeated hour doesn't run it +that night. + +==== Fixed rate and fixed delay + +`fixedRate` starts runs a fixed number of milliseconds apart, measured from start +to start. `fixedDelay` waits that long after each run ends. `initialDelay` sets +how long after start-up the first run begins; without it the first run starts as +soon as the server does. The `String` forms, `fixedRateString`, +`fixedDelayString` and `initialDelayString`, accept a placeholder, so a period can +be configured per deployment. + +.fixedRate, fixedDelay and a run that overruns +image::img/backend-schedule-timelines.svg["Three timelines of the same job: fixedRate starting every ten seconds, fixedDelay waiting ten seconds after each end, and a slow run that causes two skipped starts",scaledwidth=95%] + +==== Runs never overlap + +A run never overlaps the previous run of the same job. A start that finds the +previous run still going is skipped, and the job is next due at its following +time; it doesn't queue up and run twice to catch up. A fixed-rate job that falls +behind resumes on its own grid, without a burst. Each skipped start is counted, so +a job that routinely overruns its period shows up in the job listing rather than +running less often than it says with nobody noticing. + +The scheduler itself is one platform thread that keeps the timetable. The runs +happen on the executors, so a slow job delays nothing but its own next run. + +==== One instance at a time + +With several instances of a server behind a load balancer, each runs every job. +For a job that should happen once -- sending the daily digest, purging old rows -- +`lock` names a row it claims first: + +---- +@Scheduled(fixedDelay = 60000, lock = "purge") +---- + +Before each run the instance tries to claim the named row in a table called +`cn1_scheduler_lock`, in the server's own database, and skips the run when another +instance holds it. The claim expires after `lockAtMostFor` milliseconds -- ten +minutes when it isn't set -- so an instance that dies mid-run doesn't hold the job +forever. Set it longer than the job's longest run, or two instances can end up +running it at once. The claim's times come from the database server's clock, which +every instance shares, so an instance whose own clock runs ahead can't take a claim +another still holds. With SQLite, which runs in the server's process, it's that +host's clock. A job with a lock needs a database, which the build checks. + +A skipped run isn't a failure. A lock that can't be claimed for any other reason -- +the database refusing the statement -- is recorded as one, so a broken lock table +doesn't look like a job that's always somebody else's turn. + +==== Watching the jobs + +A job is named after its class's simple name and its method, `Cleanup.run`. When +two classes in different packages share a simple name, both jobs take the +qualified name, `com.example.billing.Cleanup.run`, so each can be listed and +started on its own. + +`GET /manage/jobs` lists every job with its schedule, its runs, failures and +skipped starts, when it last started, how long that took, the last error, and when +it's next due. The development MCP server has the same as `backend_jobs`, and +`backend_run_job` starts one immediately. The duration of each run is recorded in +the `cn1.scheduler.run.duration` histogram, by job and outcome. + +When the server stops, the scheduler stops first so that no run begins during the +drain, and runs that haven't finished get the same grace period as the requests in flight. +A task on a virtual thread keeps running on its host thread through that window. +One still running when it ends is abandoned and reported, and its `Future` never +completes. + +=== Pitfalls + +* **SQLite or file work on a virtual thread** blocks the host thread under it, + because neither waits on a socket. Leave such work on `PLATFORM`. A PostgreSQL + or MySQL query parks instead, and is fine on `VIRTUAL`. +* **Relying on the caller's context.** An `@Async` body runs outside the caller's + transaction and request. A transaction it needs, it declares itself. +* **A cron expression read in local time.** Without `zone` it's UTC, so + `0 0 9 * * *` fires at 09:00 UTC, which is the middle of the night somewhere. +* **`lockAtMostFor` shorter than a run.** Once the claim expires another instance + may start the job while the first is still running it. +* **Several instances without `lock`.** Every instance runs every job. + diff --git a/docs/developer-guide/Backend-Sessions.asciidoc b/docs/developer-guide/Backend-Sessions.asciidoc new file mode 100644 index 00000000000..ac45cac4290 --- /dev/null +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -0,0 +1,223 @@ +[[backend-sessions]] +== Backend sessions + +HTTP is stateless: every request arrives on its own, and nothing in it says the +client has been here before. A session is how a server remembers a client between +requests. The server gives the client an unguessable id in a cookie, the client +sends it back with every request, and the server finds the state it kept under that +id. This chapter covers the session API, the cookie, where sessions are stored, and +session-scoped beans. + +.A session from the first request to the last +image::img/backend-session-lifecycle.svg["The states of a session: no session, new, active and ended, with the change of id at sign-in and expiry after inactivity, above the two stores",scaledwidth=95%] + +=== Starting a session at sign-in + +A handler reaches the session through the request: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Login.java[tag=backend-session,indent=0] +---- + +`getSession(true)` returns the client's session, creating one if the request +carried no valid cookie. Nothing is stored and no cookie is sent until something +calls it, so a server that never uses sessions -- an API authenticated with +bearer tokens, say -- pays nothing for them and sets no cookie. The server the +build generates stores each session and sends its cookie when the request ends. + +The call to `changeSessionId()` isn't optional at sign-in. It gives the session a +new id, keeps its attributes, and sends the client the new cookie. Without it a +session id the client held before signing in stays valid after, and an attacker +who planted that id in the victim's browser beforehand -- a session fixation +attack -- is signed in along with the victim. + +=== Reading and ending a session + +Most requests want the session only if there is one: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Account.java[tag=backend-session-read,indent=0] +---- + +`getSession(false)` never creates a session, and answers `null` for a client +without a valid one, so a read-only request doesn't create a session as a side +effect. `invalidate()` ends one, and the response clears the client's cookie: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Account.java[tag=backend-session-logout,indent=0] +---- + +After `invalidate()`, the request no longer has that session: `getSession(false)` +returns null, and `getSession(true)` starts a new one, whose cookie the response +sends after clearing the old one. + +[cols="2,3", options="header"] +|=== +| `HttpSession` method | What it does + +| `getAttribute(name)`, `setAttribute(name, value)`, `removeAttribute(name)` +| Read, set and remove the session's state. Setting `null` removes the attribute. + +| `getAttributeNames()` +| A copy of the attribute names. + +| `changeSessionId()` +| A new id for the same session, with a new cookie. Call it at sign-in. + +| `invalidate()`, `isValid()` +| End the session, and ask whether it has been ended. Reading or writing an + attribute of an ended session throws `IllegalStateException`. + +| `isNew()` +| Whether the current request created the session. + +| `getMaxInactiveInterval()`, `setMaxInactiveInterval(seconds)` +| How long the session may go unused before it expires, for this session alone. + +| `getCreationTime()`, `getLastAccessedTime()` +| When it was created and last used, in milliseconds since the epoch. + +| `getId()` +| The id the cookie carries. It's a credential, so never log it. +|=== + +A session is saved after the handler returns, and only when the request changed +it. A request that only reads its session writes nothing, apart from the last access +time the database store keeps. That store updates it when it's older than a +minute, or a quarter of the session's timeout when that's shorter, and allows the +same interval on top of the timeout before it calls a session expired, so a +session in use is never expired early. + +With the database store, two requests of one client load separate copies of the +session. If one of them invalidates it, the other can't write it back: a copy of a +session whose row is gone is dropped rather than saved again, so a request still +running with an old cookie can't undo a sign-out. + +=== The cookie + +The session id is 192 bits from a cryptographically secure random source, +encoded for a cookie. The cookie is sent with `Path=/` and `HttpOnly`, so +scripts in a page can't read it. `SameSite=Lax` keeps a browser from sending it +with a form another site posts. On a TLS server it's also `Secure`, so a browser +never sends it over plain HTTP. It carries no `Max-Age`, so it lasts until the +browser closes; how long the session lasts is the server's decision. + +[cols="2,3", options="header"] +|=== +| Key | What it sets + +| `cn1.session.cookie` +| The cookie's name, `CN1SESSION` by default. It has to be a valid cookie name -- + letters, digits and the punctuation HTTP allows in a token, such as `-`, `_` and + `.` -- and anything else stops the server at start-up. + +| `cn1.session.timeout` +| Seconds a session may go unused before it expires, 1800 by default. Zero means + sessions never expire, and a negative value stops the server at start-up. + +| `cn1.session.store` +| `memory`, the default, or `db`. See <>. + +| `cn1.session.same-site` +| `Lax`, `Strict` or `None`. `None` lets other sites send the cookie, and a + browser accepts it only when it's `Secure`, so the server refuses to start with + `None` unless the cookie is. + +| `cn1.session.secure` +| `auto`, the default, which marks the cookie `Secure` when the server terminates + TLS; `true` for a server behind a proxy that terminates TLS for it; or `false`. + Anything else stops the server at start-up. +|=== + +`@SessionConfig` sets the same keys in source -- `@SessionConfig(store = "db", +timeoutSeconds = 3600)` -- as the bottom layer under the properties files, and +refuses at build time a value this table says would stop the server; see +<>. + +An injected `Backend` returns the server's sessions from `getSessions()`, whose +`getStore()` answers the store in use. + +=== Where sessions are kept + +`cn1.session.store=memory` keeps sessions in the server's own memory. It needs +nothing, any object can be an attribute, and the object a request sets is the +object the next one gets. It also means sessions end with the process, and that a +second instance of the server doesn't know about them, so behind a load balancer +it needs the balancer to send each client to the same instance. + +`cn1.session.store=db` keeps sessions in the server's own database, in a table +called `cn1_http_session` that the server creates on first use. Every instance of +the server then sees every session, a restart loses nothing, and a deployment can +add or replace instances at will. The price is in what an attribute can be: the +attributes are stored as JSON, so a value must be a string, a number, a boolean, or +a map or list of those. Anything else is written as JSON can write it and read back +as whatever JSON reads, which isn't the object that was stored. + +Each row belongs to a namespace, and a server only reads the rows of its own. +By default the namespace is empty, so every server that uses the database shares +its sessions, as Spring Session's JDBC store does. Browsers don't keep cookies +apart by port, so two different servers on one host that share a database would +each accept the session cookie the browser sends to both, including a signed-in +user's. Give each project its own `cn1.session.namespace` to keep them apart; +every instance of one project keeps the same one, so they still share sessions. + +Either way, an expired session is removed at most once a minute, by the next +request that asks for a session, and a request that presents an expired session's +cookie is treated as having no session at all. + +=== Session-scoped beans + +A bean marked `@SessionScope` exists once per session, which suits state that +belongs to a client and has behaviour of its own: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Cart.java[tag=backend-session-bean,indent=0] +---- + +A singleton that injects it receives a generated stand-in, and each call on the +stand-in reaches the calling client's own instance: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/CartApi.java[tag=backend-session-bean-api,indent=0] +---- + +Using the stand-in starts a session if the client has none, as `getSession(true)` +would. The instance is built on the session's first use and kept by the server in +memory for as long as the session lives. It's never written to the session store, +whichever store is configured, so with `cn1.session.store=db` each instance of +the server builds its own copy for a client it serves. State that has to follow the +client from one instance to another belongs in the session's attributes instead. + +The bean's `@PreDestroy` method, or its `destroyMethod` for a `@Bean` method, runs +when the session ends. That's at the end of the request that invalidated it, when +the periodic purge finds it expired, or when the server stops. + +Two requests from the same client can run at once -- two browser tabs, or an app +that doesn't wait for one answer before sending the next -- and they reach the same +instance. The methods of a session-scoped bean therefore need to be safe to call +concurrently, which is why the `Cart` above synchronizes them. + +A session-scoped bean can't inject the request or the `HttpSession`: it outlives +the request that built it, and with the database store each request loads its own +copy of the session, so a copy held by the bean would go stale. The build refuses +both. A method that needs the session reads the current one when it runs, with +`Backend.currentRequest().getSession(true)`. + +=== Pitfalls + +* **Skipping `changeSessionId()` at sign-in** leaves the server open to session + fixation. Call it whenever a session gains a privilege. +* **Storing objects the database store can't write.** A service, a stream or a + domain object in an attribute works on the memory store and comes back as + something else from the database store. Store ids and plain values, and load the rest per request. +* **Relying on memory sessions behind a load balancer** without sticky routing + makes a client appear signed out whenever a request lands on another instance. +* **Holding a lock across the whole session.** Concurrent requests from one client + share its attributes and its session-scoped beans, so a long `synchronized` + method serializes that client's requests. Keep critical sections short. + diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc new file mode 100644 index 00000000000..ed80a3b32a9 --- /dev/null +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -0,0 +1,403 @@ +[[backend-web]] +== Backend controllers, contracts and WebSockets + +A controller turns HTTP into method calls. This chapter covers how requests are +mapped to methods and how their parts are bound to parameters, how a return value +becomes a response, how the app and the server share one declaration of their +API, and how a server holds WebSocket connections open. <> +introduced the shape; this is the detail. + +=== Mapping requests + +A `@RestController` is a class whose methods answer requests. It's also a bean, +so its constructor receives whatever it depends on, as <> +describes. Each mapped method names a verb and a path: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Products.java[tag=backend-web-mappings,indent=0] +---- + +`@GetMapping`, `@PostMapping`, `@PutMapping`, `@PatchMapping` and `@DeleteMapping` +cover the usual verbs, and `@RequestMapping(value = "/x", method = "OPTIONS")` +covers the rest. On the class, `@RequestMapping` is a prefix for every mapping +inside it, and a mapping with no path answers on the prefix itself, as `create` +does above. A mapping may list several paths, which is how a route keeps an old +URL working. + +A `{name}` segment matches one path segment and is bound with `@PathVariable`. +Within one controller a literal route is tried before a route with variables that +would also match it, so `/api/products/search` above reaches `search` rather than +`get` with an id of `search`. + +What the build refuses is a route nothing could ever reach. Two methods answering +the same verb and path shape are an error, even when their variables are named +differently, because a variable's name isn't part of what a request carries. The +same is true across controllers: a variable route in one controller that would +answer a literal route of another is reported, since the routers are tried in +turn and the literal one would never run. + +==== Binding the parts of a request + +Every parameter of a mapped method says where its value comes from, with exactly +one annotation: + +[cols="2,2,3", options="header"] +|=== +| Annotation | Reads | Binds to + +| `@PathVariable("id")` +| a `{id}` segment of the path +| `String`, or a numeric or `boolean` primitive + +| `@RequestParam("q")` +| a query parameter +| `String`, or a numeric or `boolean` primitive + +| `@RequestHeader("Idempotency-Key")` +| a request header +| `String`, or a numeric or `boolean` primitive + +| `@RequestBody` +| the body +| `String`, a `Map` or a `List`, or a class of your own and collections of it + +| none, typed `HttpServer.Request` +| the request itself +| anything the request exposes +|=== + +The name is required. Java drops parameter names when it compiles unless it's +told to keep them, and a router that guessed would bind the wrong value in +silence, so `@RequestParam("q")` names the parameter the client sends rather than +the variable in the method. A parameter with no annotation, or with two, is a +build error: one parameter reads from one place, which matters most for an input +like a credential that could otherwise arrive from a query string when the code +meant a header. + +`@RequestParam`, `@RequestHeader` and `@RequestBody` are required unless they say +otherwise. A request that leaves out a required value is answered 400, with the +value's name in the body, before the method runs. `defaultValue` supplies a value +for one that's missing. Two combinations are refused at build time, because each +would hand the method a value nobody sent: `required = false` on a primitive with +no `defaultValue`, since an `int` can't hold "absent" and would arrive as `0`; +and a `defaultValue` that isn't a value of the parameter's type. + +A body is parsed as JSON. A `String`, `Map` or `List` parameter gets it as parsed; +a class of your own -- an entity, a DTO -- or a `List` of them is filled in +by a codec the build writes for that class, as Jackson fills one in for a Spring +controller. <> describes the form. + +A request target with a malformed escape sequence, or one that isn't valid UTF-8, +is answered 400 before any route is tried, rather than as a 404 the client +couldn't explain. + +==== What a method returns + +[cols="2,3", options="header"] +|=== +| Return type | Response + +| `void` +| 204 with no body, or the `@ResponseStatus` code. + +| `String` +| The text, as `text/plain; charset=utf-8`, with 200 or the `@ResponseStatus` code. + +| A `Map`, `List`, `Set`, primitive or box, or a class of your own and + collections of it +| JSON, with 200 or the `@ResponseStatus` code. + +| `HttpServer.Response` +| Exactly what the method built. `@ResponseStatus` on such a method is a build + error, since the response already carries its own status. + +| `null`, from any of the above +| 404. +|=== + +Any other return type is a build error: a JDK class with no JSON form, such as +`java.io.File`, would otherwise come out as the JSON string of its `toString()`, +with the build and the request both reporting success. `@ResponseStatus` takes a +final status between 200 and 599. + +==== Your own classes as JSON + +A method can return an entity or any class of your own, or take one as its body, +and the build writes the JSON codec for that class and for every class its fields +reach. There is no reflection at run time: the codec is ordinary code that reads +and writes each field by name, so it costs what the hand-written version would. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/Order.java[tag=backend-dto-json,indent=0] +---- + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/OrderLine.java[tag=backend-dto-json,indent=0] +---- + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/OrdersApi.java[tag=backend-dto-json,indent=0] +---- + +A `POST /orders` with `{"customer":"Ada","lines":[{"sku":"A-1","quantity":2}]}` +answers with the stored order: + +---- +{"id":1,"customer":"Ada","placed_at":1740821400000,"lines":[{"sku":"A-1","quantity":2}]} +---- + +The JSON form is the one the app's `@Mapped` mapper uses, so a class shared by the +app and the server reads the same on both sides: + +* The fields are the class's own and its superclasses', except static and + `transient` ones. A public field is used directly; any other through its + `getX` (or `isX`) and `setX` methods, and is left out when it has neither. +* `@JsonProperty("due_at")` renames a field and `@JsonIgnore` leaves one out, both + from `com.codename1.annotations`. +* A `Date` is written as milliseconds since the epoch, which is Jackson's default + and what the app's mapper reads, and is read from that or from an ISO-8601 date + such as `2025-03-01T09:30:00Z`. A `byte[]` is base64, an enum is its name. +* A subclass is written with its own fields, even where a method declares the + superclass. +* A field declared `Object`, or a raw `Map` or `List`, is written by what it holds + when the server runs. A value of a class the build writes a codec for goes + through that codec; a value of any other class of yours is answered 500 rather + than written as its `toString()`, so declare the field with its type. +* A body's unknown members are ignored and absent ones leave the field as the + constructor set it, as a Spring Boot application does. + +A body the codec can't read -- a string where a number belongs, an enum name that +doesn't exist -- is answered 400 with where it was and what was expected: + +---- +$.items[2].due: expected a number or an ISO-8601 date, got true +---- + +Two objects that refer to each other, such as an order whose lines point back at +it, would be written forever, so a response that nests more than 64 objects deep +is answered 500 with a message naming the class; mark the field that points back +`@JsonIgnore`. The build refuses what it can't write a codec for, and says why: a +field typed by a type variable, such as `List` in a generic `Page`; a body +class with no constructor without arguments; an interface; and an array other +than `byte[]`. + +A method that throws is answered 500 with the body `internal error`, and the +exception is logged. The message isn't sent to the client, because an exception +message is the most common way a server leaks its internals. To answer a failure +with a status and a body of your choosing, return an `HttpServer.Response`, built +with `request.respond(status, contentType, bytes)` or +`request.respondJson(status, value)`. + +==== Taking the request itself + +A parameter typed `HttpServer.Request` receives the request, which is the way to +anything the binding annotations don't model: every header, the raw body, the +session, or a response with headers of its own. The `label` method in the example +above takes it for that reason. + +A request's byte arrays belong to the connection and are reused by the next +request on it, so a handler that keeps the body beyond its own call copies what it +needs. That's the price of a router that allocates nothing for a route without +path variables. + +==== Serving static files + +`cn1.static.root` names a directory, and the server answers requests under +`cn1.static.prefix` (`/static` by default) with its files, after every route has +had its chance. A directory is answered with its `cn1.static.index` file, and every +file carries `cn1.static.cacheControl`. The resolved path must be inside the root, +checked by resolving it on disk rather than by inspecting the request, so `../`, +an encoded escape sequence and a symbolic link out of the tree are all refused. +Conditional requests and ranges are honoured, and where the platform supports it +the file is sent from the page cache to the socket without passing through the +process. + +=== Sharing the contract with the app + +This is where having the same language on both ends stops being a slogan. An +interface annotated for the REST client generates the app's client: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesApi.java[tag=backend-contract,indent=0] +---- + +Building the backend module with `-Dcn1.restServer=true` generates two more types +from that same interface: `NotesApiServer`, a synchronous interface the backend +implements, and `NotesApiDispatcher`, which routes a method, path and body to it +and binds the path and query parameters. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesEndpoint.java[tag=backend-contract-server,indent=0] +---- + +The client's methods are asynchronous because a UI can't block; the server's +methods are synchronous because a handler has nothing to call back into. One declaration +produces both shapes, which is what gRPC does and for the same reason. + +The payoff is that changing the contract breaks the build on whichever side did +not follow it, instead of producing a response the app fails to parse in the +field. The data transfer objects are shared rather than transcribed, and their +codecs are generated on both sides, so there is no handwritten mapping layer to +drift. + +The server half is off by default. Every existing project carries these +interfaces for its client alone, and generating server classes into those builds +would grow them for nothing. + +=== Real-time with WebSockets + +The server speaks RFC 6455, so a Codename One app can hold a live connection to a +Codename One server with the same language on both ends. The client half is +`com.codename1.io.WebSocket`, which every port has shipped for years; this is the +other half of it. + +An endpoint implements `com.codename1.backend.WebSocket`: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java[tag=backend-websocket-echo,indent=0] +---- + +The build finds it, the way it finds a `@RestController`: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java[tag=backend-websocket-annotated,indent=0] +---- + +The annotation goes on the type rather than on a method, because a `@GetMapping` +marks a call and a WebSocket is a connection: its contract is seven callbacks that +share per-connection state, which is an object. What it keeps from `@GetMapping` is +what a reader cares about -- the path is relative to a class-level +`@RequestMapping`, a constructor taking a `DataSource` or an `EntityManager` is +injected the same way, and two endpoints claiming one path is a build error rather +than something registration order decides. Write the path's characters out: an +escaped character such as `%61` in the mapping is a build error too, since an +upgrade is matched against the decoded path, which an escaped mapping never equals. +An endpoint that injects a `@RequestScope` or `@SessionScope` bean, directly or +through a singleton, builds with a warning: its callbacks run outside any HTTP +request, so there's no request or session to find the bean in, and using it there +throws `IllegalStateException`, as in Spring. Keep per-connection state in the +session's attachment instead. + +Only `onOpen`, `onText` and `onBinary` have to be written. `onPing`, `onPong`, +`onClose` and `onError` have empty defaults, and a PING is answered with its PONG +before the endpoint is told, so an endpoint that ignores them still keeps its +connections alive. + +==== One endpoint, many connections + +An endpoint is created once for the route, not once per client, which is the same +shape a `@RestController` has. Everything belonging to one client lives on the +`WebSocketSession` the callbacks are handed, and `setAttachment` is where an +endpoint puts its own per-connection state. + +Messages arrive whole. A client that splits a two megabyte upload into thirty-two +frames produces one `onBinary`, and a text message is validated as UTF-8 before it +is decoded, so a handler never sees a replacement character standing in for bytes +the client didn't send. The array passed to `onBinary` is the session's own +reassembly buffer and is valid only until that call returns -- the same contract +`HttpServer.Request` carries, and for the same reason. An endpoint that keeps the +bytes copies the range it wants. + +==== Sending from somewhere else + +A callback runs on the thread that owns its connection, and the server reads +nothing more from that connection until it returns. An endpoint may therefore +block, and a slow one slows down its own client and nobody else's. + +Sending is the other way round: a session may be written to from any thread, which +is what makes a broadcast possible. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java[tag=backend-websocket-broadcast,indent=0] +---- + +Each session serializes its own writers, so two threads can't interleave halves of +two messages on one connection. What the server doesn't do is queue: `sendText` +writes to the socket and blocks if the peer has stopped reading. That's honest +back pressure rather than a buffer that grows until the machine runs out, but it +means one unresponsive client can hold up a loop that broadcasts to every other +one. A server with many clients and large messages should broadcast from a small +pool rather than from the receiving thread. + +==== Subprotocols + +An endpoint that speaks more than one protocol lists them best first: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java[tag=backend-websocket-subprotocol,indent=0] +---- + +The server's order decides, not the client's, so a client can't select a +deprecated protocol over a current one by listing it first. When nothing matches, +the handshake still succeeds and names no protocol, which is what the standard +describes; `getSubprotocol` then answers null. + +==== A connection, not a request + +A WebSocket is a connection that stopped being HTTP, and the difference is +something an author can feel. + +Under virtual threads -- the default on the packaged runtime -- each connection +owns one, and a parked virtual thread costs a stack rather than an operating system +thread. Ten thousand mostly silent connections is the workload that model was built +for. + +On the thread pool it's a worker per connection for as long as the connection +lasts. That's the mode the local development loop always runs in, because the JVM +has no virtual threads here, and it's also the mode any TLS server runs in. A +pooled deployment serving many WebSockets has to size `workers` for them, because +unlike a request they don't give the worker back. + +That makes `workers` the ceiling on concurrent connections in pool mode, and going +past it fails in a way worth knowing about: the process stays healthy, the listener +stays bound, and new connections are simply refused, because no worker ever comes +back to accept them. An eight-worker server driven by the conformance suite +reached case 9.4.4 and refused everything after it. The server says so now -- +it logs once when WebSockets hold half the pool -- and the two ways out are more +workers or a shorter `CN1_WS_IDLE_TIMEOUT_MS`, so abandoned connections give their +worker back sooner. On virtual threads none of this applies. + +The idle timeout is separate for the same reason. `CN1_HTTP_TIMEOUT_MS` sheds a +client that began a request and stopped; a WebSocket is idle by design and would +be shed within seconds by that rule. `CN1_WS_IDLE_TIMEOUT_MS` governs these +instead, defaults to five minutes, and accepts 0 for connections that may stay +silent indefinitely. `CN1_WS_MAX_MESSAGE_MB` bounds reassembly, because a message +is as large as the peer chooses to make it. + +`getMetrics()` reports `webSocketConnections`, and an open session doesn't count +towards `activeRequests` -- it's a connection, and counting it would make an idle +server read as permanently saturated. + +`stop()` sends every open connection a 1001 "going away" close before the drain +window, so a shutdown reads as one to the client rather than as a network failure. + +==== How the frame layer is checked + +Most of RFC 6455 is about frames a conformant client never sends, which is how a +server ends up wrong in ways nothing it talks to will reveal. The +https://github.com/crossbario/autobahn-testsuite[Autobahn|Testsuite] is what finds +those, and `vm/backend/ws-conformance.sh` runs it: + +---- +vm/backend/ws-conformance.sh --arm javase # or --arm native +---- + +It needs Docker or Podman, starts an echo server, and drives ~300 cases through +it. The gate has no per-case tolerance: every case must pass, and `NON-STRICT` +counts as a failure, because a lenient frame parser is the thing this is looking +for. The set of cases that ran also has to match a committed manifest, so the +suite can't shrink unnoticed to the ones that happen to pass. + +Sections 12 and 13 are excluded while permessage-deflate is unimplemented. They're +excluded rather than tolerated: accepting their result as a pass would also accept +it for a case that used to work. diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index e211f5dc05c..ff28fc319f3 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -16,10 +16,18 @@ image::img/backend-architecture.svg["Java compiled to C to a native binary, and === What this doesn't replace Spring Boot, Quarkus, Micronaut and Jakarta EE aren't the competition here. -They carry dependency injection, an ORM, declarative transactions, a security -stack and two decades of operational knowledge, and none of that exists in this -runtime. If you are running a Spring service today and it works, this chapter is -not asking you to move it. +They carry a security stack, a starter for every service a company runs, a +container that discovers beans in any jar on the class path, and two decades of +operational knowledge. If you are running a Spring service today and it works, +these chapters aren't asking you to move it. + +What this runtime borrows from them is the programming model. Controllers, +services, dependency injection, declarative transactions, scheduled jobs and +managed beans use Spring's annotation names and mean what they mean there, so a +Spring developer can read a Codename One backend without learning it first. What +it doesn't borrow is the container: every one of those annotations is resolved +while the project builds, into ordinary code, and there is nothing left to +interpret when the server runs. What those frameworks assume is a JVM, and that paying for one is reasonable. For most server work that assumption holds. This exists for the work where it fails. @@ -42,6 +50,27 @@ wire. The point of the backend is to remove the reason to leave Java for that slice. It doesn't try to take work the JVM already does well. +=== How the backend chapters fit together + +This chapter covers what every server needs: the module, the build, the +concurrency model and configuration. The chapters after it each take one part of +a server and go into depth: + +* <>: mapping HTTP requests to methods, sharing a typed contract + with the app, and holding WebSocket connections open. +* <>: splitting a server into services the build wires together, + with scopes, conditions and configuration binding. +* <>: the connection pool, the object mapping, and + `@Transactional` in full, with propagation, rollback rules and savepoints. +* <>: keeping state for a client across requests. +* <>: running work later, on a schedule, or on another thread. +* <>: traces, metrics, managed beans and the management + endpoints. +* <>: letting an AI agent call the server's tools, and giving an agent + that builds the server a way to inspect and exercise it. +* <>: the measurements, deployment, and the limits worth + knowing before choosing this. + === A first server The archetype and the initializr both generate a `backend` module beside the @@ -102,6 +131,47 @@ run doesn't terminate TLS, by design, and therefore doesn't serve HTTP/2: both refuse with a message that says so, because a second TLS implementation would have its own bugs rather than production's. +=== What the build writes + +Every annotation in these chapters is read from the compiled classes, after javac +and before the translator, during the `process-classes` phase. The build works out +everything the annotations ask for and writes it down as plain Java, so the +translator and the dead-code pass see an ordinary program: + +.What the build does with a backend module +image::img/backend-build-pipeline.svg["Annotated sources compiled by javac, resolved by the build into generated wiring, routers and aspect classes, then translated",scaledwidth=95%] + +* `BackendApplication` is the `main`. It builds the configuration, opens the + database, starts the server and installs the shutdown handler. +* `BackendWiring` is the whole of dependency injection: a `new` for each bean in + dependency order, the calls that set its injected fields, and its + `@PostConstruct` method. <> describes what goes into it. +* A `Router` per controller holds each route as a `byte[]` and binds each + parameter with code written for its type. +* A `Cn1Aspects` class per annotated class carries what `@Transactional`, + `@Async`, `@Timed` and `@Counted` add around a method. The method keeps its name + and signature, its body moves to a method of its own, and the method calls the + aspect around that body. + +That has three consequences worth knowing from the start. A dependency that +can't be satisfied, a route claimed twice and a cron expression with a typo are +build errors, each naming the class and member at fault, rather than failures the +first request finds. Starting a server with fifty beans costs what the fifty +constructors cost, because there is no scan and no lookup. And a feature the +application skips isn't in the binary at all: the translator drops every +class nothing references, and the generated code references only what the +annotations asked for. + +The generated classes land in `target/classes` beside the module's own. To see +what the build decided about the beans without reading bytecode, ask a running +development server: the `backend_beans` tool described in <> lists +every bean, its scope and what was injected into it. + +A process runs one backend. Starting a second while the first is running fails +with an error; starting again after it has stopped is fine. To scale out, run more +processes: keep sessions in the database store and give scheduled jobs a lock, so +the instances share them. + === Virtual threads and the request loop The concurrency model is the part most worth understanding, because it's what @@ -123,11 +193,59 @@ arrived, and it parks. Parking returns control to the host, which polls again. When the handler finishes a response the descriptor stays armed, so the next request on that connection costs no system call to set up. +These are ParparVM's own virtual threads, a feature of the translated runtime, and +they aren't the virtual threads of Java 21 and later. The two share a name and +little else. A Java virtual thread unmounts whenever the JDK blocks on its behalf. +One of these switches stacks in the translated C code and parks only where the +runtime has made parking possible: waiting on a socket. That covers the connection +it serves and every outbound one -- a PostgreSQL or MySQL query, a `Web` call, a +TLS handshake -- so a handler waiting on another service costs a parked stack, and +the host serves other connections meanwhile. Anything else that blocks holds the +host thread under it and every virtual thread that host runs: SQLite and file +access, which are local calls rather than sockets, resolving a host name, and +`Object.wait()`. They're also pinned: a virtual thread stays on one host for its +whole life. + +That's why the development run on the JVM doesn't use Java's virtual threads to +stand in for them, even for debugging. It serves connections on a pool of platform +threads and runs `VIRTUAL` work on platform threads too. Code that's correct +there can still stall a host on the native build, so test anything that depends on +how threads interleave -- a handler that waits, a task that blocks -- on the +native server. + Host count follows cores rather than the `workers` argument. On two pinned cores, 16 hosts served 117 requests where 2 hosts served 257,297: past one host per core they compete for the cores the server needs. In this mode `workers` stops meaning "requests in flight," because the virtual threads supply that. +=== The life of a request + +A server's handlers are arranged in a chain, and each one either answers a request or +passes it on by answering `null`. The order is fixed, so which handler wins +never depends on registration order or on what happens to be in a directory: + +.The life of one request +image::img/backend-request-lifecycle.svg["A request passing through the MCP endpoint, trace relay, management endpoints, the application's routes and static files, inside a wrapper that records metrics and saves the session",scaledwidth=95%] + +. A WebSocket upgrade is matched first, against the `@WebSocketMapping` routes, + and never reaches the chain. +. The server's own endpoints come next, each only when it's turned on: MCP at + `/mcp`, the trace relay at `/otel/v1/traces`, and the management endpoints + under `/manage`. They come before the application's routes so a catch-all + route can't answer a health check. +. Then the application's routes. +. Static files from `cn1.static.root` are always last, so a file can't shadow a + route by being named like one. +. A request nothing answered is a 404. + +Around the chain sits one wrapper, and it's where the per-request features +live. It starts the clock for the request duration metric, makes the request +available to request-scoped beans, and records the request for the development +tools. After the chain answers it saves the session and adds its cookie, destroys +the request's request-scoped beans, and records the duration under the route that +matched. A handler that throws is answered with a 500 whose body says only +`internal error`, and the exception goes to the log rather than to the client. + === Configuration and profiles A server runs against a file on a laptop and a managed database in production, @@ -200,7 +318,8 @@ there is nothing next to the binary. | The port. Also read from `PORT`. | `cn1.server.workers` -| The size of the request thread pool. +| The size of the request thread pool, 16 by default. Under virtual threads it + no longer bounds the requests in flight; see <>. | `cn1.server.tls.certificate`, `cn1.server.tls.key` | A PEM certificate and key to terminate TLS with. One without the other is @@ -212,673 +331,92 @@ there is nothing next to the binary. | `cn1.orm.createTables` | Whether the generated daos create their tables at start-up. True on a development profile, false everywhere else. -|=== - -Handlers that need any of this get it from the entry point the build writes. -Only a server that writes its own `main` names the builder behind it: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/ServerSnippets.java[tag=backend-builder,indent=0] ----- - -`run()` reads the configuration, opens the database, binds, installs a shutdown -handler that drains what's in flight, and blocks. Anything the builder is told -is used as given, and anything it isn't told comes from the configuration -- -so a port written in the source is the port, and a port that should follow the -deployment is one nobody writes there. - -=== Talking to a database - -`DataSource.open` takes a SQLite path or a PostgreSQL or MySQL URL, pools -connections to it, and returns rows as the same Java types whichever engine -answered: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java[tag=backend-database,indent=0] ----- - -There is no JDBC driver involved. SQLite is linked into the binary, and the -PostgreSQL and MySQL clients speak their wire protocols directly, and a -`mariadb://` URL is served by the same client. MySQL 8.0 and MariaDB 10.4 and -newer are supported: a string key needs a case-sensitive NO PAD collation, so -`"A"` and `"a"` are two keys and `"token "` keeps its space, and the two server -families spell that collation differently. Which one is used comes from the -server rather than from the URL scheme, so a `mysql://` URL pointed at MariaDB -is still correct. - -Statements are written once, in one portable form: `?` for every parameter, and -plain unquoted names. PostgreSQL binds `$1` rather than `?`, and that difference -stops inside `execute` and `query` rather than at every call site. SQL already -written for one engine keeps working, because a statement carrying no `?` at all -is passed through untouched -- so hand-written `$1` is left alone. A literal -question mark that isn't a parameter is written `??`, which matters on -PostgreSQL, whose `jsonb` operators are spelled `?`, `?|` and `?&`. - -A parameter count that doesn't match the statement is refused before the -statement is sent. The three engines answer a mismatch three different ways, and -SQLite's answer is to bind the missing parameters to NULL and commit the row. - -The other thing the engines disagree about is the key an insert generated. -`insert` asks whichever way this engine answers -- `last_insert_rowid()` on -SQLite, `LAST_INSERT_ID()` on MySQL, and `INSERT ... RETURNING` on PostgreSQL, -which has no last-insert-id concept at all: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java[tag=backend-database-insert,indent=0] ----- -Several statements that have to be one go through `inTransaction`, which holds a -single connection for the whole body and rolls back if it throws: +| `cn1.config.location` +| The directory the properties files are read from; the working directory by + default. Read only from a system property or the environment, since it + decides where the files are. -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java[tag=backend-database-transaction,indent=0] ----- +| `cn1.server.backlog` +| The listen backlog, 512 by default. -Everything each engine spells differently is reachable through `db.dialect()`, -which is what code that generates schema needs: +| `cn1.server.shutdownTimeoutMillis` +| How long a stop waits for the requests in flight, 10 seconds by default. Zero + means don't wait. -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java[tag=backend-database-dialect,indent=0] ----- - -Quoting every identifier isn't caution. PostgreSQL folds an unquoted name to -lower case while SQLite and MySQL preserve it, so a column called `createdAt` -becomes `createdat` on one engine of the three, and code that reads rows by name -stops finding it there. +| `cn1.server.tls.http2` +| Whether a TLS server offers HTTP/2 through ALPN. True by default. -A `Database` -- one connection rather than a pool -- is still available through -`Database.open` for code that wants exactly one, and every one of its operations -is synchronized, so sharing one across handlers is safe and serialized. That's the -right shape for a single-file SQLite server and the wrong one for a database -that's a machine across a network: there the single connection isn't a safety -property, it's the bottleneck. The pool is the default for that reason. +| `cn1.static.prefix`, `cn1.static.index`, `cn1.static.cacheControl` +| Where static files are served (`/static`), the file a directory answers with + (`index.html`), and their `Cache-Control` header (`public, max-age=3600`). -=== Storing objects +| `cn1.datasource.pool.borrowTimeoutMillis` +| How long a request waits for a free connection before it fails, 10 seconds by + default. -A class with `@Entity` on it gets a data access object written for it at build -time. The annotations are the ones the SQLite ORM chapter documents, and they -are the same annotations in the same package, because an entity is the one class -both halves of an application own: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Reminder.java[tag=backend-orm-entity,indent=0] ----- - -What differs between the app's copy and the server's is what the build generates -from it: a module compiled against the Codename One core gets a dao over the -local SQLite database, and a module compiled against this runtime gets one whose -statements are built for whichever engine the connection turns out to be. The -class says what the data is; the module says where its rows live. - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/OrmSnippets.java[tag=backend-orm-dao,indent=0] ----- - -The entity manager comes from the entry point, which opens one when the build -generated at least one entity. A controller asks for it by declaring a -constructor that takes one, and the generated entry point calls that constructor: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/ReminderApi.java[tag=backend-orm-controller,indent=0] ----- - -That's the whole of the injection. A constructor taking a `DataSource` gets the -pool instead, a constructor taking no arguments is called with none, and a -controller that needs something else builds it itself. There's no container: the -generated entry point holds a `new` with the argument written into it, and a -controller that declares a dependency nothing configured is refused at start-up -rather than handed a null to fail on later. - -The generated entry point is also the only place that registration can live, and -that's a consequence of the build order rather than a preference. This runtime -has no reflection and the translator drops a class nothing references, so the -generated `cn1app.BackendDaoBootstrap` holds a direct reference to every dao and -is what keeps them in the binary. It's written during `process-classes`, AFTER -javac has compiled the module's own sources -- so a `main` you write yourself -can't name it, and a module that sets its own `mainClass` can't reach the -generated daos at all. Use `DataSource` directly there, or let the build write -the entry point and put the startup work in a controller. - -Opening an entity manager is otherwise a single call over a pool, which is what -a test does: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/OrmSnippets.java[tag=backend-orm-open,indent=0] ----- - -Queries name JAVA FIELDS rather than columns, and the builder quotes the column -each one maps to: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/OrmSnippets.java[tag=backend-orm-query,indent=0] ----- - -A name that isn't a field of the entity is refused at once, listing the -ones that are, rather than reaching the server as a column it doesn't have. -`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `like`, `in`, `isNull` and `isNotNull` are -joined with AND in the order they were added, and `list`, `first`, `count` and -`delete` end the chain. A query the builder can't express takes SQL instead, -through `dao.find(where, params)`, which is the point at which portability -becomes yours to keep. - -Transactions take the same shape as everywhere else: the entity manager the body -is handed is pinned to one connection, so every dao reached through it runs inside -the transaction. - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/OrmSnippets.java[tag=backend-orm-transaction,indent=0] ----- - -Three things this doesn't do, on purpose. Relationships aren't supported -- -`@OneToMany` and friends don't exist, and a field referencing another entity -fails the build with a message saying to persist the foreign key as a scalar. -`createTable` creates a table that isn't there and does nothing at all to one -that is, so it's a convenience for development and for tests rather than a -migration tool. And a boolean, a date and a char are stored as integers on every -engine -- 0 or 1, epoch milliseconds, and the UTF-16 code unit -- because a -native timestamp comes back as text whose format follows the server's own time -zone and would not round-trip the same way on three engines, and because a char -that was never assigned holds NUL, which PostgreSQL refuses inside a text value. -A field declared as a primitive gets a `NOT NULL` column, because a primitive has -no null to read: a nullable column would load as `0`, `false` or `\0`, which is -indistinguishable from a row that holds those. Declare the field as its boxed -type -- `Integer` rather than `int` -- when the column can be empty. - -=== Sharing the contract with the app - -This is where having the same language on both ends stops being a slogan. An -interface annotated for the REST client generates the app's client: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesApi.java[tag=backend-contract,indent=0] ----- - -Building the backend module with `-Dcn1.restServer=true` generates two more types -from that same interface: `NotesApiServer`, a synchronous interface the backend -implements, and `NotesApiDispatcher`, which routes a method, path and body to it -and binds the path and query parameters. - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/NotesEndpoint.java[tag=backend-contract-server,indent=0] ----- - -The client's methods are asynchronous because a UI can't block; the server's -methods are synchronous because a handler has nothing to call back into. One declaration -produces both shapes, which is what gRPC does and for the same reason. - -The payoff is that changing the contract breaks the build on whichever side did -not follow it, instead of producing a response the app fails to parse in the -field. The data transfer objects are shared rather than transcribed, and their -codecs are generated on both sides, so there is no handwritten mapping layer to -drift. - -The server half is off by default. Every existing project carries these -interfaces for its client alone, and generating server classes into those builds -would grow them for nothing. - -=== Real-time with WebSockets - -The server speaks RFC 6455, so a Codename One app can hold a live connection to a -Codename One server with the same language on both ends. The client half is -`com.codename1.io.WebSocket`, which every port has shipped for years; this is the -other half of it. - -An endpoint implements `com.codename1.backend.WebSocket`: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java[tag=backend-websocket-echo,indent=0] ----- - -The build finds it, the way it finds a `@RestController`: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java[tag=backend-websocket-annotated,indent=0] ----- - -The annotation goes on the type rather than on a method, because a `@GetMapping` -marks a call and a WebSocket is a connection: its contract is seven callbacks that -share per-connection state, which is an object. What it keeps from `@GetMapping` is -what a reader cares about -- the path is relative to a class-level -`@RequestMapping`, a constructor taking a `DataSource` or an `EntityManager` is -injected the same way, and two endpoints claiming one path is a build error rather -than something registration order decides. - -==== A server that writes its own main - -The annotation is the answer for an ordinary project, and most servers need -nothing else: the build finds the endpoint and the entry point it writes registers -it. The builder below is the same narrow case as the one under -<> -- a server that writes its own `main` instead of -using the generated one -- and it names its endpoints the same way it names its -handlers, through a callback the runtime invokes while it starts. - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java[tag=backend-websocket-register,indent=0] ----- - -`run()` is what makes that a main: it binds, installs a shutdown handler that -drains what's in flight, and blocks. `start()` exists for tests, which want the -server back without a signal handler. - -There's no `server.websocket(path, endpoint)` to call once the server is running, -and the absence is deliberate. Nothing else here is configured that way, and a -setter on a running server has a window a callback can't: the listener is bound -before the application gets its reference back, so an upgrade arriving in that gap -finds a route that hasn't been added yet and is answered as ordinary HTTP. - -`HttpServer` takes the same callback, for the rare server built without the -builder at all: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java[tag=backend-websocket-raw,indent=0] ----- - -Only `onOpen`, `onText` and `onBinary` have to be written. `onPing`, `onPong`, -`onClose` and `onError` have empty defaults, and a PING is answered with its PONG -before the endpoint is told, so an endpoint that ignores them still keeps its -connections alive. - -==== One endpoint, many connections - -An endpoint is created once for the route, not once per client, which is the same -shape a `@RestController` has. Everything belonging to one client lives on the -`WebSocketSession` the callbacks are handed, and `setAttachment` is where an -endpoint puts its own per-connection state. - -Messages arrive whole. A client that splits a two megabyte upload into thirty-two -frames produces one `onBinary`, and a text message is validated as UTF-8 before it -is decoded, so a handler never sees a replacement character standing in for bytes -the client didn't send. The array passed to `onBinary` is the session's own -reassembly buffer and is valid only until that call returns -- the same contract -`HttpServer.Request` carries, and for the same reason. An endpoint that keeps the -bytes copies the range it wants. - -==== Sending from somewhere else - -A callback runs on the thread that owns its connection, and the server reads -nothing more from that connection until it returns. An endpoint may therefore -block, and a slow one slows down its own client and nobody else's. - -Sending is the other way round: a session may be written to from any thread, which -is what makes a broadcast possible. - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java[tag=backend-websocket-broadcast,indent=0] ----- - -Each session serializes its own writers, so two threads can't interleave halves of -two messages on one connection. What the server doesn't do is queue: `sendText` -writes to the socket and blocks if the peer has stopped reading. That's honest -back pressure rather than a buffer that grows until the machine runs out, but it -means one unresponsive client can hold up a loop that broadcasts to every other -one. A server with many clients and large messages should broadcast from a small -pool rather than from the receiving thread. - -==== Subprotocols - -An endpoint that speaks more than one protocol lists them best first: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/WebSocketSnippets.java[tag=backend-websocket-subprotocol,indent=0] ----- - -The server's order decides, not the client's, so a client can't select a -deprecated protocol over a current one by listing it first. When nothing matches, -the handshake still succeeds and names no protocol, which is what the standard -describes; `getSubprotocol` then answers null. - -==== A connection, not a request - -A WebSocket is a connection that stopped being HTTP, and the difference is -something an author can feel. - -Under virtual threads -- the default on the packaged runtime -- each connection -owns one, and a parked virtual thread costs a stack rather than an operating system -thread. Ten thousand mostly silent connections is the workload that model was built -for. - -On the thread pool it's a worker per connection for as long as the connection -lasts. That's the mode the local development loop always runs in, because the JVM -has no virtual threads here, and it's also the mode any TLS server runs in. A -pooled deployment serving many WebSockets has to size `workers` for them, because -unlike a request they don't give the worker back. - -That makes `workers` the ceiling on concurrent connections in pool mode, and going -past it fails in a way worth knowing about: the process stays healthy, the listener -stays bound, and new connections are simply refused, because no worker ever comes -back to accept them. An eight-worker server driven by the conformance suite -reached case 9.4.4 and refused everything after it. The server says so now -- -it logs once when WebSockets hold half the pool -- and the two ways out are more -workers or a shorter `CN1_WS_IDLE_TIMEOUT_MS`, so abandoned connections give their -worker back sooner. On virtual threads none of this applies. - -The idle timeout is separate for the same reason. `CN1_HTTP_TIMEOUT_MS` sheds a -client that began a request and stopped; a WebSocket is idle by design and would -be shed within seconds by that rule. `CN1_WS_IDLE_TIMEOUT_MS` governs these -instead, defaults to five minutes, and accepts 0 for connections that may stay -silent indefinitely. `CN1_WS_MAX_MESSAGE_MB` bounds reassembly, because a message -is as large as the peer chooses to make it. - -`getMetrics()` reports `webSocketConnections`, and an open session doesn't count -towards `activeRequests` -- it's a connection, and counting it would make an idle -server read as permanently saturated. - -`stop()` sends every open connection a 1001 "going away" close before the drain -window, so a shutdown reads as one to the client rather than as a network failure. - -==== How the frame layer is checked - -Most of RFC 6455 is about frames a conformant client never sends, which is how a -server ends up wrong in ways nothing it talks to will reveal. The -https://github.com/crossbario/autobahn-testsuite[Autobahn|Testsuite] is what finds -those, and `vm/backend/ws-conformance.sh` runs it: - ----- -vm/backend/ws-conformance.sh --arm javase # or --arm native ----- - -It needs Docker or Podman, starts an echo server, and drives ~300 cases through -it. The gate has no per-case tolerance: every case must pass, and `NON-STRICT` -counts as a failure, because a lenient frame parser is the thing this is looking -for. The set of cases that ran also has to match a committed manifest, so the -suite can't shrink unnoticed to the ones that happen to pass. - -Sections 12 and 13 are excluded while permessage-deflate is unimplemented. They're -excluded rather than tolerated: accepting their result as a pass would also accept -it for a case that used to work. - -=== Tracing with OpenTelemetry - -A server can report every request it serves to any OpenTelemetry collector, and -the reporting needs no code in the handlers. Turn it on at build time with the -annotation, on any class in the backend module: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/TracingSnippets.java[tag=backend-otel-annotation,indent=0] ----- - -Without touching the source, set it in `application.properties` instead: - ----- -cn1.otel.enabled=true ----- - -Either one makes the generated entry point install a tracer. From then on: - -* every request is a server span, named after the route that matched - (`GET /notes/{id}`), with its status, method and path; -* every outbound `Web` call is a client span, and sends the W3C `traceparent` - and `tracestate` headers so the service it reaches joins the same trace; -* every `Database` statement is a client span carrying the SQL as the code wrote - it, placeholders and all -- the bound values are never recorded; -* an incoming `traceparent` makes the request part of the caller's trace, which is - how a Codename One app's spans connect to the backend's own (see - <>). - -A WebSocket connection isn't a span. It can stay open for hours and carry any -number of messages, so it has no single start and end to time, and its upgrade -request is answered before tracing begins. - -Without the annotation or the property, nothing refers to the tracer and the -translator leaves it out of the binary. - -Spans go out over OTLP/HTTP, as binary protobuf by default, batched on a -thread of their own so a slow collector never holds up a request. A queue that -fills because the collector is down drops spans and counts them, and -`HttpServer.getMetrics()` reports those counts. The settings are the standard -OpenTelemetry environment variables, so a deployment configures this server the -same way it configures everything else it runs: - -[cols="2,2,3"] -|=== -| Variable | Property | Meaning - -| `OTEL_EXPORTER_OTLP_ENDPOINT` | `cn1.otel.endpoint` | The collector's base URL; `/v1/traces` is appended. Defaults to `http://localhost:4318`. -| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `cn1.otel.traces.endpoint` | The full URL, used as it is. -| `OTEL_EXPORTER_OTLP_HEADERS` | `cn1.otel.headers` | `name=value` pairs, comma separated, with each value URL-encoded. -| `OTEL_EXPORTER_OTLP_PROTOCOL` | `cn1.otel.protocol` | `http/protobuf` or `http/json`. gRPC isn't supported. -| `OTEL_SERVICE_NAME` | `cn1.otel.service.name` | Overrides the annotation's `serviceName`. -| `OTEL_RESOURCE_ATTRIBUTES` | `cn1.otel.resource.attributes` | Extra resource attributes, `key=value` pairs. -| `OTEL_TRACES_SAMPLER` | `cn1.otel.sampler` | `parentbased_always_on` by default; also `always_on`, `always_off`, `traceidratio` and the other `parentbased_` forms. -| `OTEL_TRACES_SAMPLER_ARG` | `cn1.otel.sampler.arg` | The ratio for the ratio samplers. -| `OTEL_SDK_DISABLED` | `cn1.otel.disabled` | `true` turns tracing off at start-up without a rebuild. +| `cn1.datasource.busyTimeoutMillis` +| How long SQLite waits on a locked database file, 5 seconds by default. |=== -`cn1.otel.attributes.exclude` names attributes never to record, for example -`db.query.text` in a code base that builds SQL by concatenating values. +One limit is read only from the environment, because it's fixed when the server +class loads: `CN1_HTTP_MAX_UPLOAD_MB`, 64 by default, bounds the request bodies +being received at once across all HTTP/1.1 connections. A request that would pass +the limit gets a 503 rather than being buffered. -Sending spans to Dynatrace, which accepts OTLP over HTTP as protobuf, is a -matter of pointing the endpoint at the environment's OTLP API and passing an -ingest token: +The chapters that follow add their own keys -- `cn1.session.*`, +`cn1.task.executor.*`, `cn1.management.*`, `cn1.mcp.*` and `cn1.otel.*` -- and +list each one where it's described. ----- -OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://abc12345.live.dynatrace.com/api/v2/otlp/v1/traces -OTEL_EXPORTER_OTLP_HEADERS=Authorization=Api-Token%20dt0c01.XXXX ----- +==== Settings in source -A server started without the builder -- one that calls `HttpServer.start` or runs -the Lambda loop itself -- installs the tracer in one line: +A server that ships as one executable often has no properties file beside it, so +the common settings can also be written as annotations, on any class in the +backend module: [source,java] ---- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/TracingSnippets.java[tag=backend-otel-install,indent=0] ----- - -Under Lambda, the invocation continues the trace the host passes in its X-Ray -header, and the loop waits for the export before it polls again, because the host -freezes the process between invocations. - -`Tracing.current()` returns the request's span, for adding an attribute of the -application's own, and `Tracing.inSpan` times a block of work as a child span. - -==== Relaying the app's spans - -A mobile app shouldn't carry the collector's credentials: anything inside an app -package can be read by whoever installs it. With `cn1.otel.relay=true` the backend -accepts the app's spans at `/otel/v1/traces`, adds its own credentials and -forwards them to the same collector: - ----- -cn1.otel.relay=true -# Optional: a shared secret the app sends in X-CN1-Telemetry-Token. -cn1.otel.relay.token=${RELAY_TOKEN} -# Only for a web app served from another origin. -cn1.otel.relay.corsOrigin=https://app.example.com +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AppSettings.java[tag=backend-settings-annotations,indent=0] ---- -The relay takes OTLP/JSON, rebuilds it against the OTLP schema -- a field the -schema doesn't name is dropped and a malformed id is refused -- and queues it for -export in whatever protocol the server exports with. It answers as soon as the -spans are queued, and answers 503 when the queue is full so the app backs off. -`cn1.otel.relay.maxBytes` and `cn1.otel.relay.maxSpans` bound a single export. - -=== What it costs +Each attribute is one key -- `port` is `cn1.server.port`, `store` is +`cn1.session.store` -- and the build compiles its value in as the bottom layer of +the configuration, below `application.properties`. A properties file or the +environment still changes it without a rebuild, and an attribute left out sets +nothing. A value the server would refuse at start-up, such as a session store +that isn't `memory` or `db`, is a build error naming the class instead, and so +are two classes that give one key different values. -Same handler, three ways, plus Go for an outside reference. Two pinned cores, 64 -connections, medians of three interleaved runs on the plaintext route: - -[cols="2,1,1,1,1"] -|=== -| Runtime | Requests/sec | p50 | p99 | Cold start - -| Codename One native, musl -| 595,610 -| 0.090 ms -| 0.249 ms -| 0.77 ms - -| Codename One native, glibc -| 547,761 -| 0.065 ms -| 4.06 ms -| 2.88 ms - -| Go, fasthttp -| 496,293 -| 0.104 ms -| 2.63 ms -| 2.39 ms - -| The same handler on the JVM -| 187,745 -| 0.260 ms -| 1.60 ms -| 82.5 ms +[cols="2,3", options="header"] |=== +| Annotation | Keys -Cold start is the interesting column. It's measured from process spawn to the -first accepted connection, and the static binary reaches it in under a -millisecond -- about a hundred times faster than the same code on a JVM, and -three times faster than Go. That number is the whole serverless argument. - -Throughput is the least interesting one. Beating a tuned Go server by a fifth on -a microbenchmark isn't a reason to move a service; it's only evidence that the -translation doesn't cost you anything. - -.Latency at the median against the 99th percentile -image::img/backend-latency-slope.svg[A slope chart showing that the musl build's tail stays close to its median while the others fan out,scaledwidth=90%] +| `@ServerConfig` +| `cn1.server.port`, `workers`, `backlog`, `shutdownTimeoutMillis` -The slope chart is the one that matters. Every runtime here has a similar -median. What differs is the distance to the 99th percentile, and that distance is -garbage collection. With the response pooled the plaintext route allocates about -0.1 bytes per request, the collector never runs, and the tail stays at 2.8 times -the median. Give the same server a handler that allocates a map per request and -its tail goes to 80 ms, because the collector shares the cores with the server. +| `@DataSourceConfig` +| `cn1.datasource.url`, `pool.size`, `pool.borrowTimeoutMillis`, + `busyTimeoutMillis` -The honest rule is that the tail follows your allocation rate, not the runtime -badge. The runtime gives you the tools to allocate nothing on the hot path; it -doesn't do it for you. +| `@SessionConfig` +| `cn1.session.store`, `timeout`, `cookie`, `same-site`, `secure`, `namespace` -==== Memory and size +| `@StaticFilesConfig` +| `cn1.static.root`, `prefix`, `index`, `cacheControl` -[cols="2,1,1"] -|=== -| Runtime | Binary or artifact | Resident under load - -| Codename One native, musl -| 7.95 MB static -| 10-40 MB - -| Codename One native, glibc -| 3.19 MB dynamic -| 14 MB +| `@OpenTelemetry` +| turns tracing on and names the service; `cn1.otel.endpoint`, `protocol`, + `sampler`, `sampler.arg` -| Go, fasthttp -| 5.63 MB static -| 6.3 MB +| `@EnableManagement` +| builds the management endpoints in; `cn1.management.enabled`, `path` -| The same handler on the JVM -| 0.13 MB jar, plus a JRE -| 190 MB +| `@EnableMcpServer` +| builds the MCP endpoint in; `cn1.mcp.path`, `allowedOrigins` |=== -The JVM row is the same handler and the same protocol code. Everything it costs -above the native rows is the runtime underneath it. - -The resident figures move around more than the latency ones, because the -collector keeps a pool of pages sized to the busiest moment the process has seen -and gives them back gradually. At rest the native builds sit near 3 MB. - -==== musl or glibc +Secrets -- a token, a password inside a database URL -- don't belong in source. +Name them as `${NAME}` in the annotation or leave them to the environment, which +is where the server reads a token from. -Both are supported, and they aren't equivalent. The static musl build starts -faster and has a far shorter tail. The glibc build has a better median, because -its allocator is better under contention, and a smaller binary, because it links -the system libraries instead of carrying them. - -The reason to pick musl isn't the median. It's that the artifact is one file -with nothing underneath it, which is what makes the container the binary and the -cold start a process exec. - -==== What isn't measured here - -GraalVM is missing from these tables on purpose. A fair comparison would have to -run the same handler, and this handler can't run on GraalVM: the runtime's -native methods are ParparVM's, so comparing would mean benchmarking a different -server written against a different framework and reporting it as though the -toolchains had been compared. That's a benchmark worth building, and it isn't -this one. - -=== Deploying it - -The musl build is a single static file, so the container that carries it can be -empty: - ----- -FROM scratch -COPY bench-linux-musl-arm64 /server -ENTRYPOINT ["/server"] ----- - -There is no base image to patch, because there is no base image. `cn1:backend-package` -builds for the machine it runs on; the cross-compiled targets -(`musl-x86_64`, `musl-arm64`, `glibc-x86_64`, `glibc-arm64`) are produced by -`vm/backend/package.sh` in the Codename One repository, which drives one builder -image per target. - -For AWS Lambda, `LambdaRuntime` implements the custom runtime loop. The Lambda -Runtime API is a plaintext poll over loopback, so it needs no listening socket and -no TLS, and what it does need is exactly what a translated binary is good at. - -=== Limits worth knowing - -* The class library is the Codename One runtime, not Java SE. A server dependency - that assumes the full JDK won't translate, and Maven Central isn't the - ecosystem this draws on. -* Routing, configuration, a connection pool and a build-time ORM are the whole of - the framework. What a controller can be handed is the pool or the entity - manager, by declaring a constructor that takes one; there is no container, no - aspect layer and no starter ecosystem, and validation and error mapping are - written by hand or generated from the REST contract. -* The packaging goal compiles Java. It recompiles the module's sources against the - backend's class library instead of reusing the jar Maven built, which is what - keeps a server off classes the runtime doesn't have, and is also why Kotlin is - not wired into this path yet even though the client ports support it. It uses - the JDK running Maven -- any release from 8 up -- and `-Dcn1.backend.jdk` names - a different one. What it can't change is the language level: the module's - sources are compiled at Java 8, because that's the bytecode the translator - reads. -* A virtual thread parks on the socket it's SERVING, and not on any other. An - outbound read -- a database query, an HTTP call to another service -- blocks the - host thread that's running it, because only the server's own descriptors are - registered with a poller that can wake them. With one host per core, that many - concurrent slow outbound calls occupy every host and other connections wait. - Servers that mostly compute and serve get the full benefit; servers whose - handlers spend their time waiting on a database should size for that, or run the - thread pool with `CN1_HTTP_POLL_MODE=0`. -* TLS runs on the thread pool, whatever the poll mode says. The TLS layer can't - park a read yet, and a blocking read on a virtual thread holds its host for the - duration, so one idle TLS client per core would occupy every one of them. The - server says so at startup when it makes that choice. -* Native builds target Linux. Development happens anywhere a JVM runs. -* WebSockets are RFC 6455 over HTTP/1.1. There is no permessage-deflate, so - messages go out uncompressed no matter how compressible, and no RFC 8441, so - a browser that reaches this server over HTTP/2 opens a second connection for its - WebSocket rather than carrying it on the first. Both are what every client - already falls back to. `wss` works on the packaged runtime through the same TLS - the rest of the server uses, and not in the local loop, which terminates no - TLS. - -The reasons to choose this are cold start, footprint, deployment shape, and one -language across the app and its server. If none of those matter for the service in -front of you, use a JVM framework. +Handlers that need any of this get it from the entry point the build writes. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index c9ecbac11b1..c06c3097ef5 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -119,6 +119,22 @@ include::Video-Capture-Constraints.asciidoc[] include::Backend.asciidoc[] +include::Backend-Web.asciidoc[] + +include::Backend-Beans.asciidoc[] + +include::Backend-Data.asciidoc[] + +include::Backend-Sessions.asciidoc[] + +include::Backend-Scheduling.asciidoc[] + +include::Backend-Observability.asciidoc[] + +include::Backend-MCP.asciidoc[] + +include::Backend-Operations.asciidoc[] + = Device and platform services include::Push-Notifications.asciidoc[] diff --git a/docs/developer-guide/img/backend-build-pipeline.svg b/docs/developer-guide/img/backend-build-pipeline.svg new file mode 100644 index 00000000000..96e48244585 --- /dev/null +++ b/docs/developer-guide/img/backend-build-pipeline.svg @@ -0,0 +1,67 @@ + + + + What the build does with a backend module + The annotations are read from the compiled classes after javac runs, and everything they ask for + is written out as plain code before the translator sees the module. Nothing is decided at run time. + Your sources + + @RestController + controllers + + @Service @Component + beans + + @Configuration + @Bean + factory methods + + @Transactional @Async + aspects + + @Scheduled @McpTool + jobs and tools + + + javac + bytecode + + process-classes + + Resolve the bean graph + constructors, injection points, + scopes and conditions: a missing + or ambiguous bean fails here + + Rewrite annotated methods + the body moves to name$cn1body; + the method keeps its name and + calls the aspect around it + + Compile routes and schedules + each route becomes byte[] tests; + each literal cron becomes six + bit masks, checked here + + Generated beside your classes + + BackendApplication + the main + + BackendWiring + every new, in dependency order + + <Controller>Router + one per controller + + <Class>Cn1Aspects + begin/commit, submit, timing + + <Bean>Cn1Scoped, Cn1Lazy + stand-ins for scoped beans + + + ParparVM, then clang + or the JVM, for cn1:backend + + A failed injection, a cron typo and a route clash are build errors that name the class and member. + diff --git a/docs/developer-guide/img/backend-request-lifecycle.svg b/docs/developer-guide/img/backend-request-lifecycle.svg new file mode 100644 index 00000000000..05d2428154d --- /dev/null +++ b/docs/developer-guide/img/backend-request-lifecycle.svg @@ -0,0 +1,62 @@ + + + + The life of one request + Handlers are asked in a fixed order and the first one that answers wins. The wrapper around them + is where metrics, the session cookie and request-scoped beans are taken care of. + + Connection + its own virtual thread, + or a pool worker + + + Upgrade? + WebSocket routes are + matched before the chain + + + Request wrapper + start the clock; note the current request + + 1 MCP endpoint + /mcp, when enabled + + not its path: next + + 2 Trace relay + /otel/v1/traces, when enabled + + not its path: next + + 3 Management + /manage/..., when enabled + + not its path: next + + 4 Your routes + your controllers' generated routers + + not its path: next + + 5 Static files + cn1.static.root, always last + no such file: 404 + then: record duration, route and status; save the session and + add its cookie; destroy the request-scoped beans + a handler that throws is answered 500 and logged + + + Response + + What a route does with the request: + + the router compares path bytes, so no + String is built for the target; + path, query, header and body values are + bound and converted; a missing required + one is answered 400 before your method runs; + the return value becomes text, JSON or + your own Response; returning null is a 404; + void is 204 unless @ResponseStatus says + otherwise. + diff --git a/docs/developer-guide/img/backend-schedule-timelines.svg b/docs/developer-guide/img/backend-schedule-timelines.svg new file mode 100644 index 00000000000..8769b6f950b --- /dev/null +++ b/docs/developer-guide/img/backend-schedule-timelines.svg @@ -0,0 +1,102 @@ + + + + fixedRate, fixedDelay and a run that overruns + Each bar is one run of the same job with a period of 10 seconds. A run never overlaps the previous + run of the same job: a start that finds it still going is skipped, never queued. + fixedRate = 10000 + 10s from start to start + + + + + + + + + + + + + + 0s + + + 20s + + + 40s + + + 60s + + + 80s + + + 100s + fixedDelay = 10000 + 10s from end to next start + + + + + + + + + + + 0s + + + 20s + + + 40s + + + 60s + + + 80s + + + 100s + fixedRate, slow run + one run takes 25s + + + + skipped + + skipped + + + + + + + + + + 0s + + + 20s + + + 40s + + + 60s + + + 80s + + + 100s + + A cron job is the same: the next fire time is computed from the schedule, and a fire that finds + the previous run still going counts as skipped in /manage/jobs and backend_jobs. + diff --git a/docs/developer-guide/img/backend-session-lifecycle.svg b/docs/developer-guide/img/backend-session-lifecycle.svg new file mode 100644 index 00000000000..5133e9fdf0c --- /dev/null +++ b/docs/developer-guide/img/backend-session-lifecycle.svg @@ -0,0 +1,45 @@ + + + + A session from the first request to the last + Nothing is stored and no cookie is sent until a handler asks for a session. The store and the cookie are + updated after the handler returns, as part of the same response. + + No session + no cookie, nothing stored; + getSession(false) is null + + New + getSession(true) creates it; + Set-Cookie: CN1SESSION=id + + Active + the cookie comes back; each + getSession() records the access + + Ended + invalidate(): cookie cleared + with Max-Age=0 + + + + + changeSessionId(): new id, + same attributes, new cookie + + idle longer than cn1.session.timeout: expired + + cn1.session.store=memory (default) + Sessions live in this process. Any attribute value works, + and the instance you set is the instance you get back. + A restart or a second instance does not see them, so + behind a load balancer this needs sticky sessions. + + cn1.session.store=db + Sessions live in cn1_http_session in the server's own + database, attributes stored as JSON, so every instance + sees every session. Values must be strings, numbers, + booleans, or maps and lists of those. + + Expired sessions are removed at most once a minute, by the next request that asks for its session. + diff --git a/docs/developer-guide/img/backend-transaction-propagation.svg b/docs/developer-guide/img/backend-transaction-propagation.svg new file mode 100644 index 00000000000..ba277f1e27c --- /dev/null +++ b/docs/developer-guide/img/backend-transaction-propagation.svg @@ -0,0 +1,44 @@ + + + + What each propagation does when a transaction is already open + Outer() is @Transactional and calls inner(). The bars are database connections; time runs left to right. + A connection is borrowed, and BEGIN sent, only when the method first touches the database. + REQUIRED (default) + + connection 1 + + + inner(): same connection, no BEGIN + BEGIN + COMMIT + inner() joins the outer transaction: one connection, one COMMIT. + If inner() fails and outer() catches it, the transaction is still rollback-only: outer() throws UnexpectedRollback. + REQUIRES_NEW + + connection 1 + + + + suspended + connection 2 + + inner(): BEGIN ... COMMIT + BEGIN + COMMIT + The outer transaction is suspended, inner() runs on a second connection + and commits or rolls back on its own. It needs a second connection from the pool. + NESTED + + connection 1 + + + SAVEPOINT cn1_sp_1 ... RELEASE + BEGIN + COMMIT + inner() runs inside the outer transaction, behind a savepoint. A failure + rolls back to the savepoint only, and outer() can still commit. + + SUPPORTS joins if one is open. MANDATORY refuses to run without one, NEVER refuses to run inside one, + and NOT_SUPPORTED suspends the open one for the duration of the call. + diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 9179b5b5598..b55c63d062e 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -749,7 +749,7 @@ Bodymovin keyframes? # ----------------------------------------------------------------------------- -# Server-side backend (Backend.asciidoc). +# Server-side backend (Backend*.asciidoc). # ----------------------------------------------------------------------------- # Two of the JVM server frameworks the backend chapter positions itself against. # Product names, so the dictionary has neither. @@ -768,6 +768,12 @@ Testsuite # The container runtime, alongside Docker, that the conformance harness accepts. # A product name. Podman +# The Unix scheduler whose expression format @Scheduled takes. The standard name +# for that format, lowercase as every scheduler documents it. +cron +# The SQL construct a NESTED transaction rolls back to. SQL's own keyword, and the +# term every database's documentation uses. +savepoints? # ----------------------------------------------------------------------------- # Attaching an agent to a build on a device (MCP-Headless-API.asciidoc). diff --git a/maven/backend/spotbugs-exclude.xml b/maven/backend/spotbugs-exclude.xml index f99a8ea15ff..da673069930 100644 --- a/maven/backend/spotbugs-exclude.xml +++ b/maven/backend/spotbugs-exclude.xml @@ -125,4 +125,15 @@ + + + + + + diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java new file mode 100644 index 00000000000..e8f3406a3b0 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -0,0 +1,3351 @@ +/* + * 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. + */ +package com.codename1.backend; + +import com.codename1.backend.mcp.McpServer; +import com.codename1.backend.mcp.McpTool; +import com.codename1.backend.metrics.Counter; +import com.codename1.backend.metrics.Histogram; +import com.codename1.backend.metrics.Metrics; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.TimeZone; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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; + +/** + * The runtime pieces the build-generated wiring calls, each on its own: cron, + * the scheduler, background tasks, sessions, metrics and the MCP endpoint. The + * whole path from annotations to a running server is the plugin's + * BackendBeansTest; these pin down behaviour that test cannot reach cheaply. + */ +class ApplicationRuntimeTest { + + // ------------------------------------------------------------------ cron + + @Test + @DisplayName("a configured cron expression naming a day no allowed month has is refused") + void impossibleCronIsRefused() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> CronSchedule.parse("0 0 0 30 2 *", "UTC")); + assertTrue(e.getMessage().contains("never fire"), e.getMessage()); + CronSchedule.parse("0 0 0 29 2 *", "UTC"); // a leap day exists + } + + @Test + @DisplayName("a schedule decades between matches is still found") + void cronFindsARareMatch() { + // February 29th on a Monday: 2016, then 2044. + CronSchedule s = CronSchedule.parse("0 0 0 29 2 MON", "UTC"); + long jan2020 = 1577836800000L; + long feb29of2044 = 2340316800000L; + assertEquals(feb29of2044, s.next(jan2020)); + } + + @Test + @DisplayName("a cron time a spring-forward night skips does not fire an hour late") + void cronSkipsTheDstGap() { + CronSchedule s = CronSchedule.parse("0 30 2 * * *", "America/New_York"); + // From 2026-03-07 12:00Z: 02:30 on the 8th does not exist in New York + // (02:00 jumps to 03:00), so the next firing is 02:30 EDT on the 9th, + // not 03:30 EDT on the 8th (1772955000000). + assertEquals(1773037800000L, s.next(1772884800000L)); + } + + @Test + @DisplayName("cron finds the next matching second, and the ones after it") + void cronNext() { + CronSchedule every15 = CronSchedule.parse("*/15 * * * * *", "UTC"); + long t = 1767225600000L; // 2026-01-01T00:00:00Z + assertEquals(t + 15000, every15.next(t)); + assertEquals(t + 30000, every15.next(t + 15000)); + CronSchedule weekdays = CronSchedule.parse("0 30 9 * * MON-FRI", "UTC"); + // 2026-01-01 is a Thursday: the next is that day at 09:30. + assertEquals(t + (9 * 3600 + 30 * 60) * 1000L, weekdays.next(t)); + // From Friday 09:30, the next is Monday 09:30. + long friday = t + 86400000L + (9 * 3600 + 30 * 60) * 1000L; + assertEquals(friday + 3 * 86400000L, weekdays.next(friday)); + } + + @Test + @DisplayName("the last day of the month, and a date that never exists") + void cronLastDayAndNever() { + CronSchedule last = CronSchedule.parse("0 0 0 L * *", "UTC"); + long jan1 = 1767225600000L; + assertEquals(jan1 + 30 * 86400000L, last.next(jan1)); // Jan 31 + // The 30th of February is refused when parsed now; built from masks + // directly, as the generated code does, it still answers "never". + long feb = 1L << 2; + long day30 = 1L << 30; + assertEquals(-1, new CronSchedule(1L, 1L, 1L, day30, feb, 0x7fL, false, "UTC", + "0 0 0 30 2 *").next(jan1)); + } + + @Test + @DisplayName("a zone moves the wall clock the expression is read in") + void cronZone() { + CronSchedule noonBerlin = CronSchedule.parse("0 0 12 * * *", "Europe/Berlin"); + long jan1 = 1767225600000L; + // Winter: Berlin is UTC+1, so noon there is 11:00 UTC. + assertEquals(jan1 + 11 * 3600000L, noonBerlin.next(jan1)); + CronSchedule fixed = CronSchedule.parse("0 0 12 * * *", "+05:30"); + assertEquals(jan1 + (6 * 3600 + 30 * 60) * 1000L, fixed.next(jan1)); + assertNotNull(TimeZone.getTimeZone("Europe/Berlin")); + } + + @Test + @DisplayName("a malformed field names itself") + void cronRefusals() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> CronSchedule.parse("0 0 25 * * *", "UTC")); + assertTrue(e.getMessage().contains("hour"), e.getMessage()); + e = assertThrows(IllegalArgumentException.class, + () -> CronSchedule.parse("0 0 * * *", "UTC")); + assertTrue(e.getMessage().contains("leading 0"), e.getMessage()); + } + + // -------------------------------------------------------------- scheduler + + @Test + @DisplayName("a manual trigger after the scheduler stopped starts nothing") + void noTriggerAfterStop() throws Exception { + Scheduler scheduler = new Scheduler(null); + final AtomicInteger runs = new AtomicInteger(); + scheduler.fixedDelay("job", 1000000, 1000000, null, Tasks.PLATFORM, null, -1, + new Runnable() { + public void run() { + runs.incrementAndGet(); + } + }); + scheduler.start(); + scheduler.stop(1000); + assertFalse(scheduler.trigger("job"), "a stopped scheduler started a run"); + Thread.sleep(100); + assertEquals(0, runs.get()); + } + + @Test + @DisplayName("an @Async call dropped at the shutdown deadline fails its Future") + void droppedAsyncFails() throws Exception { + Tasks.Registry registry = Tasks.open(null); + TaskExecutor one = new TaskExecutor("one", false, 1, registry); + final CountDownLatch started = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + one.execute(new Runnable() { + public void run() { + started.countDown(); + try { + release.await(); + } catch (InterruptedException err) { + // interrupted by the shutdown + } + } + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + AsyncTask queued = new AsyncTask("test.dropped", false) { + protected Object call() { + return "ran"; + } + }; + one.execute(queued); + one.shutdown(50); + release.countDown(); + assertTrue(queued.isDone(), "a dropped call's Future never finished"); + assertThrows(java.util.concurrent.ExecutionException.class, () -> queued.get()); + Tasks.shutdown(registry, 0); + } + + @Test + @DisplayName("two metrics that render as one Prometheus name are refused") + void prometheusNameClash() { + Metrics.gauge("test.clash.total", "", "", new com.codename1.backend.metrics.Gauge.Source() { + public double read() { + return 1; + } + }); + assertThrows(IllegalArgumentException.class, + () -> Metrics.counter("test_clash", "", ""), + "a counter test_clash exports as test_clash_total, the gauge's name"); + assertThrows(IllegalArgumentException.class, + () -> Metrics.gauge("test_clash_total", "", "", + new com.codename1.backend.metrics.Gauge.Source() { + public double read() { + return 2; + } + })); + } + + @Test + @DisplayName("an enum argument is matched by its constant name, not its toString") + void enumArgumentsByName() { + Map args = new LinkedHashMap(); + args.put("state", "PENDING"); + assertEquals(Labelled.PENDING, com.codename1.backend.mcp.McpArgs.enumValue( + Labelled.values(), args, "state", true)); + args.put("state", "Waiting for payment"); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.enumValue(Labelled.values(), args, + "state", true)); + } + + enum Labelled { + PENDING; + + public String toString() { + return "Waiting for payment"; + } + } + + @Test + @DisplayName("a counter refuses to wrap past the 64-bit range") + void counterOverflow() { + Counter c = Metrics.counter("test.overflow", "", ""); + c.add(Long.MAX_VALUE - c.get()); + assertThrows(IllegalStateException.class, () -> c.add(1)); + assertEquals(Long.MAX_VALUE, c.get()); + } + + @Test + @DisplayName("a metric registered again in another unit is kept, as Micrometer keeps it, and warned about") + void metricUnitMismatch() throws Exception { + Counter first = Metrics.counter("test.unit.counter", "", "ms"); + java.io.PrintStream err = System.err; + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + System.setErr(new java.io.PrintStream(captured, true, "UTF-8")); + try { + assertTrue(first == Metrics.counter("test.unit.counter", "", "s")); + assertTrue(first == Metrics.counter("test.unit.counter", "", "s")); + assertTrue(first == Metrics.counter("test.unit.counter", "", "")); + } finally { + System.setErr(err); + } + String warned = captured.toString("UTF-8"); + assertTrue(warned.contains("test.unit.counter is registered again with unit \"s\""), + warned); + assertEquals(warned.indexOf("test.unit.counter"), warned.lastIndexOf("test.unit.counter"), + "warned more than once: " + warned); + assertEquals("ms", first.getUnit()); + } + + @Test + @DisplayName("a gauge replacing a shared one survives the old source's removal") + void replacedSharedGaugeSurvives() { + com.codename1.backend.metrics.Gauge.Source old = + new com.codename1.backend.metrics.Gauge.Source() { + public double read() { + return 1; + } + }; + Metrics.addSource("test.shared.replace", "", "", old); + com.codename1.backend.metrics.Gauge replacement = Metrics.gauge("test.shared.replace", + "", "", new com.codename1.backend.metrics.Gauge.Source() { + public double read() { + return 2; + } + }); + Metrics.removeSource("test.shared.replace", old); // the old server stops + assertTrue(Metrics.get("test.shared.replace") == replacement, + "removing the replaced source deleted the application's gauge"); + } + + @Test + @DisplayName("label values that print alike are one histogram series, as Prometheus sees them") + void labelValuesThatPrintAlikeShareASeries() { + Histogram h = Metrics.histogram("test.labels.text", "", "ms", null, + new String[] {"flag", "code"}); + h.record(1, Boolean.TRUE, Long.valueOf(1), null); + h.record(2, "true", "1", null); + List points = h.points(); + assertEquals(1, points.size(), "two series print as one label set: " + points); + assertEquals(Long.valueOf(2), ((Map) points.get(0)).get("count")); + String text = Metrics.prometheus(); + assertEquals(text.indexOf("test_labels_text_count{"), + text.lastIndexOf("test_labels_text_count{"), text); + } + + @Test + @DisplayName("a float argument out of the float range is refused, not made infinite") + void floatArgumentRange() { + Map args = new LinkedHashMap(); + args.put("f", new Double(1e100)); + args.put("ok", new Double(1.5)); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.floatValue(args, "f", true)); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.floatObject(args, "f", true)); + assertEquals(1.5f, com.codename1.backend.mcp.McpArgs.floatValue(args, "ok", true)); + } + + @Test + @DisplayName("a string argument refuses an array or object, and takes a scalar's text") + void stringArgumentRefusesStructures() { + Map args = new LinkedHashMap(); + List list = new ArrayList(); + list.add("42"); + Map object = new LinkedHashMap(); + object.put("id", "42"); + args.put("list", list); + args.put("object", object); + args.put("number", Long.valueOf(42)); + args.put("text", "42"); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.string(args, "list", true)); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.string(args, "object", true)); + assertEquals("42", com.codename1.backend.mcp.McpArgs.string(args, "number", true)); + assertEquals("42", com.codename1.backend.mcp.McpArgs.string(args, "text", true)); + assertNull(com.codename1.backend.mcp.McpArgs.string(args, "absent", false)); + } + + @Test + @DisplayName("a session cookie name that is not an HTTP token is refused") + void cookieNameValidated() throws Exception { + String[] bad = {"", "my session", "a;b", "x\u0001"}; + for(int iter = 0 ; iter < bad.length ; iter++) { + Properties p = new Properties(); + p.setProperty("cn1.session.cookie", bad[iter]); + final Config c = Config.of(p, "test"); + assertThrows(IOException.class, () -> Sessions.configure(c, false, null, null), + "accepted cookie name [" + bad[iter] + "]"); + } + } + + @Test + @DisplayName("a refused shared gauge leaves nothing behind for a retry to skip past") + void refusedSharedGaugeLeavesNoEntry() { + Metrics.gauge("test.shared.x", "", "", new com.codename1.backend.metrics.Gauge.Source() { + public double read() { + return 1; + } + }); + final com.codename1.backend.metrics.Gauge.Source src = + new com.codename1.backend.metrics.Gauge.Source() { + public double read() { + return 2; + } + }; + assertThrows(IllegalArgumentException.class, + () -> Metrics.addSource("test_shared_x", "", "", src)); + assertThrows(IllegalArgumentException.class, + () -> Metrics.addSource("test_shared_x", "", "", src), + "a retry found the failed entry and registered no gauge"); + } + + @Test + @DisplayName("an Error while the application is built still undoes the start") + void errorDuringCreateCleansUp() throws Exception { + final boolean[] destroyed = new boolean[1]; + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + assertThrows(NoClassDefFoundError.class, () -> Backend.builder( + Config.of(settings, "test")).quiet() + .application(new EmptyApplication() { + public HttpServer.Handler[] create(Backend.Environment environment) { + throw new NoClassDefFoundError("com/example/Missing"); + } + + public void stopped() { + destroyed[0] = true; + } + }).start()); + assertTrue(destroyed[0], "the beans of a start that failed with an Error were kept"); + } + + @Test + @DisplayName("a null and an empty first label are two histogram series") + void histogramNullAndEmptyLabels() { + Histogram h = Metrics.histogram("test.labels.nullempty", "", "ms", null, + new String[] {"route", "method", null}); + h.record(1, null, "GET", null); + h.record(2, "", "GET", null); + assertEquals(2, h.points().size(), "null and \"\" were merged into one series"); + } + + @Test + @DisplayName("an MCP extension that fails to install stops the server it was starting") + void failedMcpAttachStopsTheServer() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + assertThrows(IllegalStateException.class, () -> Backend.builder( + Config.of(settings, "dev")).quiet() + .mcp(new McpServer.Extension() { + public void install(McpServer server, Backend backend) { + throw new IllegalStateException("refused"); + } + }) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }).start()); + // The port is free again: the listener did not outlive the failed start. + ServerSocket again = new ServerSocket(port); + again.close(); + } + + @Test + @DisplayName("an abandoned @Async call fails its Future instead of never finishing") + void abandonedAsyncFails() throws Exception { + AsyncTask task = new AsyncTask("test.abandoned", false) { + protected Object call() { + return "never"; + } + }; + task.abandon("stopped"); + assertTrue(task.isDone()); + assertThrows(java.util.concurrent.ExecutionException.class, () -> task.get()); + } + + @Test + @DisplayName("the longest fixed delay or rate runs once, not back to back") + @org.junit.jupiter.api.Timeout(value = 30, threadMode = + org.junit.jupiter.api.Timeout.ThreadMode.SEPARATE_THREAD) + void hugePeriodsDoNotWrap() throws Exception { + final AtomicInteger delayRuns = new AtomicInteger(); + final AtomicInteger rateRuns = new AtomicInteger(); + Scheduler scheduler = new Scheduler(null); + scheduler.fixedDelay("delay", 0, Long.MAX_VALUE, null, Tasks.PLATFORM, null, -1, + new Runnable() { + public void run() { + delayRuns.incrementAndGet(); + } + }); + scheduler.fixedRate("rate", 0, Long.MAX_VALUE, null, Tasks.PLATFORM, null, -1, + new Runnable() { + public void run() { + rateRuns.incrementAndGet(); + } + }); + scheduler.start(); + try { + long deadline = System.currentTimeMillis() + 5000; + while((delayRuns.get() < 1 || rateRuns.get() < 1) + && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + Thread.sleep(300); + } finally { + scheduler.stop(1000); + } + assertEquals(1, delayRuns.get(), "the delay wrapped into the past"); + assertEquals(1, rateRuns.get(), "the rate wrapped into the past"); + } + + @Test + @DisplayName("fixed-delay jobs run, never overlap, and stop with the scheduler") + void schedulerRunsAndStops() throws Exception { + final AtomicInteger runs = new AtomicInteger(); + final AtomicInteger concurrent = new AtomicInteger(); + final AtomicInteger overlap = new AtomicInteger(); + Scheduler scheduler = new Scheduler(null); + scheduler.fixedRate("rate", 0, 5, null, Tasks.PLATFORM, null, -1, new Runnable() { + public void run() { + if(concurrent.incrementAndGet() > 1) { + overlap.incrementAndGet(); + } + runs.incrementAndGet(); + try { + Thread.sleep(15); + } catch (InterruptedException ignored) { + // test + } + concurrent.decrementAndGet(); + } + }); + scheduler.start(); + long deadline = System.currentTimeMillis() + 5000; + while(runs.get() < 5 && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + scheduler.stop(1000); + int after = runs.get(); + Thread.sleep(100); + assertTrue(after >= 5, "only " + after + " runs"); + assertEquals(0, overlap.get(), "a run overlapped the previous one"); + assertEquals(after, runs.get(), "a run started after stop"); + List jobs = scheduler.describe(); + assertEquals("rate", ((Map)jobs.get(0)).get("name")); + assertTrue(((Number)((Map)jobs.get(0)).get("skipped")).longValue() > 0, + "a 5ms rate with 15ms runs must skip fires"); + Tasks.shutdown(1000); + } + + @Test + @DisplayName("a lock is refused without a database") + void lockNeedsADatabase() { + Scheduler scheduler = new Scheduler(null); + assertThrows(IllegalStateException.class, () -> scheduler.cron("x", + CronSchedule.parse("@hourly", null), null, Tasks.PLATFORM, "x", -1, + new Runnable() { + public void run() { + } + })); + } + + @Test + @DisplayName("an async task completes its future, and a failure reaches the caller") + void asyncTask() throws Exception { + AsyncTask ok = new AsyncTask("ok", false) { + protected Object call() { + return AsyncResult.of("done"); + } + }; + Tasks.platform(ok); + assertEquals("done", ok.get(5, TimeUnit.SECONDS)); + AsyncTask bad = new AsyncTask("bad", false) { + protected Object call() { + throw new IllegalStateException("no"); + } + }; + Tasks.platform(bad); + java.util.concurrent.ExecutionException err = assertThrows( + java.util.concurrent.ExecutionException.class, () -> bad.get(5, TimeUnit.SECONDS)); + assertTrue(err.getCause() instanceof IllegalStateException); + Tasks.shutdown(1000); + } + + @Test + @DisplayName("an async call returning another's pending future does not hold its worker") + void chainedFutureDoesNotStarveItsExecutor() throws Exception { + final TaskExecutor solo = new TaskExecutor("solo", false, 1, null); + AsyncTask outer = new AsyncTask("outer", false) { + protected Object call() { + AsyncTask inner = new AsyncTask("inner", false) { + protected Object call() { + return AsyncResult.of("inner-done"); + } + }; + solo.execute(inner); // queued behind this, on the only thread + return inner; + } + }; + solo.execute(outer); + assertEquals("inner-done", outer.get(10, TimeUnit.SECONDS), + "the outer call held the only worker waiting for the inner one"); + AsyncTask failing = new AsyncTask("failing", false) { + protected Object call() { + AsyncTask inner = new AsyncTask("broken", false) { + protected Object call() { + throw new IllegalStateException("inner failed"); + } + }; + solo.execute(inner); + return inner; + } + }; + solo.execute(failing); + java.util.concurrent.ExecutionException err = assertThrows( + java.util.concurrent.ExecutionException.class, + () -> failing.get(10, TimeUnit.SECONDS)); + assertEquals("inner failed", err.getCause().getMessage()); + solo.shutdown(1000); + } + + @Test + @DisplayName("an executor shut down with the longest wait waits for its tasks") + void hugeShutdownWaitWaits() throws Exception { + // A guard, not a reproduction: shutdown() only takes deadline - now, + // which wraparound already kept right. It holds whichever way the wait + // is written. + TaskExecutor e = new TaskExecutor("patient", false, 1, null); + final AtomicInteger ran = new AtomicInteger(); + e.execute(new Runnable() { + public void run() { + try { + Thread.sleep(150); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + ran.incrementAndGet(); + } + }); + e.execute(new Runnable() { + public void run() { + ran.incrementAndGet(); + } + }); + e.shutdown(Long.MAX_VALUE); + assertEquals(0, e.getDroppedCount(), "the wait overflowed and gave up at once"); + assertEquals(2, ran.get()); + } + + @Test + @DisplayName("the longest timed get waits instead of timing out at once") + void hugeTimeoutWaits() throws Exception { + assertEquals(Long.MAX_VALUE, AsyncTask.deadline(System.currentTimeMillis(), + TimeUnit.DAYS.toMillis(Long.MAX_VALUE))); + final CountDownLatch go = new CountDownLatch(1); + AsyncTask slow = new AsyncTask("slow", false) { + protected Object call() throws Exception { + go.await(5, TimeUnit.SECONDS); + return AsyncResult.of("done"); + } + }; + Tasks.platform(slow); + Thread opener = new Thread(new Runnable() { + public void run() { + try { + Thread.sleep(100); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + go.countDown(); + } + }); + opener.start(); + assertEquals("done", slow.get(Long.MAX_VALUE, TimeUnit.DAYS)); + Tasks.shutdown(1000); + } + + @Test + @DisplayName("a virtual task falls back to a platform thread where there are none") + void virtualFallsBack() throws Exception { + final CountDownLatch ran = new CountDownLatch(1); + Tasks.virtual(new Runnable() { + public void run() { + ran.countDown(); + } + }); + assertTrue(ran.await(5, TimeUnit.SECONDS)); + Tasks.shutdown(1000); + } + + // --------------------------------------------------------------- sessions + + @Test + @DisplayName("two servers' database session stores never read each other's sessions") + void dbSessionNamespaces(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + DataSource pool = DataSource.open(new java.io.File(dir, "ns.db").getAbsolutePath(), + 2, 5000, 10000); + try { + Sessions.Db orders = new Sessions.Db(pool, "com.example.orders"); + Sessions.Db admin = new Sessions.Db(pool, "com.example.admin"); + long now = System.currentTimeMillis(); + HttpSession signedIn = new HttpSession("shared-cookie", now, now, 1800); + signedIn.markNew(); + signedIn.setAttribute("user", "ada"); + orders.save(signedIn, null); + assertNull(admin.load("shared-cookie"), + "another server accepted this server's session cookie"); + admin.delete("shared-cookie"); + assertEquals(0, admin.purgeExpired(Long.MAX_VALUE / 4)); + assertEquals("ada", orders.load("shared-cookie").getAttribute("user")); + // A replica of the same server shares them. + assertEquals("ada", new Sessions.Db(pool, "com.example.orders") + .load("shared-cookie").getAttribute("user")); + } finally { + pool.close(); + } + } + + @Test + @DisplayName("the database session store: no resurrection, and no early expiry for a short timeout") + void dbSessionStore(@org.junit.jupiter.api.io.TempDir java.io.File dir) throws Exception { + DataSource pool = DataSource.open(new java.io.File(dir, "s.db").getAbsolutePath(), + 2, 5000, 10000); + try { + Sessions.Db store = new Sessions.Db(pool); + long now = System.currentTimeMillis(); + HttpSession created = new HttpSession("sid", now, now, 1800); + created.markNew(); + created.setAttribute("user", "ada"); + store.save(created, null); + // Two requests hold their own copies; one logs out. + HttpSession a = store.load("sid"); + HttpSession b = store.load("sid"); + assertNotNull(a); + store.delete(a.getId()); + b.setAttribute("seen", "yes"); + store.save(b, null); + assertNull(store.load("sid"), "a stale copy brought an invalidated session back"); + + // Two copies each changing their own attribute: merged, not replaced, + // and the slower one's older last use does not move it back. + HttpSession fresh = new HttpSession("merge", now, now, 1800); + fresh.markNew(); + store.save(fresh, null); + HttpSession one = store.load("merge"); + HttpSession two = store.load("merge"); + one.touch(now + 5000); + one.setAttribute("theme", "dark"); + two.setAttribute("lang", "en"); + store.save(one, null); + store.save(two, null); + HttpSession merged = store.load("merge"); + assertEquals("dark", merged.getAttribute("theme"), "a stale copy discarded a change"); + assertEquals("en", merged.getAttribute("lang")); + assertEquals(now + 5000, merged.getLastAccessedTime(), + "a slower request moved the last use back"); + // A stale copy rotating after another request invalidated the session + // must not bring it back under the new id. + HttpSession live = new HttpSession("rot", now, now, 1800); + live.markNew(); + store.save(live, null); + HttpSession stale = store.load("rot"); + store.delete("rot"); // a logout elsewhere + String rotated = stale.changeSessionId(); + store.save(stale, "rot"); + assertNull(store.load(rotated), "a stale rotation recreated a logged-out session"); + two.removeAttribute("lang"); + store.save(two, null); + assertNull(store.load("merge").getAttribute("lang")); + assertEquals("dark", store.load("merge").getAttribute("theme")); + + // A ten-second session last written eleven seconds ago may be a + // read-only one used every few seconds whose touches were never + // written; it is not expired until the touch interval has passed too. + HttpSession short10 = new HttpSession("short", now, now - 11000, 10); + short10.markNew(); + store.save(short10, null); + HttpSession back = store.load("short"); + assertFalse(back.isExpired(now), "expired before the touch interval allowed"); + assertTrue(back.isExpired(now + 2000)); + assertEquals(2500L, Sessions.Db.touchInterval(10)); + assertEquals(60000L, Sessions.Db.touchInterval(1800)); + } finally { + pool.close(); + } + } + + @Test + @DisplayName("an expired session's beans are not destroyed while a request is still using them") + void inUseSessionBeansSurviveThePurge() throws Exception { + final List ended = new ArrayList(); + Sessions sessions = new Sessions(new EmptyApplication() { + public void sessionEnded(Object[] beans) { + ended.add(beans); + } + }); + long now = System.currentTimeMillis(); + HttpSession session = new HttpSession("busy", now, now, 1); // a 1-second timeout + session.owner = sessions; + session.scopedBeans(1)[0] = "cart"; + HttpServer.Request request = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + session.markNew(); + sessions.getStore().save(session, null); + sessions.enter(request, session); + // Two minutes on -- long past the timeout and the purge interval -- and + // the request is still running: its beans, and the session itself in the + // store, must survive. + sessions.purgeIfDue(sessions.getStore(), now + 120000); + assertTrue(ended.isEmpty(), "a running request's session beans were destroyed"); + assertNotNull(sessions.getStore().load("busy"), + "a running request's session was purged from the store"); + sessions.leave(request); + sessions.purgeIfDue(sessions.getStore(), now + 240000); + assertEquals(1, ended.size(), "the idle session's beans were never destroyed"); + + // A request that ends its session and starts another: the replacement's + // beans are in use too. + HttpSession first = new HttpSession("first", now, now, 1); + first.owner = sessions; + HttpServer.Request switching = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + sessions.enter(switching, first); + HttpSession replacement = new HttpSession("second", now, now, 1); + replacement.owner = sessions; + sessions.enter(switching, replacement); + replacement.scopedBeans(1)[0] = "new cart"; + sessions.purgeIfDue(sessions.getStore(), now + 360000); + assertEquals(1, ended.size(), "the replacement session's beans were destroyed in use"); + sessions.leave(switching); + } + + @Test + @DisplayName("histogram label keys may not collide in Prometheus, nor be its le") + void histogramLabelKeysChecked() { + assertThrows(IllegalArgumentException.class, () -> Metrics.histogram("test.lbl.a", + "", "ms", null, new String[] {"a.b", "a_b", null})); + assertThrows(IllegalArgumentException.class, () -> Metrics.histogram("test.lbl.b", + "", "ms", null, new String[] {"le", null, null})); + } + + @Test + @DisplayName("a scheduler refuses two jobs of one name") + void duplicateJobNames() { + Scheduler scheduler = new Scheduler(null); + Runnable body = new Runnable() { + public void run() { + } + }; + scheduler.fixedDelay("same", 1000, 1000, null, Tasks.PLATFORM, null, -1, body); + assertThrows(IllegalArgumentException.class, () -> scheduler.fixedDelay("same", 1000, + 1000, null, Tasks.PLATFORM, null, -1, body)); + } + + @Test + @DisplayName("a handler that invalidates its session and then throws an Error still logs out") + void errorAfterInvalidateStillEndsTheSession() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication()) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + if(request.getTarget().startsWith("/in")) { + request.getSession(true).setAttribute("user", "ada"); + return request.respond(200, "text/plain", "in".getBytes("UTF-8")); + } + if(request.getTarget().startsWith("/out")) { + request.getSession(true).invalidate(); + throw new AssertionError("after the logout"); + } + HttpSession s = request.getSession(false); + return request.respond(200, "text/plain", String.valueOf( + s == null ? null : s.getAttribute("user")).getBytes("UTF-8")); + } + }).start(); + try { + HttpURLConnection in = open(port, "/in"); + assertEquals("in", read(in)); + String pair = in.getHeaderField("Set-Cookie"); + pair = pair.substring(0, pair.indexOf(';')); + HttpURLConnection out = open(port, "/out"); + out.setRequestProperty("Cookie", pair); + // Answered 500, as Spring Boot's Tomcat answers a handler's Error. + assertEquals(500, out.getResponseCode()); + HttpURLConnection me = open(port, "/me"); + me.setRequestProperty("Cookie", pair); + assertEquals("null", read(me), "the logout was lost when the handler threw an Error"); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a bare HttpServer refuses sessions it could never store") + void standaloneSessionsRefused() { + HttpServer.Request request = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> request.getSession(true)); + assertTrue(e.getMessage().contains("Backend.builder()"), e.getMessage()); + } + + @Test + @DisplayName("a cron zone the runtime does not know is refused, not read as UTC") + void unknownCronZone() { + assertThrows(IllegalArgumentException.class, + () -> CronSchedule.parse("0 0 0 * * *", "Europe/Berli")); + CronSchedule.parse("0 0 0 * * *", "Europe/Berlin"); + } + + @Test + @DisplayName("a file response replaced by a 500 when the session cannot be stored is closed") + void fileResponseClosedWhenTheSessionFails(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + final java.io.File file = new java.io.File(dir, "body.txt"); + java.nio.file.Files.write(file.toPath(), "hello".getBytes("UTF-8")); + final SessionStore inner = new Sessions().getStore(); + SessionStore failing = new SessionStore() { + public HttpSession load(String id) throws IOException { + return inner.load(id); + } + + public void save(HttpSession session, String previousId) throws IOException { + throw new IOException("the store is down"); + } + + public void delete(String id) throws IOException { + inner.delete(id); + } + + public int purgeExpired(long now) throws IOException { + return inner.purgeExpired(now); + } + + public int size() { + return inner.size(); + } + }; + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .sessionStore(failing) + .application(new EmptyApplication() { + public HttpServer.Handler[] create(Backend.Environment environment) { + return new HttpServer.Handler[] {new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + request.getSession(true).setAttribute("seen", "yes"); + int fd = StaticFiles.trackFile( + FileIo.openRead(file.getAbsolutePath())); + return HttpServer.Response.file(200, "text/plain", fd, 0, + file.length(), null); + } + }}; + } + }).start(); + try { + int before = StaticFiles.openFileCount(); + assertEquals(500, open(port, "/file").getResponseCode()); + assertEquals(before, StaticFiles.openFileCount(), + "the discarded file response's descriptor was never closed"); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a stopped backend no longer holds the process slot") + void stoppedBackendReleasesTheSlot() throws Exception { + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }).start(); + backend.stop(); + java.lang.reflect.Field slot = Backend.class.getDeclaredField("processLive"); + slot.setAccessible(true); + assertNull(slot.get(null), "the stopped backend is still reachable from the slot"); + } + + @Test + @DisplayName("awaitTermination waits for the teardown a handler's stop() deferred") + void awaitTerminationWaitsForTheDeferredTeardown() throws Exception { + final Backend[] running = new Backend[1]; + final java.util.concurrent.atomic.AtomicBoolean destroyed = + new java.util.concurrent.atomic.AtomicBoolean(); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + running[0] = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication() { + public HttpServer.Handler[] create(Backend.Environment environment) { + return new HttpServer.Handler[] {new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + running[0].stop(); + return HttpServer.Response.text(200, "stopping"); + } + }}; + } + + public void stopped() { + try { + Thread.sleep(300); // a slow @PreDestroy + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + destroyed.set(true); + } + }).start(); + assertEquals("stopping", read(open(port, "/stop"))); + running[0].awaitTermination(); + assertTrue(destroyed.get(), + "awaitTermination returned while the teardown was still running"); + } + + @Test + @DisplayName("one process runs one backend: a second start is refused, a restart is not") + void oneBackendPerProcess() throws Exception { + final HttpServer.Handler none = new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }; + Properties first = new Properties(); + first.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + Backend running = Backend.builder(Config.of(first, "test")).quiet().handler(none).start(); + try { + final Properties second = new Properties(); + second.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + IllegalStateException refused = assertThrows(IllegalStateException.class, + () -> Backend.builder(Config.of(second, "test")).quiet().handler(none) + .start()); + assertTrue(refused.getMessage().contains("one process runs one backend"), + refused.getMessage()); + } finally { + running.stop(); + } + Properties again = new Properties(); + again.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + Backend restarted = Backend.builder(Config.of(again, "test")).quiet().handler(none) + .start(); + restarted.stop(); + } + + @Test + @DisplayName("an Error from the application's stopping hook does not abandon the shutdown") + void stopSurvivesAnError() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication() { + public void stopping() { + throw new AssertionError("broken hook"); + } + }) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }).start(); + backend.stop(); + ServerSocket again = new ServerSocket(port); // the listener is gone + again.close(); + } + + @Test + @DisplayName("a failed start waits for its start-up tasks before destroying their beans") + void failedStartDrainsItsTasks() throws Exception { + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + final java.util.concurrent.atomic.AtomicBoolean finished = + new java.util.concurrent.atomic.AtomicBoolean(); + final java.util.concurrent.atomic.AtomicBoolean seenAtDestroy = + new java.util.concurrent.atomic.AtomicBoolean(); + assertThrows(IllegalStateException.class, () -> Backend.builder( + Config.of(settings, "test")).quiet().shutdownTimeoutMillis(5000) + .application(new EmptyApplication() { + public HttpServer.Handler[] create(Backend.Environment environment) { + // A @PostConstruct that starts work, then a later bean + // that refuses its configuration. + Tasks.executor("boot", Tasks.PLATFORM).execute(new Runnable() { + public void run() { + long until = System.currentTimeMillis() + 300; + while(System.currentTimeMillis() < until) { + try { + Thread.sleep(20); + } catch (InterruptedException ignored) { + // keeps going, as a task may + } + } + finished.set(true); + } + }); + throw new IllegalStateException("a later bean refuses to start"); + } + + public void stopped() { + seenAtDestroy.set(finished.get()); + } + }).start()); + assertTrue(seenAtDestroy.get(), + "the beans were destroyed while a start-up task was still using them"); + } + + @Test + @DisplayName("a metric reader that declines to open is never shut down") + void declinedMetricReaderIsNotStopped() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + final AtomicInteger shutdowns = new AtomicInteger(); + // The dev profile turns the management endpoints on, so the server + // measures whatever the reader answers. + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet() + .metrics(new com.codename1.backend.metrics.MetricReader() { + public boolean open(Config config) { + return false; + } + + public void shutdown(int timeoutMillis) { + shutdowns.incrementAndGet(); + } + }) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }).start(); + backend.stop(); + assertEquals(0, shutdowns.get(), "stop() shut down a reader that never opened"); + } + + @Test + @DisplayName("an AUTO executor is pinned to the kind a later caller asks for by name") + void autoExecutorIsPinnedByAnExplicitKind() { + Tasks.Registry mine = Tasks.open(null); + try { + TaskExecutor first = Tasks.executor(mine, "shared", Tasks.AUTO); + assertFalse(first.isVirtual()); // no virtual hosts on this runtime + TaskExecutor again = Tasks.executor(mine, "shared", Tasks.VIRTUAL); + assertTrue(again == first); + assertTrue(again.isVirtual(), + "the explicit kind lost to the AUTO call that happened to come first"); + // The explicit kind is now the executor's, so the other one conflicts. + assertThrows(IllegalStateException.class, + () -> Tasks.executor(mine, "shared", Tasks.PLATFORM)); + } finally { + Tasks.shutdown(mine, 0); + } + } + + @Test + @DisplayName("the websocket wrapper forwards the endpoint's subprotocols") + void wrappedEndpointKeepsItsSubprotocols() { + WebSocket endpoint = new WebSocket() { + public void onOpen(WebSocketSession session) { + } + + public void onText(WebSocketSession session, String message) { + } + + public void onBinary(WebSocketSession session, byte[] m, int o, int l) { + } + + public String[] getSubprotocols() { + return new String[] {"chat.v2"}; + } + }; + Tasks.Registry mine = Tasks.open(null); + try { + String[] offered = new Backend.TaskBound(endpoint, mine).getSubprotocols(); + assertNotNull(offered, "the wrapper hid the endpoint's subprotocols"); + assertEquals("chat.v2", offered[0]); + } finally { + Tasks.shutdown(mine, 0); + } + } + + @Test + @DisplayName("a websocket callback carries its own server's executors") + void websocketCallbacksCarryTheirTasks() throws Exception { + final Tasks.Registry mine = Tasks.open(null); + final TaskExecutor[] seen = new TaskExecutor[1]; + WebSocket endpoint = new WebSocket() { + public void onOpen(WebSocketSession session) { + } + + public void onText(WebSocketSession session, String message) { + seen[0] = Tasks.executor("ws", Tasks.PLATFORM); + } + + public void onBinary(WebSocketSession session, byte[] m, int o, int l) { + } + }; + Tasks.Registry other = Tasks.open(null); // started later: the fallback + try { + new Backend.TaskBound(endpoint, mine).onText(null, "hi"); + assertTrue(seen[0] == Tasks.executor(mine, "ws", Tasks.PLATFORM), + "the callback used another server's executors"); + } finally { + Tasks.shutdown(mine, 0); + Tasks.shutdown(other, 0); + } + } + + @Test + @DisplayName("every loaded copy of a session shares one set of session-scoped beans") + void sessionBeansAreSharedAcrossCopies() { + Sessions sessions = new Sessions(); + long now = System.currentTimeMillis(); + HttpSession first = new HttpSession("same", now, now, 1800); + HttpSession second = new HttpSession("same", now, now, 1800); + first.owner = sessions; + second.owner = sessions; + Object[] beansA = first.scopedBeans(2); + beansA[0] = "cart"; + assertTrue(first.beanLock() == second.beanLock(), + "two copies of one session locked different objects"); + assertEquals("cart", second.scopedBeans(2)[0]); + } + + private static double served() { + Map point = (Map)Metrics.get("cn1.server.requests_served").points().get(0); + return ((Number)point.get("value")).doubleValue(); + } + + private static double number(Backend b) { + return ((Number)b.getServer().getMetrics().get("requestsServed")).doubleValue(); + } + + @Test + @DisplayName("a managed operation answers 404 only for what does not exist, 400 for bad input") + void managedOperationStatuses() throws Exception { + final ManagedBean cache = new ManagedBean() { + public String getObjectName() { return "cache"; } + public String getDescription() { return ""; } + public String[] attributeNames() { return new String[0]; } + public String[] attributeDescriptions() { return new String[0]; } + public Object readAttribute(int index) { return null; } + public String[] operationNames() { return new String[] {"evict"}; } + public String[] operationDescriptions() { return new String[] {""}; } + public String[][] operationParameters() { return new String[][] {{"key"}}; } + public Object invoke(int index, Map arguments) { + if(!arguments.containsKey("key")) { + throw new IllegalArgumentException("key is required"); + } + return "evicted"; + } + }; + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + settings.setProperty(Management.TOKEN, "t0k"); + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().management() + .application(new EmptyApplication() { + public HttpServer.Handler[] create(Backend.Environment environment) { + environment.registerManaged(cache); + return new HttpServer.Handler[0]; + } + }) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }).start(); + try { + assertEquals(200, manage(port, "/manage/managed/cache/evict", "{\"key\":\"a\"}")); + assertEquals(400, manage(port, "/manage/managed/cache/evict", "{}"), + "an argument the operation refused was reported as a missing endpoint"); + assertEquals(400, manage(port, "/manage/managed/cache/evict", "{not json"), + "a malformed body was an internal error"); + assertEquals(404, manage(port, "/manage/managed/cache/nope", "{}")); + assertEquals(404, manage(port, "/manage/managed/none/evict", "{}")); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("management is served only by a server built with it, whatever the profile") + void managementIsLinkedOnlyWhenAsked() throws Exception { + HttpServer.Handler none = new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }; + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + // A development profile, where management defaults on -- but the entry + // point never asked for it, so there is nothing to turn on. + Backend without = Backend.builder(Config.of(settings, "dev")).quiet().handler(none) + .start(); + try { + assertEquals(404, open(port, "/manage/health").getResponseCode()); + } finally { + without.stop(); + } + Backend with = Backend.builder(Config.of(settings, "dev")).quiet().management() + .handler(none).start(); + try { + assertEquals(200, open(port, "/manage/health").getResponseCode()); + } finally { + with.stop(); + } + } + + @Test + @DisplayName("compiled-in settings are the bottom layer: a properties value overrides them") + void compiledSettingsSitUnderTheFiles() throws Exception { + HttpServer.Handler none = new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }; + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().management() + .compiledSettings(new String[] {Management.PATH, "/ops", + Config.SERVER_PORT, "1"}) + .handler(none).start(); + try { + // The compiled path took effect, and the compiled port did not + // displace the one the properties set. + assertEquals(200, open(port, "/ops/health").getResponseCode()); + assertEquals(404, open(port, "/manage/health").getResponseCode()); + } finally { + backend.stop(); + } + Config config = Config.of(settings, "dev") + .withCompiledDefaults(new String[] {"cn1.session.cookie", "APP"}); + assertEquals("APP", config.get("cn1.session.cookie")); + assertTrue(config.keys().contains("cn1.session.cookie"), + "a compiled key was missing from the listing Tasks checks at start-up"); + assertEquals(String.valueOf(port), config.get(Config.SERVER_PORT)); + assertThrows(IllegalArgumentException.class, + () -> Config.of(settings, "dev").withCompiledDefaults(new String[] {"odd"})); + } + + private static int manage(int port, String path, String body) throws IOException { + HttpURLConnection c = open(port, path); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Authorization", "Bearer t0k"); + c.setRequestProperty("Content-Type", "application/json"); + OutputStream out = c.getOutputStream(); + out.write(body.getBytes("UTF-8")); + out.close(); + return c.getResponseCode(); + } + + private static long requestCount() { + long n = 0; + List points = Metrics.get("http.server.request.duration").points(); + for(int iter = 0 ; iter < points.size() ; iter++) { + n += ((Number)((Map)points.get(iter)).get("count")).longValue(); + } + return n; + } + + @Test + @DisplayName("a server without generated beans still applies the session settings") + void handlerOnlySessionSettings() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + settings.setProperty("cn1.session.cookie", "APPSESSION"); + settings.setProperty("cn1.session.same-site", "Strict"); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + request.getSession(true).setAttribute("k", "v"); + return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); + } + }) + .start(); + try { + HttpURLConnection c = open(port, "/"); + assertEquals("ok", read(c)); + String cookie = c.getHeaderField("Set-Cookie"); + assertTrue(cookie != null && cookie.startsWith("APPSESSION=") + && cookie.contains("SameSite=Strict"), String.valueOf(cookie)); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a task still queued when the shutdown deadline passes never starts") + void shutdownDropsQueuedWorkAtTheDeadline() throws Exception { + Tasks.Registry registry = Tasks.open(null); + TaskExecutor one = new TaskExecutor("one", false, 1, registry); + final CountDownLatch release = new CountDownLatch(1); + final CountDownLatch started = new CountDownLatch(1); + final AtomicInteger late = new AtomicInteger(); + one.execute(new Runnable() { + public void run() { + started.countDown(); + try { + release.await(); + } catch (InterruptedException err) { + // the shutdown interrupts it; fine + } + } + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + one.execute(new Runnable() { + public void run() { + late.incrementAndGet(); + } + }); + one.shutdown(50); + assertEquals(1, one.getDroppedCount()); + + release.countDown(); + Thread.sleep(200); + assertEquals(0, late.get(), "a queued task ran after the shutdown deadline"); + Tasks.shutdown(registry, 0); + } + + @Test + @DisplayName("a session is created on demand, found again by its cookie, and invalidated") + void sessions() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication()) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + String t = request.getTarget(); + if(t.startsWith("/login")) { + HttpSession s = request.getSession(true); + s.changeSessionId(); + s.setAttribute("user", "ada"); + return request.respond(200, "text/plain", "in".getBytes("UTF-8")); + } + if(t.startsWith("/me")) { + HttpSession s = request.getSession(false); + String who = s == null ? "nobody" : String.valueOf(s.getAttribute("user")); + return request.respond(200, "text/plain", who.getBytes("UTF-8")); + } + if(t.startsWith("/logout")) { + request.getSession(true).invalidate(); + return request.respond(200, "text/plain", "out".getBytes("UTF-8")); + } + if(t.startsWith("/twice")) { + // Ends two sessions in one request: both must go. + request.getSession(true).invalidate(); + request.getSession(true).invalidate(); + return request.respond(200, "text/plain", "gone".getBytes("UTF-8")); + } + if(t.startsWith("/nothing")) { + // A new session and no response to send its cookie on. + request.getSession(true).setAttribute("k", "v"); + return null; + } + if(t.startsWith("/switch")) { + // Ends the session and starts another in one request. + request.getSession(true).invalidate(); + String none = String.valueOf(request.getSession(false)); + HttpSession next = request.getSession(true); + next.setAttribute("user", "grace"); + return request.respond(200, "text/plain", + (none + "|" + next.isValid()).getBytes("UTF-8")); + } + return null; + } + }) + .start(); + try { + HttpURLConnection login = open(port, "/login"); + assertEquals(200, login.getResponseCode()); + String cookie = login.getHeaderField("Set-Cookie"); + assertNotNull(cookie, "no session cookie was sent"); + assertTrue(cookie.contains("HttpOnly") && cookie.contains("SameSite=Lax"), cookie); + String pair = cookie.substring(0, cookie.indexOf(';')); + HttpURLConnection me = open(port, "/me"); + me.setRequestProperty("Cookie", pair); + assertEquals("ada", read(me)); + HttpURLConnection anonymous = open(port, "/me"); + assertEquals("nobody", read(anonymous)); + assertNull(anonymous.getHeaderField("Set-Cookie"), + "a request that never asked for a session got one"); + HttpURLConnection logout = open(port, "/logout"); + logout.setRequestProperty("Cookie", pair); + assertEquals("out", read(logout)); + assertTrue(logout.getHeaderField("Set-Cookie").contains("Max-Age=0")); + HttpURLConnection after = open(port, "/me"); + after.setRequestProperty("Cookie", pair); + assertEquals("nobody", read(after)); + // Invalidated then replaced in one request: a lookup in between finds + // none, and the new session is what the client ends up with. + HttpURLConnection login2 = open(port, "/login"); + assertEquals("in", read(login2)); + String pair2 = login2.getHeaderField("Set-Cookie"); + pair2 = pair2.substring(0, pair2.indexOf(';')); + HttpURLConnection sw = open(port, "/switch"); + sw.setRequestProperty("Cookie", pair2); + assertEquals("null|true", read(sw)); + String replaced = null; + List cookies = sw.getHeaderFields().get("Set-Cookie"); + for(int iter = 0 ; iter < cookies.size() ; iter++) { + String c = (String)cookies.get(iter); + if(!c.contains("Max-Age=0")) { + replaced = c.substring(0, c.indexOf(';')); + } + } + assertNotNull(replaced, "the replacement session was never sent"); + HttpURLConnection whoNow = open(port, "/me"); + whoNow.setRequestProperty("Cookie", replaced); + assertEquals("grace", read(whoNow)); + HttpURLConnection oldOne = open(port, "/me"); + oldOne.setRequestProperty("Cookie", pair2); + assertEquals("nobody", read(oldOne)); + HttpURLConnection twice = open(port, "/twice"); + twice.setRequestProperty("Cookie", replaced); + assertEquals("gone", read(twice)); + HttpURLConnection afterTwice = open(port, "/me"); + afterTwice.setRequestProperty("Cookie", replaced); + assertEquals("nobody", read(afterTwice), + "the first of two sessions ended in one request survived"); + int kept = backend.getSessions().getStore().size(); + HttpURLConnection nothing = open(port, "/nothing"); + assertEquals(404, nothing.getResponseCode()); + assertEquals(kept, backend.getSessions().getStore().size(), + "a session whose cookie could never be sent was stored"); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a handler's own Set-Cookie survives beside the session's, and its Response is not touched") + void cookiesCoexist() { + HttpServer.Response r = new HttpServer.Response(200, "text/plain", new byte[0], + new LinkedHashMap(java.util.Collections.singletonMap("Set-Cookie", "a=1"))); + HttpServer.Response sent = Sessions.withHeader(r, "Set-Cookie", "b=2"); + Object both = sent.extraHeaders.get("Set-Cookie"); + assertTrue(both instanceof List && ((List)both).size() == 2, String.valueOf(both)); + // The handler's Response may be a shared constant: a cookie written into + // it would reach the next request that returns it. + assertTrue(sent != r, "the handler's Response was modified in place"); + assertEquals("a=1", r.extraHeaders.get("Set-Cookie")); + HttpServer.Response bare = new HttpServer.Response(200, "text/plain", new byte[0], null); + assertTrue(Sessions.withHeader(bare, "Set-Cookie", "c=3") != bare); + assertTrue(bare.extraHeaders == null, "a header-less shared Response gained a cookie"); + } + + @Test + @DisplayName("cn1.session.secure accepts auto, true or false and refuses anything else") + void secureSettingIsValidated() throws Exception { + Properties p = new Properties(); + p.setProperty("cn1.session.secure", "tru"); + try { + IOException refused = assertThrows(IOException.class, + () -> Sessions.configure(Config.of(p, "test"), true, null, null)); + assertTrue(refused.getMessage().contains("auto, true or false"), + refused.getMessage()); + p.setProperty("cn1.session.secure", "FALSE"); + Sessions.configure(Config.of(p, "test"), true, null, null); + p.setProperty("cn1.session.timeout", "-1800"); + IOException negative = assertThrows(IOException.class, + () -> Sessions.configure(Config.of(p, "test"), true, null, null)); + assertTrue(negative.getMessage().contains("cn1.session.timeout"), + negative.getMessage()); + } finally { + p.clear(); + } + } + + @Test + @DisplayName("a histogram keeps its own copy of its boundaries and label keys") + void histogramCopiesItsArrays() { + double[] bounds = {1, 2, 3}; + String[] labels = {"route"}; + Histogram h = Metrics.histogram("test.copied", "", "ms", bounds, labels); + bounds[0] = 100; + labels[0] = "renamed"; + h.getLabelKeys()[0] = "again"; + assertEquals("route", h.getLabelKeys()[0]); + h.record(0.5); + Map point = (Map)h.points().get(0); + assertEquals(1.0, ((Number)((List)point.get("bounds")).get(0)).doubleValue()); + assertThrows(IllegalArgumentException.class, () -> Metrics.histogram("test.unsorted", + "", "ms", new double[] {2, 1}, null)); + } + + @Test + @DisplayName("an MCP byte or short argument out of its range is refused, not wrapped") + void narrowArgumentsAreRangeChecked() { + Map args = new LinkedHashMap(); + args.put("s", new Long(40000)); + args.put("b", new Long(-129)); + args.put("ok", new Long(-128)); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.shortValue(args, "s", true)); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.shortObject(args, "s", true)); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.byteValue(args, "b", true)); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.byteObject(args, "b", true)); + assertEquals(-128, com.codename1.backend.mcp.McpArgs.byteValue(args, "ok", true)); + assertEquals(-128, com.codename1.backend.mcp.McpArgs.shortValue(args, "ok", true)); + // A JSON number too big for a long must not saturate to Long.MAX_VALUE. + args.put("huge", new Double(1e20)); + args.put("big", new Double(9.007199254740992E15)); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.longValue(args, "huge", true)); + assertEquals(9007199254740992L, + com.codename1.backend.mcp.McpArgs.longValue(args, "big", true)); + } + + // ---------------------------------------------------------------- metrics + + @Test + @DisplayName("instruments are shared by name, and render as Prometheus text") + void metrics() { + Counter c = Metrics.counter("test.orders", "Orders", "{order}"); + assertTrue(c == Metrics.counter("test.orders", "", "")); + c.add(3); + Histogram h = Metrics.histogram("test.latency", "Latency", "ms"); + h.record(7); + h.record(700); + String text = Metrics.prometheus(); + assertTrue(text.contains("test_orders_total 3"), text); + assertTrue(text.contains("test_latency_bucket{le=\"10\"} 1"), text); + assertTrue(text.contains("test_latency_count 2"), text); + assertThrows(IllegalArgumentException.class, () -> c.add(-1)); + assertThrows(IllegalArgumentException.class, + () -> Metrics.histogram("test.orders", "", "")); + } + + // -------------------------------------------------------------------- MCP + + @Test + @DisplayName("the MCP endpoint lists and calls a tool, and reports a bad call as a tool error") + void mcp() throws Exception { + McpTool echo = new McpTool() { + public String name() { + return "echo"; + } + + public String description() { + return "Echoes"; + } + + public Map inputSchema() { + Map m = new LinkedHashMap(); + m.put("type", "object"); + return m; + } + + public Object call(Map arguments) { + if(!arguments.containsKey("text")) { + throw new IllegalArgumentException("text is required"); + } + return arguments.get("text"); + } + }; + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet() + .mcp(null).mcpTool(echo).handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }).start(); + try { + String init = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":" + + "\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\"}}", null); + assertTrue(init.contains("\"protocolVersion\":\"2025-03-26\""), init); + String call = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":" + + "\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"text\":" + + "\"hi\"}}}", null); + assertTrue(call.contains("\"text\":\"hi\"") && call.contains("\"isError\":false"), + call); + String bad = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":" + + "\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{}}}", null); + assertTrue(bad.contains("\"isError\":true") && bad.contains("text is required"), bad); + String answer = rawMcp(port, "127.0.0.1:" + port, "http://evil.example"); + assertTrue(answer.startsWith("HTTP/1.1 403"), "a foreign Origin was served: " + + answer); + // DNS rebinding: the hostile page's name now resolves to 127.0.0.1, + // so its Origin and the Host header agree -- and must still be refused. + answer = rawMcp(port, "evil.example:" + port, "http://evil.example:" + port); + assertTrue(answer.startsWith("HTTP/1.1 403"), "a rebound Origin was served: " + + answer); + answer = rawMcp(port, "127.0.0.1:" + port, "http://localhost:" + port); + assertTrue(answer.startsWith("HTTP/1.1 200"), "a loopback Origin was refused: " + + answer); + } finally { + backend.stop(); + } + // A server started again in the same process has only its own tools: the + // stopped server's echo is gone, so with none there is no endpoint. + Backend again = Backend.builder(Config.of(settings, "dev")).quiet() + .mcp(null).handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }).start(); + try { + HttpURLConnection c = open(port, "/mcp"); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json"); + OutputStream out = c.getOutputStream(); + out.write("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}" + .getBytes("UTF-8")); + out.close(); + assertEquals(404, c.getResponseCode(), + "a restarted server served the previous server's MCP tools"); + } finally { + again.stop(); + } + } + + @Test + @DisplayName("outside development the MCP endpoint refuses to start without a token") + void mcpNeedsATokenInProduction() { + Properties settings = new Properties(); + settings.setProperty(McpServer.ENABLED, "true"); + IOException e = assertThrows(IOException.class, + () -> McpServer.fromConfig(Config.of(settings, "prod"), null, null, null)); + assertTrue(e.getMessage().contains(McpServer.TOKEN), e.getMessage()); + } + + @Test + @DisplayName("of two requests rotating one database session, the loser sends no cookie") + void aLostRotationIsNotAnnounced(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + DataSource pool = DataSource.open(new java.io.File(dir, "r.db").getAbsolutePath(), + 2, 5000, 10000); + try { + Sessions sessions = new Sessions(); + Sessions.Db store = new Sessions.Db(pool); + sessions.setStore(store); + long now = System.currentTimeMillis(); + HttpSession created = new HttpSession("twice", now, now, 1800); + created.markNew(); + store.save(created, null); + HttpSession first = store.load("twice"); + HttpSession second = store.load("twice"); + first.owner = sessions; + second.owner = sessions; + String won = first.changeSessionId(); + String lost = second.changeSessionId(); + HttpServer.Response a = sessions.finish(first, HttpServer.Response.text(200, "a")); + HttpServer.Response b = sessions.finish(second, HttpServer.Response.text(200, "b")); + assertTrue(String.valueOf(a.extraHeaders).contains(won), + "the rotation that moved the row did not announce its id"); + assertTrue(b.extraHeaders == null || !String.valueOf(b.extraHeaders).contains(lost), + "the losing rotation sent an id nothing stores: " + b.extraHeaders); + assertNotNull(store.load(won)); + assertNull(store.load(lost)); + } finally { + pool.close(); + } + } + + @Test + @DisplayName("an expired run releases only its own lease, not a later run's") + void anExpiredRunDoesNotReleaseItsSuccessor(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + DataSource pool = DataSource.open(new java.io.File(dir, "l.db").getAbsolutePath(), + 2, 5000, 10000); + try { + Scheduler scheduler = new Scheduler(pool); + Runnable nothing = new Runnable() { + public void run() { + } + }; + Scheduler.Job slow = new Scheduler.Job("slow", Scheduler.FIXED_DELAY, null, 1000, 0, + null, Tasks.PLATFORM, "shared", 1, nothing); + Scheduler.Job next = new Scheduler.Job("next", Scheduler.FIXED_DELAY, null, 1000, 0, + null, Tasks.PLATFORM, "shared", 60000, nothing); + String expired = scheduler.claim(slow); + assertNotNull(expired); + Thread.sleep(20); // the 1ms lease runs out + String current = scheduler.claim(next); + assertNotNull(current, "an expired lease was not taken over"); + scheduler.release(slow, expired); // the overrun finally ends + assertNull(scheduler.claim(slow), + "the overrun released the lease a later run now holds"); + scheduler.release(next, current); + assertNotNull(scheduler.claim(slow), "releasing its own lease freed nothing"); + } finally { + pool.close(); + } + } + + @Test + @DisplayName("a colon is legal in a Prometheus metric name but not in a label name") + void prometheusLabelNamesHaveNoColon() { + Histogram h = Metrics.histogram("test.lbl.render", "", "ms", null, + new String[] {"tenant:id", null, null}); + h.record(1, "acme", null, null); + String text = Metrics.prometheus(); + assertTrue(text.contains("tenant_id=\"acme\""), text); + assertFalse(text.contains("tenant:id="), "a colon reached a label name: " + text); + assertThrows(IllegalArgumentException.class, () -> Metrics.histogram("test.lbl.colon", + "", "ms", null, new String[] {"tenant:id", "tenant_id", null})); + } + + @Test + @DisplayName("a task that shuts its own executor down is not waited for") + void aTaskStoppingItsExecutorIsNotAwaited() throws Exception { + Tasks.Registry registry = Tasks.open(null); + final TaskExecutor one = new TaskExecutor("self", false, 1, registry); + final long[] took = {-1}; + final boolean[] interrupted = {false}; + final CountDownLatch done = new CountDownLatch(1); + one.execute(new Runnable() { + public void run() { + long start = System.currentTimeMillis(); + one.shutdown(5000); + took[0] = System.currentTimeMillis() - start; + interrupted[0] = Thread.currentThread().isInterrupted(); + done.countDown(); + } + }); + assertTrue(done.await(10, TimeUnit.SECONDS)); + assertTrue(took[0] < 2000, "the stopping task waited " + took[0] + "ms for itself"); + assertFalse(interrupted[0], "the stopping task was interrupted by its own shutdown"); + Tasks.shutdown(registry, 0); + } + + @Test + @DisplayName("a double that rounds to -2^63 is refused as a long, the exact Long is not") + void theNegativeLongBoundaryIsChecked() { + Map args = new LinkedHashMap(); + args.put("rounded", Double.valueOf(-9223372036854775809.0)); + args.put("exact", Long.valueOf(Long.MIN_VALUE)); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.longValue(args, "rounded", true)); + assertEquals(Long.MIN_VALUE, + com.codename1.backend.mcp.McpArgs.longValue(args, "exact", true)); + } + + @Test + @DisplayName("a bearer token no request could present is refused at start, unechoed") + void unsendableBearerTokensAreRefused() { + String[] bad = {"s3cret\n", " s3cret", "s3\u0001cret"}; + for(String token : bad) { + Properties mcp = new Properties(); + mcp.setProperty(McpServer.ENABLED, "true"); + mcp.setProperty(McpServer.TOKEN, token); + IOException e = assertThrows(IOException.class, + () -> McpServer.fromConfig(Config.of(mcp, "prod"), null, null, null)); + assertTrue(e.getMessage().contains(McpServer.TOKEN), e.getMessage()); + assertFalse(e.getMessage().contains("s3"), "the secret was echoed"); + Properties manage = new Properties(); + manage.setProperty(Management.ENABLED, "true"); + manage.setProperty(Management.TOKEN, token); + e = assertThrows(IOException.class, + () -> Management.fromConfig(Config.of(manage, "prod"))); + assertTrue(e.getMessage().contains(Management.TOKEN), e.getMessage()); + } + } + + @Test + @DisplayName("a session a running request holds is not expired by the next lookup") + void aLookupSparesASessionInUse() throws Exception { + Sessions sessions = new Sessions(); + long now = System.currentTimeMillis(); + // Stored an hour ago with a minute's timeout: expired by the stored time, + // but a request that began then is still running and holding it. + HttpSession held = new HttpSession("long", now - 3600000, now - 3600000, 60); + held.owner = sessions; + held.markNew(); + sessions.getStore().save(held, null); + HttpServer.Request running = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + sessions.enter(running, held); + assertNotNull(sessions.find("long", false), + "a second request expired the session another request is using"); + assertNotNull(sessions.getStore().load("long")); + sessions.leave(running); + // One nobody is using still expires on lookup. + HttpSession idle = new HttpSession("idle", now - 3600000, now - 3600000, 60); + idle.owner = sessions; + idle.markNew(); + sessions.getStore().save(idle, null); + assertNull(sessions.find("idle", false), "an idle expired session was found"); + } + + @Test + @DisplayName("a websocket callback reports to its own server's tracer") + void websocketCallbacksCarryTheirTracer() throws Exception { + final Tracer[] seen = new Tracer[1]; + WebSocket endpoint = new WebSocket() { + public void onOpen(WebSocketSession session) { + } + + public void onText(WebSocketSession session, String message) { + seen[0] = Tracing.active(); + } + + public void onBinary(WebSocketSession session, byte[] m, int o, int l) { + } + }; + Tracer mine = new QuietTracer(); + Tracer later = new QuietTracer(); + Tasks.Registry tasks = Tasks.open(null); + Tracing.install(later); // a second server, started after + try { + new Backend.TaskBound(endpoint, tasks, mine).onText(null, "hi"); + assertTrue(seen[0] == mine, "the callback reported to another server's tracer"); + assertTrue(Tracing.active() == later, "the binding outlived the callback"); + } finally { + Tracing.install(null); + Tasks.shutdown(tasks, 0); + } + } + + @Test + @DisplayName("NaN and Infinity written as strings are refused as number arguments") + void nonFiniteNumberStringsAreRefused() { + Map args = new LinkedHashMap(); + args.put("nan", "NaN"); + args.put("inf", "-Infinity"); + args.put("ok", "2.5"); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.doubleValue(args, "nan", true)); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.doubleValue(args, "inf", true)); + assertEquals(2.5, com.codename1.backend.mcp.McpArgs.doubleValue(args, "ok", true), 0.0); + } + + @Test + @DisplayName("two requests rotating one memory session: the later rotation is undone, not announced") + void aSecondRotationOfASharedSessionIsUndone() throws Exception { + Sessions sessions = new Sessions(); + long now = System.currentTimeMillis(); + HttpSession shared = new HttpSession("start", now, now, 1800); + shared.owner = sessions; + shared.markNew(); + sessions.getStore().save(shared, null); + shared.clean(); + HttpServer.Request a = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + HttpServer.Request b = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + sessions.enter(a, shared); // both found it under "start" + sessions.enter(b, shared); + String first = shared.changeSessionId(); + HttpServer.Response ra = sessions.finish(a, shared, HttpServer.Response.text(200, "a")); + String second = shared.changeSessionId(); // the memory store shares the object + HttpServer.Response rb = sessions.finish(b, shared, HttpServer.Response.text(200, "b")); + assertTrue(String.valueOf(ra.extraHeaders).contains(first)); + assertTrue(rb.extraHeaders == null || !String.valueOf(rb.extraHeaders).contains(second), + "the second rotation announced an id: " + rb.extraHeaders); + assertTrue(sessions.getStore().load(first) == shared, + "the id the first response carries was removed"); + assertNull(sessions.getStore().load(second)); + assertEquals(first, shared.getId()); + // One request rotating twice is still its own rotation. + HttpServer.Request c = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + sessions.enter(c, shared); + shared.changeSessionId(); + String last = shared.changeSessionId(); + HttpServer.Response rc = sessions.finish(c, shared, HttpServer.Response.text(200, "c")); + assertTrue(String.valueOf(rc.extraHeaders).contains(last)); + assertNull(sessions.getStore().load(first), "a request's own rotation left the old id"); + } + + @Test + @DisplayName("one session the store cannot end does not stop the request's others ending") + void everyEndedSessionIsFinishedWhenOneFails() throws Exception { + final List deleted = new ArrayList(); + final String[] ids = new String[2]; + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication()) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + for(int i = 0 ; i < 2 ; i++) { + HttpSession s = request.getSession(true); + ids[i] = s.getId(); + s.setAttribute("n", "x"); + s.invalidate(); + } + return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); + } + }).start(); + final SessionStore memory = backend.getSessions().getStore(); + backend.getSessions().setStore(new SessionStore() { + public HttpSession load(String id) throws IOException { + return memory.load(id); + } + + public void save(HttpSession session, String previousId) throws IOException { + memory.save(session, previousId); + } + + public void delete(String id) throws IOException { + if(id.equals(ids[0])) { + throw new IOException("the store refused"); + } + deleted.add(id); + memory.delete(id); + } + + public int purgeExpired(long now) throws IOException { + return memory.purgeExpired(now); + } + + public int size() { + return memory.size(); + } + }); + try { + HttpURLConnection c = open(port, "/"); + int status = c.getResponseCode(); + assertEquals(500, status, "the store's failure should fail the request"); + assertTrue(ids[0] != null && ids[1] != null && !ids[0].equals(ids[1]), + ids[0] + " / " + ids[1]); + assertTrue(deleted.contains(ids[1]), + "the second ended session was skipped after the first failed: " + deleted + + " ids " + ids[0] + " / " + ids[1]); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a request's end forgets the ids of the sessions it found, so a pooled one holds none") + void leavingForgetsFoundSessionIds() { + Sessions sessions = new Sessions(); + long now = System.currentTimeMillis(); + HttpSession s = new HttpSession("pooled", now, now, 1800); + HttpServer.Request request = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + sessions.enter(request, s); + assertNotNull(request.sessionIdsFound); + sessions.leave(request); + assertNull(request.sessionIdsFound, "a finished request still holds its sessions"); + } + + @Test + @DisplayName("an executor kind that is neither platform nor virtual stops the start") + void unknownExecutorKindsAreRefused() { + Properties settings = new Properties(); + settings.setProperty("cn1.task.executor.reports.kind", "platfrom"); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> Tasks.open(Config.of(settings, "test"))); + assertTrue(e.getMessage().contains("cn1.task.executor.reports.kind"), e.getMessage()); + assertEquals("platform", Tasks.checkedKind("k", " platform ")); + assertNull(Tasks.checkedKind("k", " ")); + } + + @Test + @DisplayName("a histogram registered again must have the same buckets and labels") + void histogramShapesMustAgree() { + Histogram h = Metrics.histogram("test.shape", "", "ms", new double[] {1, 2}, null); + assertTrue(h == Metrics.histogram("test.shape", "", "ms", new double[] {1, 2}, null)); + assertThrows(IllegalArgumentException.class, () -> Metrics.histogram("test.shape", "", + "ms", new double[] {1, 3}, null)); + assertThrows(IllegalArgumentException.class, () -> Metrics.histogram("test.shape", "", + "ms", new double[] {1, 2}, new String[] {"route", null, null})); + } + + @Test + @DisplayName("an empty JSON-RPC batch is answered with Invalid Request") + void anEmptyBatchIsInvalid() throws Exception { + Properties settings = new Properties(); + settings.setProperty(McpServer.ENABLED, "true"); + McpServer server = McpServer.fromConfig(Config.of(settings, "dev"), null, null, null); + HttpServer.Response r = server.handle(new HttpServer.Request("POST", "/mcp", + "HTTP/1.1", new LinkedHashMap(), "[]")); + assertEquals(200, r.getStatus()); + // respondJson defers the encoding to the write; render the value here. + String body = r.body != null && r.body.length > 0 ? new String(r.body, "UTF-8") + : Json.write(r.deferredJson); + assertTrue(body.contains("-32600") && body.contains("\"id\":null"), body); + } + + @Test + @DisplayName("a tokenless MCP endpoint binds loopback, and refuses an explicit public address") + void tokenlessMcpStaysOnLoopback() throws Exception { + Properties settings = new Properties(); + settings.setProperty(McpServer.ENABLED, "true"); + int port = freePort(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + IOException refused = assertThrows(IOException.class, () -> Backend.builder( + Config.of(settings, "dev")).quiet().application(new EmptyApplication()) + .mcp(null).host("0.0.0.0").start()); + assertTrue(refused.getMessage().contains(McpServer.TOKEN), refused.getMessage()); + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet() + .application(new EmptyApplication()).mcp(null).start(); + try { + java.net.InetAddress external = null; + java.util.Enumeration nics = java.net.NetworkInterface.getNetworkInterfaces(); + while(external == null && nics != null && nics.hasMoreElements()) { + java.net.NetworkInterface nic = (java.net.NetworkInterface) nics.nextElement(); + java.util.Enumeration addresses = nic.getInetAddresses(); + while(nic.isUp() && addresses.hasMoreElements()) { + java.net.InetAddress a = (java.net.InetAddress) addresses.nextElement(); + if(a instanceof java.net.Inet4Address && !a.isLoopbackAddress()) { + external = a; + break; + } + } + } + if(external != null) { + java.net.Socket probe = new java.net.Socket(); + try { + probe.connect(new java.net.InetSocketAddress(external, port), 2000); + org.junit.jupiter.api.Assertions.fail("the tokenless MCP server answered on " + + external.getHostAddress()); + } catch (IOException expected) { + // refused: loopback only + } finally { + probe.close(); + } + } + java.net.Socket local = new java.net.Socket("127.0.0.1", port); + local.close(); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a request that is not JSON-RPC 2.0 is refused before its method runs") + void onlyJsonRpc2IsDispatched() throws Exception { + Properties settings = new Properties(); + settings.setProperty(McpServer.ENABLED, "true"); + McpServer server = McpServer.fromConfig(Config.of(settings, "dev"), null, null, null); + HttpServer.Response r = server.handle(new HttpServer.Request("POST", "/mcp", + "HTTP/1.1", new LinkedHashMap(), "{\"jsonrpc\":\"1.0\",\"id\":4," + + "\"method\":\"ping\"}")); + String body = r.body != null && r.body.length > 0 ? new String(r.body, "UTF-8") + : Json.write(r.deferredJson); + assertTrue(body.contains("-32600") && !body.contains("\"result\""), body); + r = server.handle(new HttpServer.Request("POST", "/mcp", "HTTP/1.1", + new LinkedHashMap(), "{\"id\":5,\"method\":\"ping\"}")); + body = r.body != null && r.body.length > 0 ? new String(r.body, "UTF-8") + : Json.write(r.deferredJson); + assertTrue(body.contains("-32600"), body); + } + + @Test + @DisplayName("a time the clocks pass twice fires once, at the first, from either side of the change") + void cronInAFallBackOverlap() { + CronSchedule c = CronSchedule.parse("0 30 1 * * *", "America/New_York"); + long hour = 3600000L; + long minute = 60000L; + // 2026-11-01: 01:30 EDT is 05:30Z, the clocks go back at 06:00Z, and 01:30 + // EST is 06:30Z. The next day's 01:30 EST is 30.5 hours after midnight UTC. + long midnightUtc = 1793491200000L; // 2026-11-01T00:00:00Z + long tomorrow = midnightUtc + 30 * hour + 30 * minute; + assertEquals(midnightUtc + 5 * hour + 30 * minute, c.next(midnightUtc + 4 * hour)); + // Once the first has passed, that day's is spent -- before the change + // (just after a run at the first) and after it alike. + assertEquals(tomorrow, c.next(midnightUtc + 5 * hour + 30 * minute)); + assertEquals(tomorrow, c.next(midnightUtc + 5 * hour + 45 * minute)); + assertEquals(tomorrow, c.next(midnightUtc + 6 * hour)); + // A job that fires through the night still gets the times after the fold. + CronSchedule half = CronSchedule.parse("0 */30 * * * *", "America/New_York"); + assertEquals(midnightUtc + 7 * hour, half.next(midnightUtc + 6 * hour + 10 * minute), + "the 02:00 EST after the repeated hour was skipped"); + } + + @Test + @DisplayName("a session rotated by a running request is spared under its old id too") + void aRotatingRequestKeepsItsOldIdBusy(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + DataSource pool = DataSource.open(new java.io.File(dir, "busy.db").getAbsolutePath(), + 2, 5000, 10000); + try { + Sessions sessions = new Sessions(); + sessions.setStore(new Sessions.Db(pool)); + long now = System.currentTimeMillis(); + HttpSession stored = new HttpSession("old", now - 3600000, now - 3600000, 60); + stored.markNew(); + sessions.getStore().save(stored, null); + HttpSession copy = sessions.getStore().load("old"); + copy.owner = sessions; + HttpServer.Request running = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + sessions.enter(running, copy); + copy.changeSessionId(); // the row is still under "old" + assertNotNull(sessions.find("old", false), + "another request expired the row a rotating request still owns"); + assertNotNull(sessions.getStore().load("old")); + sessions.leave(running); + } finally { + pool.close(); + } + } + + @Test + @DisplayName("an id-less message with no method is invalid, and answered; a response is ignored") + void malformedIdlessMessagesAreAnswered() throws Exception { + Properties settings = new Properties(); + settings.setProperty(McpServer.ENABLED, "true"); + McpServer server = McpServer.fromConfig(Config.of(settings, "dev"), null, null, null); + HttpServer.Response r = server.handle(new HttpServer.Request("POST", "/mcp", + "HTTP/1.1", new LinkedHashMap(), "{\"jsonrpc\":\"2.0\"}")); + assertEquals(200, r.getStatus()); + String body = r.body != null && r.body.length > 0 ? new String(r.body, "UTF-8") + : Json.write(r.deferredJson); + assertTrue(body.contains("-32600") && body.contains("\"id\":null"), body); + r = server.handle(new HttpServer.Request("POST", "/mcp", "HTTP/1.1", + new LinkedHashMap(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}")); + assertEquals(202, r.getStatus(), "a response the client sent was answered"); + } + + @Test + @DisplayName("a float argument too small for a float is refused, not made zero") + void floatUnderflowIsRefused() { + Map args = new LinkedHashMap(); + args.put("tiny", Double.valueOf(1e-100)); + args.put("zero", Double.valueOf(0)); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.mcp.McpArgs.floatValue(args, "tiny", true)); + assertEquals(0f, com.codename1.backend.mcp.McpArgs.floatValue(args, "zero", true)); + } + + @Test + @DisplayName("a handler that loses a race to stop the server does not hold up the drain") + void aSecondStoppingHandlerReturns() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + settings.setProperty(Config.SERVER_SHUTDOWN_MILLIS, "20000"); + final Backend[] self = new Backend[1]; + final CountDownLatch both = new CountDownLatch(2); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication()) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + both.countDown(); + both.await(5, TimeUnit.SECONDS); + self[0].stop(); + return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); + } + }).start(); + self[0] = backend; + final List errors = new ArrayList(); + Thread[] callers = new Thread[2]; + for(int i = 0 ; i < 2 ; i++) { + callers[i] = new Thread(new Runnable() { + public void run() { + try { + HttpURLConnection c = open(port, "/"); + c.getResponseCode(); + } catch (IOException err) { + // the server is stopping under it; fine + } + } + }); + callers[i].start(); + } + long started = System.currentTimeMillis(); + for(Thread t : callers) { + t.join(30000); + } + backend.stop(); + long took = System.currentTimeMillis() - started; + assertTrue(took < 15000, "the drain waited " + took + "ms for the losing stop caller"); + } + + @Test + @DisplayName("a scheduled run that ends during the drain still records its metrics") + void aDrainedRunIsMeasured() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + final CountDownLatch running = new CountDownLatch(1); + final Scheduler[] scheduler = new Scheduler[1]; + // With management, as a development entry point builds it: that is what + // has the server record metrics when no exporter is configured. + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().management() + .application(new EmptyApplication() { + public void started(Backend b) { + scheduler[0] = new Scheduler(null); + scheduler[0].fixedDelay("drainedJob", 0, 1000000, null, Tasks.PLATFORM, + null, -1, new Runnable() { + public void run() { + running.countDown(); + try { + Thread.sleep(400); + } catch (InterruptedException err) { + // the drain may interrupt at its deadline + } + } + }); + scheduler[0].bind(b); + scheduler[0].start(); + } + + public void stopping() { + scheduler[0].stop(0); // stop launching; the run drains + } + }).start(); + assertTrue(running.await(5, TimeUnit.SECONDS)); + backend.stop(); + assertTrue(Json.write(Metrics.snapshot()).contains("drainedJob"), + "the run that finished in the drain was not recorded"); + } + + @Test + @DisplayName("loopback is 127/8 as a numeric literal, ::1 or localhost -- not a name that starts 127.") + void loopbackIsCheckedStrictly() { + assertTrue(Backend.isLoopback("127.0.0.1")); + assertTrue(Backend.isLoopback("127.1.2.3")); + assertTrue(Backend.isLoopback("::1")); + assertTrue(Backend.isLoopback("localhost")); + assertFalse(Backend.isLoopback("127.backend.example")); + assertFalse(Backend.isLoopback("127.0.0.1.evil.example")); + assertFalse(Backend.isLoopback("127.0.0.256")); + assertFalse(Backend.isLoopback("127.0.0")); + assertFalse(Backend.isLoopback("10.0.0.1")); + assertEquals("127.0.0.1", Backend.advertised(null)); + assertEquals("127.0.0.1", Backend.advertised("0.0.0.0")); + assertEquals("[::1]", Backend.advertised("::1")); + assertEquals("192.0.2.10", Backend.advertised("192.0.2.10")); + } + + @Test + @DisplayName("a JSON-RPC id that is an object, array or boolean is refused before the method runs") + void unsupportedIdTypesAreRefused() throws Exception { + final int[] calls = {0}; + com.codename1.backend.mcp.McpTool tool = new com.codename1.backend.mcp.McpTool() { + public String name() { + return "touch"; + } + + public String description() { + return "counts calls"; + } + + public Map inputSchema() { + Map schema = new LinkedHashMap(); + schema.put("type", "object"); + return schema; + } + + public Object call(Map arguments) { + calls[0]++; + return "ok"; + } + }; + Properties settings = new Properties(); + settings.setProperty(McpServer.ENABLED, "true"); + McpServer server = McpServer.fromConfig(Config.of(settings, "dev"), null, null, + java.util.Collections.singletonList(tool)); + HttpServer.Response r = server.handle(new HttpServer.Request("POST", "/mcp", + "HTTP/1.1", new LinkedHashMap(), "{\"jsonrpc\":\"2.0\",\"id\":{\"x\":1}," + + "\"method\":\"tools/call\",\"params\":{\"name\":\"touch\"," + + "\"arguments\":{}}}")); + String body = r.body != null && r.body.length > 0 ? new String(r.body, "UTF-8") + : Json.write(r.deferredJson); + assertTrue(body.contains("-32600") && body.contains("\"id\":null"), body); + assertEquals(0, calls[0], "a request with an object id ran its tool"); + } + + @Test + @DisplayName("backend_call reaches a listener bound to one address, IPv6 loopback included") + void backendCallUsesTheBoundAddress() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend; + try { + backend = Backend.builder(Config.of(settings, "dev")).quiet() + .application(new EmptyApplication()) + .mcp(new com.codename1.backend.mcp.DevTools()) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + return request.respond(200, "text/plain", "reached".getBytes("UTF-8")); + } + }).host("::1").start(); + } catch (IOException noIpv6) { + org.junit.jupiter.api.Assumptions.assumeTrue(false, "no IPv6 loopback here"); + return; + } + try { + assertEquals("[::1]", backend.getListenAddress()); + HttpURLConnection c = (HttpURLConnection) new URL("http://[::1]:" + port + "/mcp") + .openConnection(); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json"); + c.setRequestProperty("Accept", "application/json, text/event-stream"); + c.getOutputStream().write(("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":" + + "\"tools/call\",\"params\":{\"name\":\"backend_call\",\"arguments\":" + + "{\"method\":\"GET\",\"path\":\"/x\"}}}").getBytes("UTF-8")); + String answer = read(c); + assertTrue(answer.contains("reached") && answer.contains("\"isError\":false"), + answer); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("request beans a @PreDestroy builds, however deep, are destroyed too") + void requestBeansBuiltDuringDestroyAreDrained() throws Exception { + final List ended = java.util.Collections.synchronizedList(new ArrayList()); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication() { + public HttpServer.Handler[] create(Backend.Environment environment) { + return new HttpServer.Handler[] {new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + request.scopedBeans(1)[0] = "a"; + return HttpServer.Response.text(200, "ok"); + } + }}; + } + + public boolean tracksCurrentRequest() { + return true; + } + + public void requestEnded(Object[] beans) { + String name = (String) beans[0]; + ended.add(name); + // Its @PreDestroy uses a request bean nobody built yet. + String next = "a".equals(name) ? "b" : "b".equals(name) ? "c" + : "c".equals(name) ? "d" : null; + if (next != null) { + Backend.currentRequest().scopedBeans(1)[0] = next; + } + } + }).start(); + try { + assertEquals("ok", read(open(port, "/x"))); + long deadline = System.currentTimeMillis() + 5000; + while(ended.size() < 4 && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + assertEquals("[a, b, c, d]", String.valueOf(ended), + "a bean built while destroying another was never destroyed"); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("request beans are destroyed even when the JSON body fails to serialise") + void requestBeansEndWhenSerialisationFails() throws Exception { + final List ended = java.util.Collections.synchronizedList(new ArrayList()); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication() { + public HttpServer.Handler[] create(Backend.Environment environment) { + return new HttpServer.Handler[] {new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + request.scopedBeans(1)[0] = "bean"; + return request.respondJson(200, new Json.Writable() { + public void writeTo(ByteSink out) { + throw new IllegalStateException("cannot write"); + } + }); + } + }}; + } + + public void requestEnded(Object[] beans) { + ended.add(beans[0]); + } + }).start(); + try { + assertEquals(500, open(port, "/x").getResponseCode()); + assertEquals("[bean]", String.valueOf(ended), + "a failed serialisation left the request's beans undestroyed"); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a JSON body a request bean owns is written before the bean is destroyed") + void deferredJsonIsWrittenBeforeRequestBeansEnd() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication() { + public HttpServer.Handler[] create(Backend.Environment environment) { + return new HttpServer.Handler[] {new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + List owned = new ArrayList(); + owned.add("kept"); + request.scopedBeans(1)[0] = owned; + return request.respondJson(200, owned); + } + }}; + } + + public void requestEnded(Object[] beans) { + ((List) beans[0]).clear(); // a @PreDestroy that clears + } + }).start(); + try { + assertEquals("[\"kept\"]", read(open(port, "/x"))); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a request finishing during a memory-store login does not undo its rotation") + void concurrentRequestKeepsTheLoginsRotation() throws Exception { + final CountDownLatch rotated = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication() { + public HttpServer.Handler[] create(Backend.Environment environment) { + return new HttpServer.Handler[] {new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + String t = request.getTarget(); + if (t.startsWith("/in")) { + request.getSession(true).setAttribute("visits", "1"); + return HttpServer.Response.text(200, "in"); + } + if (t.startsWith("/login")) { + HttpSession s = request.getSession(false); + s.changeSessionId(); // fixation defence + s.setAttribute("user", "ada"); + rotated.countDown(); + release.await(10, TimeUnit.SECONDS); + return HttpServer.Response.text(200, "login"); + } + HttpSession s = request.getSession(false); + return HttpServer.Response.text(200, s == null ? "none" + : String.valueOf(s.getAttribute("user"))); + } + }}; + } + }).start(); + try { + HttpURLConnection in = open(port, "/in"); + assertEquals("in", read(in)); + String cookie = in.getHeaderField("Set-Cookie"); + final String old = cookie.substring(0, cookie.indexOf(';')); + final int p = port; + final String[] loginCookie = new String[1]; + Thread login = new Thread(new Runnable() { + public void run() { + try { + HttpURLConnection c = open(p, "/login"); + c.setRequestProperty("Cookie", old); + read(c); + loginCookie[0] = c.getHeaderField("Set-Cookie"); + } catch (IOException err) { + loginCookie[0] = String.valueOf(err); + } + } + }); + login.start(); + assertTrue(rotated.await(10, TimeUnit.SECONDS)); + HttpURLConnection meanwhile = open(port, "/peek"); // the old cookie, mid-login + meanwhile.setRequestProperty("Cookie", old); + assertFalse("ada".equals(read(meanwhile)), + "the old id reached the session the login was authenticating"); + release.countDown(); + login.join(10000); + assertNotNull(loginCookie[0], "the login's rotation was undone: no new cookie"); + String rotatedPair = loginCookie[0].substring(0, loginCookie[0].indexOf(';')); + assertFalse(rotatedPair.equals(old), "the login announced the old id"); + HttpURLConnection attacker = open(port, "/me"); + attacker.setRequestProperty("Cookie", old); + assertFalse("ada".equals(read(attacker)), + "the old, pre-login id carries the signed-in session"); + HttpURLConnection user = open(port, "/me"); + user.setRequestProperty("Cookie", rotatedPair); + assertEquals("ada", read(user)); + } finally { + release.countDown(); + backend.stop(); + } + } + + @Test + @DisplayName("a rotation whose save fails leaves the session and its beans under the old id") + void failedRotationSaveIsUndone() throws Exception { + final SessionStore inner = new Sessions().getStore(); + final boolean[] failRotation = new boolean[1]; + SessionStore store = new SessionStore() { + public HttpSession load(String id) throws IOException { + return inner.load(id); + } + + public void save(HttpSession session, String previousId) throws IOException { + if (previousId != null && failRotation[0]) { + throw new IOException("the store is down"); + } + inner.save(session, previousId); + } + + public void delete(String id) throws IOException { + inner.delete(id); + } + + public int purgeExpired(long now) throws IOException { + return inner.purgeExpired(now); + } + + public int size() { + return inner.size(); + } + }; + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .sessionStore(store) + .application(new EmptyApplication() { + public HttpServer.Handler[] create(Backend.Environment environment) { + return new HttpServer.Handler[] {new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + String t = request.getTarget(); + if (t.startsWith("/in")) { + request.getSession(true).scopedBeans(1)[0] = "cart"; + return HttpServer.Response.text(200, "in"); + } + HttpSession s = request.getSession(false); + if (t.startsWith("/rotate")) { + s.changeSessionId(); + s.scopedBeans(1); // after the rotation: moves them + return HttpServer.Response.text(200, "rotated"); + } + return HttpServer.Response.text(200, s == null ? "none" + : s.getId() + " " + s.scopedBeans(1)[0]); + } + }}; + } + }).start(); + try { + HttpURLConnection in = open(port, "/in"); + assertEquals("in", read(in)); + String cookie = in.getHeaderField("Set-Cookie"); + String pair = cookie.substring(0, cookie.indexOf(';')); + String id = pair.substring(pair.indexOf('=') + 1); + failRotation[0] = true; + HttpURLConnection rotate = open(port, "/rotate"); + rotate.setRequestProperty("Cookie", pair); + assertEquals(500, rotate.getResponseCode()); + assertNull(rotate.getHeaderField("Set-Cookie")); + failRotation[0] = false; + HttpURLConnection me = open(port, "/me"); + me.setRequestProperty("Cookie", pair); + assertEquals(id + " cart", read(me), + "the failed rotation left the session or its beans under an id nobody has"); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("an invalidated session's beans outlive the invalidating request while another uses them") + void invalidatedBeansWaitForOtherUsers(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + DataSource pool = DataSource.open(new java.io.File(dir, "inv.db").getAbsolutePath(), + 2, 5000, 10000); + final List ended = new ArrayList(); + try { + Sessions sessions = new Sessions(new EmptyApplication() { + public void sessionEnded(Object[] beans) { + ended.add(beans); + } + }); + sessions.setStore(new Sessions.Db(pool)); + long now = System.currentTimeMillis(); + HttpSession stored = new HttpSession("shared", now, now, 1800); + stored.markNew(); + sessions.getStore().save(stored, null); + HttpSession a = sessions.getStore().load("shared"); + HttpSession b = sessions.getStore().load("shared"); + a.owner = sessions; + b.owner = sessions; + HttpServer.Request ra = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + HttpServer.Request rb = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + sessions.enter(ra, a); + sessions.enter(rb, b); + Object[] beans = b.scopedBeans(1); + beans[0] = "cart"; + a.invalidate(); // a logout in request A + sessions.finish(ra, a, HttpServer.Response.text(200, "a")); + sessions.leave(ra); + assertTrue(ended.isEmpty(), "request B's beans were destroyed under it"); + assertTrue(b.scopedBeans(1) == beans, + "request B was handed fresh beans under the deleted id"); + sessions.leave(rb); + assertEquals(1, ended.size(), "the retired beans were never destroyed"); + } finally { + pool.close(); + } + } + + @Test + @DisplayName("removing a shared gauge source waits for a read of it already running") + void removingASourceWaitsForItsRead() throws Exception { + final CountDownLatch reading = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + com.codename1.backend.metrics.Gauge.Source slow = + new com.codename1.backend.metrics.Gauge.Source() { + public double read() { + reading.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException err) { + // test + } + return 1; + } + }; + Metrics.addSource("test.drain.gauge", "", "", slow); + Thread reader = new Thread(new Runnable() { + public void run() { + Metrics.snapshot(); + } + }); + reader.start(); + assertTrue(reading.await(5, TimeUnit.SECONDS)); + final boolean[] removed = {false}; + Thread remover = new Thread(new Runnable() { + public void run() { + Metrics.removeSource("test.drain.gauge", slow); + removed[0] = true; + } + }); + remover.start(); + Thread.sleep(300); + assertFalse(removed[0], "the source was removed while its read was still running"); + release.countDown(); + remover.join(5000); + assertTrue(removed[0]); + reader.join(5000); + } + + @Test + @DisplayName("a gauge stuck in its callback delays only its own removal") + void aStuckGaugeDelaysOnlyItself() throws Exception { + final CountDownLatch reading = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + com.codename1.backend.metrics.Gauge.Source stuck = + new com.codename1.backend.metrics.Gauge.Source() { + public double read() { + reading.countDown(); + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException err) { + // test + } + return 1; + } + }; + com.codename1.backend.metrics.Gauge.Source quick = + new com.codename1.backend.metrics.Gauge.Source() { + public double read() { + return 2; + } + }; + Metrics.addSource("test.stuck.gauge", "", "", stuck); + Metrics.addSource("test.quick.gauge", "", "", quick); + Thread reader = new Thread(new Runnable() { + public void run() { + Metrics.snapshot(); + } + }); + reader.start(); + try { + assertTrue(reading.await(5, TimeUnit.SECONDS)); + long start = System.currentTimeMillis(); + Metrics.removeSource("test.quick.gauge", quick); + assertTrue(System.currentTimeMillis() - start < 1000, + "another gauge's stuck read delayed this removal"); + } finally { + release.countDown(); + reader.join(5000); + Metrics.removeSource("test.stuck.gauge", stuck); + } + } + + @Test + @DisplayName("a gauge without a name is refused, like every other instrument") + void aNamelessGaugeIsRefused() { + assertThrows(IllegalArgumentException.class, () -> Metrics.gauge("", "", "", + new com.codename1.backend.metrics.Gauge.Source() { + public double read() { + return 1; + } + })); + } + + @Test + @DisplayName("one named executor asked for two kinds of thread is refused, AUTO agrees with either") + void anExecutorHasOneThreadKind() { + Tasks.Registry registry = Tasks.open(null); + try { + Tasks.executor(registry, "reports", Tasks.PLATFORM); + Tasks.executor(registry, "reports", Tasks.AUTO); + assertThrows(IllegalStateException.class, + () -> Tasks.executor(registry, "reports", Tasks.VIRTUAL)); + } finally { + Tasks.shutdown(registry, 0); + } + } + + @Test + @DisplayName("a rotation a failed request cannot announce is undone, and its other changes kept") + void anUnannouncedRotationKeepsTheChanges(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + DataSource pool = DataSource.open(new java.io.File(dir, "undo.db").getAbsolutePath(), + 2, 5000, 10000); + try { + Sessions sessions = new Sessions(); + sessions.setStore(new Sessions.Db(pool)); + long now = System.currentTimeMillis(); + HttpSession stored = new HttpSession("kept", now, now, 1800); + stored.markNew(); + sessions.getStore().save(stored, null); + HttpSession copy = sessions.getStore().load("kept"); + copy.owner = sessions; + copy.setAttribute("cart", "3 items"); + String next = copy.changeSessionId(); + assertNull(sessions.finish(copy, null)); // the handler threw + assertEquals("kept", copy.getId(), "the session kept an id the client never got"); + assertNull(sessions.getStore().load(next)); + assertEquals("3 items", sessions.getStore().load("kept").getAttribute("cart"), + "the failed request's other changes were lost with the rotation"); + } finally { + pool.close(); + } + } + + @Test + @DisplayName("params that are not an object are Invalid params, not an empty call") + void nonObjectParamsAreRefused() throws Exception { + Properties settings = new Properties(); + settings.setProperty(McpServer.ENABLED, "true"); + McpServer server = McpServer.fromConfig(Config.of(settings, "dev"), null, null, null); + HttpServer.Response r = server.handle(new HttpServer.Request("POST", "/mcp", + "HTTP/1.1", new LinkedHashMap(), "{\"jsonrpc\":\"2.0\",\"id\":3," + + "\"method\":\"initialize\",\"params\":1}")); + String body = r.body != null && r.body.length > 0 ? new String(r.body, "UTF-8") + : Json.write(r.deferredJson); + assertTrue(body.contains("-32602") && !body.contains("protocolVersion"), body); + } + + @Test + @DisplayName("tools/call with arguments that are not an object is refused, and the tool never runs") + void nonObjectArgumentsAreRefused() throws Exception { + final int[] calls = {0}; + com.codename1.backend.mcp.McpTool tool = new com.codename1.backend.mcp.McpTool() { + public String name() { + return "touch"; + } + + public String description() { + return "counts calls"; + } + + public Map inputSchema() { + Map schema = new LinkedHashMap(); + schema.put("type", "object"); + return schema; + } + + public Object call(Map arguments) { + calls[0]++; + return "ok"; + } + }; + Properties settings = new Properties(); + settings.setProperty(McpServer.ENABLED, "true"); + McpServer server = McpServer.fromConfig(Config.of(settings, "dev"), null, null, + java.util.Collections.singletonList(tool)); + HttpServer.Response r = server.handle(new HttpServer.Request("POST", "/mcp", + "HTTP/1.1", new LinkedHashMap(), "{\"jsonrpc\":\"2.0\",\"id\":6," + + "\"method\":\"tools/call\",\"params\":{\"name\":\"touch\"," + + "\"arguments\":[1]}}")); + String body = r.body != null && r.body.length > 0 ? new String(r.body, "UTF-8") + : Json.write(r.deferredJson); + assertTrue(body.contains("-32602"), body); + assertEquals(0, calls[0], "a call with array arguments ran its tool"); + } + + @Test + @DisplayName("a configured MCP or management path is compared in its canonical form") + void configuredPathsAreCanonical() throws Exception { + Properties settings = new Properties(); + settings.setProperty(McpServer.ENABLED, "true"); + settings.setProperty(McpServer.PATH, "/%6dcp"); + assertEquals("/mcp", McpServer.fromConfig(Config.of(settings, "dev"), null, null, + null).getPath()); + Properties manage = new Properties(); + manage.setProperty(Management.PATH, "/%6danage"); + assertEquals("/manage", Config.of(manage, "dev").getRoutePath(Management.PATH, + "/manage")); + } + + private static Backend startAnswering(int port, final String text, + com.codename1.backend.mcp.DevTools tools) throws Exception { + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + return Backend.builder(Config.of(settings, "dev")).quiet() + .application(new EmptyApplication()).mcp(tools) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + return request.respond(200, "text/plain", text.getBytes("UTF-8")); + } + }).start(); + } + + @Test + @DisplayName("a stop() a handler makes tears the beans down only after that handler returns") + void aHandlerStoppingTheServerFinishesFirst() throws Exception { + final boolean[] handlerDone = {false}; + final boolean[] destroyedEarly = {false}; + final CountDownLatch destroyed = new CountDownLatch(1); + final Backend[] self = new Backend[1]; + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication() { + public void stopped() { + synchronized (handlerDone) { + destroyedEarly[0] = !handlerDone[0]; + } + destroyed.countDown(); + } + }) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + self[0].stop(); + Thread.sleep(200); // still using the beans + synchronized (handlerDone) { + handlerDone[0] = true; + } + return request.respond(200, "text/plain", "bye".getBytes("UTF-8")); + } + }).start(); + self[0] = backend; + assertEquals("bye", read(open(port, "/"))); + assertTrue(destroyed.await(10, TimeUnit.SECONDS), "the beans were never destroyed"); + assertFalse(destroyedEarly[0], "the beans were destroyed while the handler still ran"); + long deadline = System.currentTimeMillis() + 5000; + while(!backend.isStopped() && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + assertTrue(backend.isStopped()); + } + + @Test + @DisplayName("values past a histogram's label keys do not make series of their own") + void extraLabelValuesAreIgnored() { + Histogram h = Metrics.histogram("test.extra.labels", "", "ms", null, + new String[] {"route", null, null}); + h.record(1, "/a", "x", null); + h.record(2, "/a", "y", null); + int labelled = 0; + for(Object p : h.points()) { + Map attributes = (Map) ((Map) p).get("attributes"); + if(attributes != null && "/a".equals(attributes.get("route"))) { + labelled++; + } + } + assertEquals(1, labelled, "two series exported under one label set"); + } + + @Test + @DisplayName("a new session whose first save fails has its beans destroyed and its row removed") + void anUnsavedNewSessionIsDiscarded() throws Exception { + final List ended = new ArrayList(); + final List deleted = new ArrayList(); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + final SessionStore memory = new Sessions.Memory(); + final SessionStore failing = new SessionStore() { + public HttpSession load(String id) throws IOException { + return memory.load(id); + } + + public void save(HttpSession session, String previousId) throws IOException { + throw new IOException("the store refused"); + } + + public void delete(String id) throws IOException { + deleted.add(id); + memory.delete(id); + } + + public int purgeExpired(long now) throws IOException { + return memory.purgeExpired(now); + } + + public int size() { + return memory.size(); + } + }; + Backend backend = Backend.builder(Config.of(settings, "test")).quiet() + .sessionStore(failing) + .application(new EmptyApplication() { + public void sessionEnded(Object[] beans) { + ended.add(beans); + } + }) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + HttpSession s = request.getSession(true); + s.scopedBeans(1)[0] = "cart"; + s.setAttribute("n", "x"); + return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); + } + }).start(); + try { + assertEquals(500, open(port, "/").getResponseCode()); + assertEquals(1, ended.size(), "the unsaved session's beans were kept"); + assertEquals(1, deleted.size(), "a half-written row was left behind"); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a session store is set before the server listens; replacing it later is refused") + void sessionStoresAreSetBeforeUse() throws Exception { + Sessions sessions = new Sessions(); + sessions.setStore(new Sessions.Memory()); // nothing used yet: fine + sessions.find(null, true); + assertThrows(IllegalStateException.class, + () -> sessions.setStore(new Sessions.Memory())); + } + + @Test + @DisplayName("a builder runs one server at a time, and may start again once it stopped") + void aBuilderRunsOneServerAtATime() throws Exception { + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + Backend.Builder builder = Backend.builder(Config.of(settings, "test")).quiet() + .application(new EmptyApplication()); + Backend first = builder.start(); + try { + assertThrows(IllegalStateException.class, () -> builder.start()); + } finally { + first.stop(); + } + Backend again = builder.start(); + again.stop(); + } + + @Test + @DisplayName("a copy that rotates after another request invalidated its session keeps the retired beans") + void aStaleRotationUsesTheRetiredBeans(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + DataSource pool = DataSource.open(new java.io.File(dir, "retired.db").getAbsolutePath(), + 2, 5000, 10000); + try { + Sessions sessions = new Sessions(new EmptyApplication()); + sessions.setStore(new Sessions.Db(pool)); + long now = System.currentTimeMillis(); + HttpSession stored = new HttpSession("shared", now, now, 1800); + stored.markNew(); + sessions.getStore().save(stored, null); + HttpSession a = sessions.getStore().load("shared"); + HttpSession b = sessions.getStore().load("shared"); + a.owner = sessions; + b.owner = sessions; + HttpServer.Request ra = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + HttpServer.Request rb = new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null); + sessions.enter(ra, a); + sessions.enter(rb, b); + Object[] beans = b.scopedBeans(1); + a.invalidate(); + sessions.finish(ra, a, HttpServer.Response.text(200, "a")); + sessions.leave(ra); + b.changeSessionId(); // B rotates its stale copy + assertTrue(b.scopedBeans(1) == beans, + "a fresh set of beans was built under the rotated id"); + sessions.leave(rb); + } finally { + pool.close(); + } + } + + @Test + @DisplayName("recording with no label values is the unlabelled series, not a second empty one") + void noLabelValuesIsThePlainSeries() { + Histogram h = Metrics.histogram("test.plain.labels", "", "ms", null, + new String[] {"route", null, null}); + h.record(1); + h.record(2, null, null, null); + assertEquals(1, h.points().size(), "two series exported with the same empty labels"); + } + + @Test + @DisplayName("a database session's beans get the same expiry grace as its row") + void dbBeansGetTheRowsGrace(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + DataSource pool = DataSource.open(new java.io.File(dir, "grace.db").getAbsolutePath(), + 2, 5000, 10000); + final List ended = new ArrayList(); + try { + Sessions sessions = new Sessions(new EmptyApplication() { + public void sessionEnded(Object[] beans) { + ended.add(beans); + } + }); + sessions.setStore(new Sessions.Db(pool)); + long now = System.currentTimeMillis(); + // A minute's timeout, last used 65 seconds ago: past the timeout, inside + // the 15-second touch interval the store still accepts the row for. + HttpSession s = new HttpSession("graced", now - 65000, now - 65000, 60); + s.owner = sessions; + s.scopedBeans(1)[0] = "cart"; + sessions.purgeIfDue(sessions.getStore(), now); + assertTrue(ended.isEmpty(), "the beans expired while the row was still accepted"); + sessions.purgeIfDue(sessions.getStore(), now + 61000); + assertEquals(1, ended.size(), "the beans never expired"); + } finally { + pool.close(); + } + } + + @Test + @DisplayName("label values that export alike are one series; reserved and empty keys are refused") + void labelValuesAndKeysAreChecked() { + Histogram h = Metrics.histogram("test.canonical.labels", "", "ms", null, + new String[] {"code", null, null}); + h.record(1, Integer.valueOf(200), null, null); + h.record(2, Long.valueOf(200), null, null); + assertEquals(1, h.points().size(), "an Integer and a Long label made two series"); + assertThrows(IllegalArgumentException.class, () -> Metrics.histogram( + "test.reserved.label", "", "ms", null, + new String[] {"otel.metric.overflow", null, null})); + assertThrows(IllegalArgumentException.class, () -> Metrics.histogram( + "test.reserved.folded", "", "ms", null, + new String[] {"otel_metric_overflow", null, null})); + assertThrows(IllegalArgumentException.class, + () -> com.codename1.backend.metrics.Gauge.point("", "x", 1)); + } + + /** A tracer that records nothing, for tests that only check which one is used. */ + static class QuietTracer implements Tracer { + public boolean open(Config config) { + return true; + } + + public Span startSpan(String name, int kind, Span parent, String traceparent, + String tracestate) { + return null; + } + + public void flush(int timeoutMillis) { + } + + public void shutdown(int timeoutMillis) { + } + + public HttpServer.Handler relay() { + return null; + } + + public void metrics(Map out) { + } + } + + // ----------------------------------------------------------------- helpers + + /** An application with no beans, for tests that only need the hooks. */ + static class EmptyApplication implements Backend.Application { + public HttpServer.Handler[] create(Backend.Environment environment) { + return new HttpServer.Handler[0]; + } + + public void registerWebSockets(HttpServer.WebSocketRegistry registry) { + } + + public void started(Backend backend) { + } + + public void stopping() { + } + + public void stopped() { + } + + public boolean tracksCurrentRequest() { + return false; + } + + public void requestEnded(Object[] beans) { + } + + public void sessionEnded(Object[] beans) { + } + + public Scheduler getScheduler() { + return null; + } + + public List describeBeans() { + return new ArrayList(); + } + + public List describeRoutes() { + return new ArrayList(); + } + } + + private static HttpURLConnection open(int port, String path) throws IOException { + return (HttpURLConnection)new URL("http://127.0.0.1:" + port + path).openConnection(); + } + + private static String read(HttpURLConnection c) throws IOException { + int status = c.getResponseCode(); + InputStream in = status >= 400 ? c.getErrorStream() : c.getInputStream(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + if(in != null) { + byte[] buffer = new byte[4096]; + int n; + while((n = in.read(buffer)) > 0) { + out.write(buffer, 0, n); + } + } + String body = new String(out.toByteArray(), "UTF-8"); + return status >= 400 ? "HTTP " + status + ": " + body : body; + } + + @Test + @DisplayName("MCP and management match the canonical path; MCP does CORS for allowed origins") + void ownRoutesUseTheCanonicalPathAndMcpDoesCors() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + settings.setProperty(McpServer.ENABLED, "true"); + settings.setProperty(McpServer.ALLOWED_ORIGINS, "https://app.example"); + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().management() + .mcp(null).handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return HttpServer.Response.text(418, "fell through"); + } + }).start(); + String ping = "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"ping\"}"; + try { + // /%6dcp and /%6danage are /mcp and /manage (RFC 3986 6.2.2). + String escaped = rawRequest(port, "POST", "/%6dcp", null, ping); + assertTrue(escaped.startsWith("HTTP/1.1 200"), escaped); + assertTrue(escaped.contains("\"id\":4"), escaped); + String health = rawRequest(port, "GET", "/%6danage/health", null, null); + assertTrue(health.startsWith("HTTP/1.1 200"), health); + + // The preflight carries no token and must still be answered. + String preflight = rawRequest(port, "OPTIONS", "/mcp", "https://app.example", null); + assertTrue(preflight.startsWith("HTTP/1.1 204"), preflight); + String lower = preflight.toLowerCase(java.util.Locale.ROOT); + assertTrue(lower.contains("access-control-allow-origin: https://app.example"), + preflight); + assertTrue(lower.contains("access-control-allow-headers: authorization"), preflight); + String call = rawRequest(port, "POST", "/mcp", "https://app.example", ping); + assertTrue(call.startsWith("HTTP/1.1 200"), call); + assertTrue(call.toLowerCase(java.util.Locale.ROOT) + .contains("access-control-allow-origin: https://app.example"), call); + + String refused = rawRequest(port, "OPTIONS", "/mcp", "https://evil.example", null); + assertTrue(refused.startsWith("HTTP/1.1 403"), refused); + assertFalse(refused.toLowerCase(java.util.Locale.ROOT) + .contains("access-control-allow-origin"), refused); + } finally { + backend.stop(); + } + } + + /** One raw HTTP/1.1 exchange; HttpURLConnection drops a hand-set Origin. */ + private static String rawRequest(int port, String method, String path, String origin, + String body) throws IOException { + java.net.Socket socket = new java.net.Socket("127.0.0.1", port); + try { + StringBuilder head = new StringBuilder(method).append(' ').append(path) + .append(" HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n"); + if (origin != null) { + head.append("Origin: ").append(origin).append("\r\n"); + } + byte[] bytes = body == null ? new byte[0] : body.getBytes("UTF-8"); + if (body != null) { + head.append("Content-Type: application/json\r\n"); + } + head.append("Content-Length: ").append(bytes.length).append("\r\n\r\n"); + socket.getOutputStream().write(head.toString().getBytes("UTF-8")); + socket.getOutputStream().write(bytes); + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int n; + while ((n = socket.getInputStream().read(buffer)) > 0) { + raw.write(buffer, 0, n); + } + return new String(raw.toByteArray(), "UTF-8"); + } finally { + socket.close(); + } + } + + /** + * A raw POST of a ping to /mcp, because HttpURLConnection silently drops + * restricted headers like Origin and Host -- the request would arrive + * without them and pass. + */ + private static String rawMcp(int port, String host, String origin) throws IOException { + String body = "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"ping\"}"; + java.net.Socket socket = new java.net.Socket("127.0.0.1", port); + try { + socket.getOutputStream().write(("POST /mcp HTTP/1.1\r\nHost: " + host + + "\r\nOrigin: " + origin + "\r\nContent-Type: application/json\r\n" + + "Content-Length: " + body.length() + "\r\nConnection: close\r\n\r\n" + + body).getBytes("UTF-8")); + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int n; + while((n = socket.getInputStream().read(buffer)) > 0) { + raw.write(buffer, 0, n); + } + return new String(raw.toByteArray(), "UTF-8"); + } finally { + socket.close(); + } + } + + private static String post(int port, String path, String json, String origin) + throws IOException { + HttpURLConnection c = open(port, path); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json"); + if(origin != null) { + c.setRequestProperty("Origin", origin); + } + OutputStream out = c.getOutputStream(); + out.write(json.getBytes("UTF-8")); + out.close(); + return read(c); + } + + private static int freePort() throws IOException { + ServerSocket s = new ServerSocket(0); + try { + return s.getLocalPort(); + } finally { + s.close(); + } + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/JsonCodecTest.java b/maven/backend/src/test/java/com/codename1/backend/JsonCodecTest.java new file mode 100644 index 00000000000..8e2aae1e09a --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/JsonCodecTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * 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.backend; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class JsonCodecTest { + private static final JsonCodec.Path ROOT = JsonCodec.Path.ROOT; + + @Test + @DisplayName("a whole number is read within its range, and refused outside it") + void wholeNumbers() { + assertEquals(Long.MAX_VALUE, JsonCodec.readLong(Long.valueOf(Long.MAX_VALUE), ROOT, "n", + -1, Long.MIN_VALUE, Long.MAX_VALUE)); + assertEquals(3L, JsonCodec.readLong(Double.valueOf(3.0), ROOT, "n", -1, 0, 10)); + assertEquals(Long.MIN_VALUE, JsonCodec.readLong(Double.valueOf(-9.223372036854775808E18), + ROOT, "n", -1, Long.MIN_VALUE, Long.MAX_VALUE)); + // 2^63 as a double: Long.MAX_VALUE rounds up to it, so a double compare + // accepted it and the cast clamped it to Long.MAX_VALUE. + IllegalArgumentException past = assertThrows(IllegalArgumentException.class, + () -> JsonCodec.readLong(Double.valueOf(9223372036854775808.0), ROOT, "n", -1, + Long.MIN_VALUE, Long.MAX_VALUE)); + assertEquals("$.n: expected a whole number from -9223372036854775808 to " + + "9223372036854775807, got the number 9.223372036854776E18", past.getMessage()); + assertThrows(IllegalArgumentException.class, () -> JsonCodec.readLong( + Double.valueOf(2.5), ROOT, "n", -1, 0, 10)); + assertThrows(IllegalArgumentException.class, () -> JsonCodec.readLong( + Long.valueOf(11), ROOT, "n", -1, 0, 10)); + } + + @Test + @DisplayName("dates are read from milliseconds and ISO-8601, and an impossible one is refused") + void dates() { + assertEquals(86400000L, JsonCodec.readDate(Long.valueOf(86400000L), ROOT, "d", -1) + .getTime()); + assertEquals(86400000L, JsonCodec.readDate("1970-01-02T00:00:00Z", ROOT, "d", -1) + .getTime()); + assertEquals(86399500L, JsonCodec.readDate("1970-01-02T02:59:59.5+03:00", ROOT, "d", -1) + .getTime()); + assertEquals(86399500L, JsonCodec.readDate("1970-01-02T02:59:59.5+0300", ROOT, "d", -1) + .getTime()); + assertEquals(951782400000L, JsonCodec.readDate("2000-02-29", ROOT, "d", -1).getTime()); + assertThrows(IllegalArgumentException.class, + () -> JsonCodec.readDate("2001-02-29", ROOT, "d", -1)); + assertThrows(IllegalArgumentException.class, + () -> JsonCodec.readDate("2001-01-01T25:00", ROOT, "d", -1)); + } + + @Test + @DisplayName("a refusal names the path of the value it refused") + void paths() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> JsonCodec.readString(Boolean.TRUE, ROOT.child("items").child(2), "name", + -1)); + assertEquals("$.items[2].name: expected a string, got true", e.getMessage()); + assertEquals("$[0]", JsonCodec.enter(ROOT, null, 0).toString()); + assertEquals("$", JsonCodec.enter(ROOT, null, -1).toString()); + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/LambdaTracingTest.java b/maven/backend/src/test/java/com/codename1/backend/LambdaTracingTest.java index 3e7ca4274b3..23ccc0a0f67 100644 --- a/maven/backend/src/test/java/com/codename1/backend/LambdaTracingTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/LambdaTracingTest.java @@ -212,6 +212,57 @@ void aStandaloneTracestateIsTheCallers() { assertTrue(Tracing.callerTraceparent(other)); } + @Test + @DisplayName("an Error out of the tracer is a monitoring fault too: the work goes on untraced") + void tracerErrorsAreContained() throws Exception { + Recorder recorder = new Recorder(); + recorder.errorOnAttributes = true; + Tracing.install(recorder); + assertEquals(null, Tracing.startLambda(null, "req")); + RecordedSpan span = (RecordedSpan)recorder.spans.get(0); + assertTrue(span.ended, "a span dropped after an Error in its decoration was never ended"); + assertTrue(span.discarded); + + recorder.startError = new LinkageError("tracer class missing"); + assertEquals(null, Tracing.startLambda(null, "req"), + "an Error from startSpan escaped into the invocation"); + Object ran = Tracing.inBackground("job", null, null, new Tracing.Work() { + public Object run(Span s) { + return "ran"; + } + }); + assertEquals("ran", ran, "background work did not run past a failing tracer"); + } + + @Test + @DisplayName("work that fails with an Error is recorded on its span, not exported as a success") + void errorsAreRecordedOnSpans() throws Exception { + Recorder recorder = new Recorder(); + Tracing.install(recorder); + try { + Tracing.inSpan("work", new Tracing.Work() { + public Object run(Span s) { + throw new AssertionError("broken invariant"); + } + }); + } catch (AssertionError expected) { + // rethrown, as it must be + } + RecordedSpan span = (RecordedSpan)recorder.spans.get(0); + assertTrue(span.ended); + assertEquals(1, span.errors.size(), "the Error was not recorded on the span"); + try { + Tracing.inBackground("job", null, null, new Tracing.Work() { + public Object run(Span s) { + throw new LinkageError("missing class"); + } + }); + } catch (LinkageError expected) { + // rethrown + } + assertEquals(1, ((RecordedSpan)recorder.spans.get(1)).errors.size()); + } + @Test @DisplayName("a span whose decoration throws is still ended") void abandonedSpansAreEnded() throws Exception { @@ -235,6 +286,8 @@ private static void drain(HttpExchange ex) throws IOException { private static final class Recorder implements Tracer { final List spans = new ArrayList(); boolean throwOnAttributes; + boolean errorOnAttributes; + Error startError; public boolean open(Config config) { return true; @@ -242,7 +295,10 @@ public boolean open(Config config) { public Span startSpan(String name, int kind, Span parent, String traceparent, String tracestate) { - RecordedSpan span = new RecordedSpan(throwOnAttributes); + if(startError != null) { + throw startError; + } + RecordedSpan span = new RecordedSpan(throwOnAttributes, errorOnAttributes); spans.add(span); return span; } @@ -268,19 +324,24 @@ public void metrics(Map out) { private static final class RecordedSpan extends Span { private final boolean throwOnAttributes; + private final boolean errorOnAttributes; boolean ended; boolean discarded; String error; final List errors = new ArrayList(); - RecordedSpan(boolean throwOnAttributes) { + RecordedSpan(boolean throwOnAttributes, boolean errorOnAttributes) { this.throwOnAttributes = throwOnAttributes; + this.errorOnAttributes = errorOnAttributes; } private Span attr() { if(throwOnAttributes) { throw new IllegalStateException("tracer bug"); } + if(errorOnAttributes) { + throw new AssertionError("tracer assertion"); + } return this; } diff --git a/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java new file mode 100644 index 00000000000..c217ddd5025 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java @@ -0,0 +1,244 @@ +/* + * 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. + */ +package com.codename1.backend; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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; + +/** + * What the transaction, scheduler-lock and session-store code sends to a SERVER + * engine, which is where it differs: savepoints and read-only transactions go + * through MySQL's text protocol, and the lock and session tables are DDL built + * from each dialect's column types. + * + * Runs only when pointed at a database -- CN1_TX_POSTGRES or CN1_TX_MYSQL, a + * postgres:// or mysql:// URL -- because SQLite, which TransactionsTest covers, + * is the engine that needs none of this. + */ +class ServerEngineTransactionsTest { + + private static DataSource open(String engine) throws IOException { + String url = System.getenv("CN1_TX_" + engine); + Assumptions.assumeTrue(url != null && url.length() > 0, + "CN1_TX_" + engine + " is unset; this engine is not exercised"); + DataSource pool = DataSource.open(url, 4, 5000, 10000); + pool.execute("DROP TABLE IF EXISTS cn1_tx_probe", null); + pool.execute("CREATE TABLE cn1_tx_probe (v VARCHAR(40))", null); + return pool; + } + + private static int rows(DataSource pool) throws IOException { + Map row = pool.queryOne("SELECT COUNT(*) AS n FROM cn1_tx_probe", null); + return ((Number)row.get("n")).intValue(); + } + + private static void insert(DataSource pool, String v) throws IOException { + pool.execute("INSERT INTO cn1_tx_probe (v) VALUES (?)", new Object[] {v}); + } + + @ParameterizedTest + @ValueSource(strings = {"POSTGRES", "MYSQL"}) + void commitRollbackAndSavepoints(String engine) throws Exception { + DataSource pool = open(engine); + try { + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + insert(pool, "a"); + Transactions.commit(tx); + assertEquals(1, rows(pool)); + + tx = Transactions.begin(Transactions.REQUIRED, false, -1); + insert(pool, "b"); + Transactions.afterThrow(tx, true); + assertEquals(1, rows(pool), "a rolled-back insert was kept"); + + Transactions.Transaction outer = Transactions.begin(Transactions.REQUIRED, false, -1); + insert(pool, "kept"); + Transactions.Transaction nested = Transactions.begin(Transactions.NESTED, false, -1); + insert(pool, "undone"); + Transactions.afterThrow(nested, true); + Transactions.commit(outer); + assertEquals(2, rows(pool), "the savepoint did not undo exactly its own insert"); + + // REQUIRES_NEW on a second connection commits while the outer + // transaction -- which a server engine lets both write -- rolls back. + outer = Transactions.begin(Transactions.REQUIRED, false, -1); + insert(pool, "outer"); + Transactions.Transaction inner = Transactions.begin(Transactions.REQUIRES_NEW, false, -1); + insert(pool, "inner"); + Transactions.commit(inner); + Transactions.afterThrow(outer, true); + assertEquals(3, rows(pool), "REQUIRES_NEW did not commit independently"); + } finally { + pool.execute("DROP TABLE IF EXISTS cn1_tx_probe", null); + pool.close(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"POSTGRES", "MYSQL"}) + void aReadOnlyTransactionRefusesWrites(String engine) throws Exception { + DataSource pool = open(engine); + try { + final DataSource p = pool; + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, true, -1); + assertThrows(IOException.class, () -> insert(p, "x"), + "the engine accepted a write in a read-only transaction"); + Transactions.afterThrow(tx, true); + assertEquals(0, rows(pool)); + } finally { + pool.execute("DROP TABLE IF EXISTS cn1_tx_probe", null); + pool.close(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"POSTGRES", "MYSQL"}) + void theSessionStoreRoundTrips(String engine) throws Exception { + DataSource pool = open(engine); + try { + pool.execute("DROP TABLE IF EXISTS cn1_http_session", null); + Sessions.Db store = new Sessions.Db(pool); + long now = System.currentTimeMillis(); + HttpSession s = new HttpSession("abc", now, now, 60); + s.markNew(); // only a new session inserts + s.setAttribute("user", "ada"); + s.setAttribute("visits", new Long(3)); + store.save(s, null); + HttpSession back = store.load("abc"); + assertNotNull(back); + assertEquals("ada", back.getAttribute("user")); + assertEquals(3L, ((Number)back.getAttribute("visits")).longValue()); + store.save(back, null); // the update path + // Two copies of one session, each changing its own attribute: the + // second save must merge onto the first, not replace it. + HttpSession one = store.load("abc"); + HttpSession two = store.load("abc"); + one.setAttribute("theme", "dark"); + two.setAttribute("lang", "en"); + store.save(one, null); + store.save(two, null); + HttpSession both = store.load("abc"); + assertEquals("dark", both.getAttribute("theme"), "a stale copy discarded a change"); + assertEquals("en", both.getAttribute("lang")); + assertEquals("ada", both.getAttribute("user")); + HttpSession renamed = new HttpSession("def", now, now, 60); + store.save(renamed, "abc"); + assertNull(store.load("abc"), "the old id survived a rename"); + HttpSession stale = new HttpSession("old", now - 120000, now - 120000, 60); + stale.markNew(); + store.save(stale, null); + // A 30-day timeout: its multiplication once overflowed PostgreSQL's + // INTEGER and failed every purge. + HttpSession month = new HttpSession("month", now, now, 2592000); + month.markNew(); + store.save(month, null); + assertTrue(store.purgeExpired(now) >= 1); + assertNull(store.load("old")); + assertNotNull(store.load("month"), "a live 30-day session was purged"); + } finally { + pool.execute("DROP TABLE IF EXISTS cn1_http_session", null); + pool.execute("DROP TABLE IF EXISTS cn1_tx_probe", null); + pool.close(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"POSTGRES", "MYSQL"}) + void theSchedulerLockIsExclusive(String engine) throws Exception { + DataSource pool = open(engine); + try { + pool.execute("DROP TABLE IF EXISTS cn1_scheduler_lock", null); + final int[] ran = new int[2]; + final Scheduler first = new Scheduler(pool); + final Scheduler second = new Scheduler(pool); + final java.util.concurrent.CountDownLatch holding = new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1); + first.fixedDelay("job", 1000000, 1000000, null, Tasks.PLATFORM, "job", 60000, + new Runnable() { + public void run() { + ran[0]++; + holding.countDown(); + try { + release.await(); + } catch (InterruptedException ignored) { + // test + } + } + }); + second.fixedDelay("job", 1000000, 1000000, null, Tasks.PLATFORM, "job", 60000, + new Runnable() { + public void run() { + ran[1]++; + } + }); + first.start(); + second.start(); + assertTrue(first.trigger("job")); + assertTrue(holding.await(10, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue(second.trigger("job")); + long deadline = System.currentTimeMillis() + 5000; + while(System.currentTimeMillis() < deadline + && ((Number)((Map)second.describe().get(0)).get("skipped")).longValue() == 0) { + Thread.sleep(20); + } + assertEquals(0, ran[1], "the second instance ran while the first held the lock"); + release.countDown(); + first.stop(5000); + second.stop(5000); + // Released: now the second one may run. + Scheduler third = new Scheduler(pool); + final boolean[] thirdRan = new boolean[1]; + third.fixedDelay("job", 1000000, 1000000, null, Tasks.PLATFORM, "job", 60000, + new Runnable() { + public void run() { + thirdRan[0] = true; + } + }); + third.start(); + assertTrue(third.trigger("job")); + deadline = System.currentTimeMillis() + 5000; + while(!thirdRan[0] && System.currentTimeMillis() < deadline) { + Thread.sleep(20); + } + third.stop(5000); + assertTrue(thirdRan[0], "a released lock could not be claimed again"); + assertFalse(ran[0] == 0); + } finally { + Tasks.shutdown(2000); + pool.execute("DROP TABLE IF EXISTS cn1_scheduler_lock", null); + pool.execute("DROP TABLE IF EXISTS cn1_tx_probe", null); + pool.close(); + } + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java new file mode 100644 index 00000000000..44187cd9fc0 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java @@ -0,0 +1,362 @@ +/* + * 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. + */ +package com.codename1.backend; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The runtime half of {@code @Transactional}: what the woven code calls, driven + * here the way it drives it -- begin, the body, then commit or afterThrow with + * the decision the build worked out -- against a real SQLite file, because a + * transaction is only proven by what a SECOND connection can see. + */ +class TransactionsTest { + private DataSource pool; + + @BeforeEach + void open(@TempDir File dir) throws IOException { + // A file, not :memory:, so the pool can hold a second connection and the + // tests can look at the table from outside the transaction. + pool = DataSource.open(new File(dir, "tx.db").getAbsolutePath(), 4, 5000, 10000); + pool.execute("CREATE TABLE t (v TEXT)", null); + } + + @AfterEach + void close() { + pool.close(); + } + + private int rows() throws IOException { + Map row = pool.queryOne("SELECT COUNT(*) AS n FROM t", null); + return ((Number)row.get("n")).intValue(); + } + + private void insert(String v) throws IOException { + pool.execute("INSERT INTO t (v) VALUES (?)", new Object[] {v}); + } + + @Test + @DisplayName("REQUIRED commits what its body did") + void requiredCommits() throws Exception { + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("a"); + insert("b"); + Transactions.commit(tx); + assertFalse(Transactions.isActive()); + assertEquals(2, rows()); + } + + @Test + @DisplayName("a rollback undoes every statement the body ran through the pool") + void rollbackUndoes() throws Exception { + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("a"); + Transactions.afterThrow(tx, true); + assertEquals(0, rows()); + } + + @Test + @DisplayName("statements inside the transaction share one connection, the pool's others see nothing yet") + void oneConnectionIsolated() throws Exception { + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("a"); + Database inside = pool.borrow(); + Database again = pool.borrow(); + assertSame(inside, again, "a thread in a transaction was handed a second connection"); + pool.release(inside); + pool.release(again); + Transactions.commit(tx); + assertEquals(1, rows()); + } + + @Test + @DisplayName("a joined method that fails marks the outer transaction, which then refuses to commit") + void joinedFailureIsUnexpectedRollback() throws Exception { + Transactions.Transaction outer = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("a"); + Transactions.Transaction inner = Transactions.begin(Transactions.REQUIRED, false, -1); + assertFalse(inner.isNewTransaction()); + insert("b"); + Transactions.afterThrow(inner, true); + assertTrue(Transactions.isRollbackOnly()); + assertThrows(TransactionException.UnexpectedRollback.class, + () -> Transactions.commit(outer)); + assertEquals(0, rows()); + } + + @Test + @DisplayName("REQUIRES_NEW commits on its own connection even when the outer one rolls back") + void requiresNewIsIndependent() throws Exception { + Transactions.Transaction outer = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("outer"); + Transactions.Transaction inner = Transactions.begin(Transactions.REQUIRES_NEW, true, -1); + assertTrue(inner.isNewTransaction()); + // SQLite: the outer transaction holds the write lock, so the inner one + // could not write -- read instead, which proves it is a different + // connection that cannot see the outer's uncommitted row. + Map seen = pool.queryOne("SELECT COUNT(*) AS n FROM t", null); + assertEquals(0, ((Number)seen.get("n")).intValue(), + "REQUIRES_NEW saw the suspended transaction's uncommitted row"); + Transactions.commit(inner); + Transactions.afterThrow(outer, true); + assertEquals(0, rows()); + } + + @Test + @DisplayName("a transaction on one pool refuses work on another, which would commit on its own") + void aSecondPoolIsRefusedInsideATransaction(@TempDir File dir) throws Exception { + DataSource other = DataSource.open(new File(dir, "other.db").getAbsolutePath(), 2, 5000, + 10000); + try { + other.execute("CREATE TABLE u (v TEXT)", null); + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("a"); + assertThrows(TransactionException.IllegalState.class, + () -> other.execute("INSERT INTO u (v) VALUES ('b')", null)); + Transactions.Transaction own = Transactions.begin(Transactions.REQUIRES_NEW, false, -1); + other.execute("INSERT INTO u (v) VALUES ('c')", null); + Transactions.commit(own); + Transactions.afterThrow(tx, true); + assertEquals(0, rows()); + assertEquals(1, ((Number) other.queryOne("SELECT COUNT(*) AS n FROM u", null) + .get("n")).intValue()); + } finally { + other.close(); + } + } + + @Test + @DisplayName("a joined inTransaction body that fails dooms the outer transaction, even if caught") + void aFailedJoinedHelperMarksRollbackOnly() throws Exception { + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("a"); + try { + pool.inTransaction(new DataSource.Work() { + public Object run(Database db) throws Exception { + db.execute("INSERT INTO t (v) VALUES ('b')", null); + throw new IllegalStateException("the helper failed"); + } + }); + } catch (IllegalStateException caught) { + // the caller handles it, and would otherwise go on to commit + } + assertTrue(Transactions.isRollbackOnly(), + "a failed joined helper left the transaction free to commit its writes"); + assertThrows(TransactionException.UnexpectedRollback.class, () -> Transactions.commit(tx)); + assertEquals(0, rows()); + } + + @Test + @DisplayName("a savepoint that cannot be set leaves the outer transaction unable to commit") + void aFailedSavepointMarksTheOuterRollbackOnly() throws Exception { + Transactions.Transaction outer = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("a"); + Database connection = pool.borrow(); // the transaction's own + pool.release(connection); + connection.close(); // so the SAVEPOINT fails + assertThrows(TransactionException.class, + () -> Transactions.begin(Transactions.NESTED, false, -1)); + assertTrue(Transactions.isRollbackOnly(), + "a caught savepoint failure left the outer transaction free to commit"); + try { + Transactions.afterThrow(outer, true); + } catch (RuntimeException closed) { + // the connection is gone; the rollback cannot reach it + } + } + + @Test + @DisplayName("NESTED rolls back to its savepoint and leaves the outer transaction able to commit") + void nestedSavepoint() throws Exception { + Transactions.Transaction outer = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("kept"); + Transactions.Transaction nested = Transactions.begin(Transactions.NESTED, false, -1); + insert("undone"); + Transactions.afterThrow(nested, true); + assertFalse(Transactions.isRollbackOnly()); + Transactions.commit(outer); + assertEquals(1, rows()); + } + + @Test + @DisplayName("NESTED before the transaction has touched a database sets its savepoint on the first statement") + void nestedBeforeAnyStatement() throws Exception { + // No process-wide default pool: the savepoint waits for the statement + // that decides which database the transaction is on. + Transactions.Transaction outer = Transactions.begin(Transactions.REQUIRED, false, -1); + Transactions.Transaction nested = Transactions.begin(Transactions.NESTED, false, -1); + insert("undone"); + Transactions.afterThrow(nested, true); + insert("kept"); + Transactions.commit(outer); + assertEquals(1, rows(), "the late savepoint did not undo exactly the nested insert"); + + outer = Transactions.begin(Transactions.REQUIRED, false, -1); + nested = Transactions.begin(Transactions.NESTED, false, -1); + Transactions.commit(nested); // never touched the database + insert("after"); + Transactions.commit(outer); + assertEquals(2, rows()); + + outer = Transactions.begin(Transactions.REQUIRED, false, -1); + nested = Transactions.begin(Transactions.NESTED, false, -1); + Transactions.afterThrow(nested, true); // nothing ran, nothing to undo + assertFalse(Transactions.isRollbackOnly()); + insert("last"); + Transactions.commit(outer); + assertEquals(3, rows()); + } + + @Test + @DisplayName("setRollbackOnly in the method that began the transaction rolls back silently") + void localRollbackOnlyIsSilent() throws Exception { + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("a"); + Transactions.setRollbackOnly(); + assertTrue(Transactions.isRollbackOnly()); + Transactions.commit(tx); // no UnexpectedRollback + assertFalse(Transactions.isActive()); + assertEquals(0, rows()); + + // From a joined method it is the participant's failure, and the method + // that began the transaction must be told its work was not saved. + Transactions.Transaction outer = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("b"); + Transactions.Transaction inner = Transactions.begin(Transactions.REQUIRED, false, -1); + Transactions.setRollbackOnly(); + Transactions.commit(inner); + assertThrows(TransactionException.UnexpectedRollback.class, + () -> Transactions.commit(outer)); + assertEquals(0, rows()); + } + + @Test + @DisplayName("setRollbackOnly in a NESTED method undoes only its savepoint") + void nestedRollbackOnlyIsLocal() throws Exception { + Transactions.Transaction outer = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("kept"); + Transactions.Transaction nested = Transactions.begin(Transactions.NESTED, false, -1); + insert("undone"); + Transactions.setRollbackOnly(); + assertTrue(Transactions.isRollbackOnly()); + Transactions.commit(nested); + assertFalse(Transactions.isRollbackOnly(), "the outer transaction was marked too"); + insert("after"); + Transactions.commit(outer); // no UnexpectedRollback + assertEquals(2, rows()); + } + + @Test + @DisplayName("MANDATORY refuses to run alone and NEVER refuses to run inside one") + void mandatoryAndNever() throws Exception { + assertThrows(TransactionException.IllegalState.class, + () -> Transactions.begin(Transactions.MANDATORY, false, -1)); + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + assertThrows(TransactionException.IllegalState.class, + () -> Transactions.begin(Transactions.NEVER, false, -1)); + Transactions.commit(tx); + } + + @Test + @DisplayName("NOT_SUPPORTED suspends the transaction and restores it afterwards") + void notSupportedSuspends() throws Exception { + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("a"); + Transactions.Transaction none = Transactions.begin(Transactions.NOT_SUPPORTED, false, -1); + assertFalse(Transactions.isActive()); + Transactions.commit(none); + assertTrue(Transactions.isActive()); + Transactions.commit(tx); + assertEquals(1, rows()); + } + + @Test + @DisplayName("a checked exception commits unless the build decided otherwise") + void checkedExceptionCommits() throws Exception { + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("a"); + Transactions.afterThrow(tx, false); + assertEquals(1, rows()); + } + + @Test + @DisplayName("a transaction that never touches the database borrows no connection") + void lazyBegin() throws Exception { + int idle = pool.getIdleCount(); + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + assertEquals(idle, pool.getIdleCount()); + Transactions.commit(tx); + assertEquals(idle, pool.getIdleCount()); + } + + @Test + @DisplayName("the programmatic inTransaction joins the thread's transaction instead of nesting") + void inTransactionJoins() throws Exception { + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + pool.inTransaction(new DataSource.Work() { + public Object run(Database db) throws Exception { + db.execute("INSERT INTO t (v) VALUES (?)", new Object[] {"joined"}); + return null; + } + }); + Transactions.afterThrow(tx, true); + assertEquals(0, rows(), "the joined work committed on its own"); + } + + @Test + @DisplayName("each thread has its own transaction") + void perThread() throws Exception { + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + insert("a"); + final Database[] other = new Database[1]; + final Database mine = pool.borrow(); + pool.release(mine); + Thread t = new Thread(() -> { + try { + other[0] = pool.borrow(); + pool.release(other[0]); + } catch (IOException err) { + throw new RuntimeException(err); + } + }); + t.start(); + t.join(); + assertNotSame(mine, other[0], "another thread was handed this thread's transaction"); + Transactions.commit(tx); + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/WebSocketReviewFixesTest.java b/maven/backend/src/test/java/com/codename1/backend/WebSocketReviewFixesTest.java index a7964013c86..3d668c6437d 100644 --- a/maven/backend/src/test/java/com/codename1/backend/WebSocketReviewFixesTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/WebSocketReviewFixesTest.java @@ -117,6 +117,56 @@ void webSocketOnlyServerAnswersHttpWithNotFound() throws Exception { } } + @Test + @DisplayName("stop() from onOpen discounts its own connection and defers the teardown") + void stopFromTheHandshakeIsACallersStop() throws Exception { + final Backend[] running = new Backend[1]; + final long[] took = new long[1]; + final String[] poolAfterStop = new String[1]; + final java.util.concurrent.CountDownLatch opened = + new java.util.concurrent.CountDownLatch(1); + Backend backend = Backend.builder().port(0).quiet().shutdownTimeoutMillis(4000) + .dataSource(":memory:") + .webSockets(new Backend.WebSocketEndpoints() { + public void register(HttpServer.WebSocketRegistry registry, + DataSource dataSource, + com.codename1.backend.orm.EntityManager entities) { + registry.route("/stop", new WebSocket() { + public void onOpen(WebSocketSession session) { + long started = System.currentTimeMillis(); + running[0].stop(); + took[0] = System.currentTimeMillis() - started; + try { + DataSource pool = running[0].getDataSource(); + pool.release(pool.borrow()); + poolAfterStop[0] = "open"; + } catch (Exception err) { + poolAfterStop[0] = String.valueOf(err); + } + opened.countDown(); + } + public void onText(WebSocketSession session, String message) { + } + public void onBinary(WebSocketSession s, byte[] m, int o, int l) { + } + }); + } + }) + .start(); + running[0] = backend; + RawWebSocketClient client = new RawWebSocketClient(backend.getServer().getPort(), + "/stop", null); + try { + assertTrue(opened.await(15, java.util.concurrent.TimeUnit.SECONDS)); + } finally { + client.close(); + } + assertTrue(took[0] < 2000, "stop() from onOpen waited " + took[0] + + "ms for its own connection"); + assertEquals("open", poolAfterStop[0], + "the teardown ran under the callback that stopped the server"); + } + @Test @DisplayName("a percent-spelled path reaches the same endpoint as the plain one") void pathsAreRoutedCanonically() throws Exception { diff --git a/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java b/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java new file mode 100644 index 00000000000..8b2cee495e5 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java @@ -0,0 +1,150 @@ +/* + * 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. + */ +package com.codename1.backend.mcp; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Which SQLite pragmas backend_sql runs without write=true. */ +class DevToolsTest { + + @Test + @DisplayName("only pragmas that read run unconfirmed, in either spelling") + void pragmaAllowList() { + assertTrue(DevTools.readOnlyPragma("PRAGMA table_info(notes)")); + assertTrue(DevTools.readOnlyPragma("pragma main.table_list")); + assertTrue(DevTools.readOnlyPragma("PRAGMA busy_timeout")); + assertFalse(DevTools.readOnlyPragma("PRAGMA busy_timeout(0)"), + "a setting given in parentheses was run unconfirmed"); + assertFalse(DevTools.readOnlyPragma("PRAGMA cache_size = 123")); + assertFalse(DevTools.readOnlyPragma("PRAGMA writable_schema(ON)")); + assertFalse(DevTools.readOnlyPragma("PRAGMA optimize")); + } + + @Test + @DisplayName("with the backend down, the bridge answers under the request's own id") + void bridgeAnswersUnderTheTopLevelId() throws Exception { + java.net.ServerSocket probe = new java.net.ServerSocket(0); + int closed = probe.getLocalPort(); + probe.close(); + java.net.URL url = new java.net.URL("http://127.0.0.1:" + closed + "/mcp"); + String answer = StdioBridge.post(url, null, "{\"jsonrpc\":\"2.0\",\"method\":" + + "\"tools/call\",\"params\":{\"arguments\":{\"id\":\"nested\"}},\"id\":7}"); + assertTrue(answer != null && answer.contains("\"id\":7") && !answer.contains("nested"), + String.valueOf(answer)); + assertTrue(StdioBridge.post(url, null, "{\"jsonrpc\":\"2.0\",\"method\":\"note\"," + + "\"params\":{\"id\":1}}") == null, "a notification got an answer"); + } + + @Test + @DisplayName("a backend that accepts and never answers is reported, not waited on forever") + void aStalledBackendTimesOut() throws Exception { + java.net.ServerSocket silent = new java.net.ServerSocket(0); + final java.util.List held = new java.util.ArrayList(); + Thread acceptor = new Thread(new Runnable() { + public void run() { + try { + held.add(silent.accept()); // accepted, never answered + } catch (java.io.IOException closed) { + // the test is over + } + } + }); + acceptor.setDaemon(true); + acceptor.start(); + try { + java.net.URL url = new java.net.URL("http://127.0.0.1:" + silent.getLocalPort() + + "/mcp"); + long start = System.currentTimeMillis(); + String answer = StdioBridge.post(url, null, + "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":3}", 300); + assertTrue(System.currentTimeMillis() - start < 10000, "the wait was not bounded"); + assertTrue(answer != null && answer.contains("\"id\":3") + && answer.contains("unreachable"), String.valueOf(answer)); + } finally { + silent.close(); + } + assertTrue(StdioBridge.readTimeoutMillis() > 0); + } + + @Test + @DisplayName("an HTTP refusal, a 401 most often, is answered under the host's own id") + void aRefusalKeepsTheRequestId() throws Exception { + com.sun.net.httpserver.HttpServer refusing = com.sun.net.httpserver.HttpServer.create( + new java.net.InetSocketAddress("127.0.0.1", 0), 0); + refusing.createContext("/mcp", exchange -> { + byte[] answer = ("{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32001," + + "\"message\":\"A bearer token is required\"}}").getBytes("UTF-8"); + java.io.InputStream in = exchange.getRequestBody(); + while(in.read() >= 0) { + // drain the request + } + exchange.sendResponseHeaders(401, answer.length); + exchange.getResponseBody().write(answer); + exchange.close(); + }); + refusing.start(); + try { + java.net.URL url = new java.net.URL("http://127.0.0.1:" + + refusing.getAddress().getPort() + "/mcp"); + String answer = StdioBridge.post(url, null, + "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":9}", 5000); + assertTrue(answer != null && answer.contains("\"id\":9") + && answer.contains("401") && answer.contains("bearer token"), + String.valueOf(answer)); + assertTrue(StdioBridge.post(url, null, + "{\"jsonrpc\":\"2.0\",\"method\":\"note\"}", 5000) == null, + "a notification got an answer"); + } finally { + refusing.stop(0); + } + } + + @Test + @DisplayName("a batch the backend cannot take gets an error for each request in it") + void aFailedBatchAnswersEveryId() throws Exception { + java.net.ServerSocket probe = new java.net.ServerSocket(0); + int closed = probe.getLocalPort(); + probe.close(); + java.net.URL url = new java.net.URL("http://127.0.0.1:" + closed + "/mcp"); + String answer = StdioBridge.post(url, null, "[{\"jsonrpc\":\"2.0\",\"method\":\"ping\"," + + "\"id\":1},{\"jsonrpc\":\"2.0\",\"method\":\"note\"}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":\"two\"}]", 2000); + assertTrue(answer != null && answer.startsWith("[") && answer.contains("\"id\":1") + && answer.contains("\"id\":\"two\""), String.valueOf(answer)); + } + + @Test + @DisplayName("credentials in exporter headers and URL queries are masked") + void credentialsInValuesAreMasked() { + assertTrue(DevTools.secret("cn1.otel.headers", "api-key=abc123")); + assertTrue(DevTools.secret("cn1.datasource.url", + "postgres://db.example/app?user=app&password=hunter2")); + assertTrue(DevTools.secret("DATABASE_URL", "postgres://app:hunter2@db/app")); + assertFalse(DevTools.secret("cn1.server.port", "8080")); + assertFalse(DevTools.secret("cn1.datasource.url", "sqlite:app.db")); + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/mcp/McpDispatchTest.java b/maven/backend/src/test/java/com/codename1/backend/mcp/McpDispatchTest.java new file mode 100644 index 00000000000..fe87a117108 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/mcp/McpDispatchTest.java @@ -0,0 +1,94 @@ +/* + * 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.backend.mcp; + +import com.codename1.backend.Config; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class McpDispatchTest { + @Test + @DisplayName("an explicit null JSON-RPC id is a request, answered; an absent one is not") + void aNullIdIsNotANotification() throws Exception { + Properties settings = new Properties(); + settings.setProperty(McpServer.ENABLED, "true"); + McpServer server = McpServer.fromConfig(Config.of(settings, "dev"), null, null, null); + Map ping = new LinkedHashMap(); + ping.put("jsonrpc", "2.0"); + ping.put("id", null); + ping.put("method", "ping"); + Object answer = server.dispatch(ping); + assertNotNull(answer, "a request with id null got no answer"); + assertTrue(((Map) answer).containsKey("id") && ((Map) answer).get("id") == null); + ping.remove("id"); + assertNull(server.dispatch(ping), "a notification was answered"); + } + + @Test + @DisplayName("a notification runs its method; only the answer is withheld") + void aNotificationStillRuns() throws Exception { + final int[] calls = {0}; + McpTool tool = new McpTool() { + public String name() { + return "touch"; + } + + public String description() { + return "counts calls"; + } + + public Map inputSchema() { + Map schema = new LinkedHashMap(); + schema.put("type", "object"); + return schema; + } + + public Object call(Map arguments) { + calls[0]++; + return "ok"; + } + }; + Properties settings = new Properties(); + settings.setProperty(McpServer.ENABLED, "true"); + McpServer server = McpServer.fromConfig(Config.of(settings, "dev"), null, null, + java.util.Collections.singletonList(tool)); + Map call = new LinkedHashMap(); + call.put("jsonrpc", "2.0"); + call.put("method", "tools/call"); + Map params = new LinkedHashMap(); + params.put("name", "touch"); + params.put("arguments", new LinkedHashMap()); + call.put("params", params); + assertNull(server.dispatch(call), "a notification was answered"); + org.junit.jupiter.api.Assertions.assertEquals(1, calls[0], + "an id-less tools/call never ran its tool"); + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java new file mode 100644 index 00000000000..171276d6d35 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -0,0 +1,451 @@ +/* + * 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. + */ +package com.codename1.backend.otel; + +import com.codename1.backend.metrics.Histogram; +import com.codename1.backend.metrics.Metrics; +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest; +import io.opentelemetry.proto.metrics.v1.HistogramDataPoint; +import io.opentelemetry.proto.metrics.v1.Metric; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The metrics export decoded by the classes opentelemetry-proto generates from + * the specification's own .proto files, so the wire types are judged by the + * schema rather than by a decoder written beside the encoder. + */ +class OtlpMetricsProtoTest { + + @Test + @DisplayName("histogram bucket counts decode to one count per bucket, matching the bounds") + void histogramBuckets() throws Exception { + Histogram h = Metrics.histogram("test.proto.histogram", "Proto check", "ms", + new double[] {1, 10, 100}, null); + h.record(0.5); + h.record(5); + h.record(5); + h.record(50); + h.record(500); + h.record(700); + List instruments = Collections.singletonList(h); + Map request = OtlpMetricExporter.request(new LinkedHashMap(), instruments, + System.currentTimeMillis()); + ExportMetricsServiceRequest decoded = ExportMetricsServiceRequest.parseFrom( + OtlpSchema.metricsProtobuf(request)); + Metric metric = decoded.getResourceMetrics(0).getScopeMetrics(0).getMetrics(0); + assertEquals("test.proto.histogram", metric.getName()); + HistogramDataPoint point = metric.getHistogram().getDataPoints(0); + assertEquals(Arrays.asList(1.0, 10.0, 100.0), point.getExplicitBoundsList()); + // A varint/fixed64 mismatch decodes as roughly eight counts per bucket. + assertEquals(Arrays.asList(1L, 2L, 1L, 2L), point.getBucketCountsList()); + assertEquals(6L, point.getCount()); + assertEquals(1260.5, point.getSum(), 1e-9); + assertNotNull(point.getAttributesList()); + } + + @Test + @DisplayName("an infinite reading is exported as OTLP/JSON's string form, and as a double") + void infiniteValuesSurviveBothEncodings() throws Exception { + Histogram h = Metrics.histogram("test.proto.infinite", "", "ms", + new double[] {1, 10}, null); + h.record(Double.POSITIVE_INFINITY); + Map request = OtlpMetricExporter.request(new LinkedHashMap(), + Collections.singletonList(h), System.currentTimeMillis()); + String json = new String(OtlpSchema.json(request), "UTF-8"); + assertTrue(json.contains("\"sum\":\"Infinity\""), json); + assertFalse(json.contains("null"), "a non-finite value was written as null: " + json); + ExportMetricsServiceRequest decoded = ExportMetricsServiceRequest.parseFrom( + OtlpSchema.metricsProtobuf(request)); + assertEquals(Double.POSITIVE_INFINITY, decoded.getResourceMetrics(0).getScopeMetrics(0) + .getMetrics(0).getHistogram().getDataPoints(0).getSum(), 0.0); + } + + @Test + @DisplayName("a gauge whose callback throws an Error costs its value, not the export") + void aGaugeErrorIsContained() throws Exception { + com.codename1.backend.metrics.Gauge broken = Metrics.gauge("test.proto.broken", "", "", + new com.codename1.backend.metrics.Gauge.Source() { + public double read() { + throw new AssertionError("application bug"); + } + }); + assertTrue(Double.isNaN(broken.read())); + Map request = OtlpMetricExporter.request(new LinkedHashMap(), + Collections.singletonList(broken), System.currentTimeMillis()); + OtlpSchema.metricsProtobuf(request); + assertTrue(Metrics.prometheus().length() > 0); + } + + @Test + @DisplayName("a counter past 2^53 is exported exactly, through as_int") + void countersAreIntegral() throws Exception { + com.codename1.backend.metrics.Counter big = Metrics.counter("test.proto.big", "", ""); + big.add(9007199254740993L); + Map request = OtlpMetricExporter.request(new LinkedHashMap(), + Collections.singletonList(big), System.currentTimeMillis()); + ExportMetricsServiceRequest decoded = ExportMetricsServiceRequest.parseFrom( + OtlpSchema.metricsProtobuf(request)); + io.opentelemetry.proto.metrics.v1.NumberDataPoint point = decoded.getResourceMetrics(0) + .getScopeMetrics(0).getMetrics(0).getSum().getDataPoints(0); + assertEquals(9007199254740993L, point.getAsInt(), + "a counter was rounded through a double"); + } + + @Test + @DisplayName("an up-down counter below zero encodes as a signed as_int") + void negativeUpDownCounter() throws Exception { + com.codename1.backend.metrics.Counter c = Metrics.upDownCounter("test.proto.negative", + "", ""); + c.add(-5); + Map request = OtlpMetricExporter.request(new LinkedHashMap(), + Collections.singletonList(c), System.currentTimeMillis()); + ExportMetricsServiceRequest decoded = ExportMetricsServiceRequest.parseFrom( + OtlpSchema.metricsProtobuf(request)); + assertEquals(-5L, decoded.getResourceMetrics(0).getScopeMetrics(0).getMetrics(0) + .getSum().getDataPoints(0).getAsInt()); + } + + @Test + @DisplayName("data points a collector rejects in a partial success are reported, not healthy") + void partialSuccessIsReported() throws Exception { + final byte[][] answer = {new byte[0]}; + final String[] type = {"application/x-protobuf"}; + com.sun.net.httpserver.HttpServer collector = com.sun.net.httpserver.HttpServer.create( + new java.net.InetSocketAddress("127.0.0.1", 0), 0); + collector.createContext("/v1/metrics", exchange -> { + java.io.InputStream in = exchange.getRequestBody(); + while(in.read() >= 0) { + // drained + } + exchange.getResponseHeaders().add("Content-Type", type[0]); + exchange.sendResponseHeaders(200, answer[0].length); + exchange.getResponseBody().write(answer[0]); + exchange.close(); + }); + collector.start(); + try { + java.util.Properties p = new java.util.Properties(); + p.setProperty(OtlpMetricExporter.ENDPOINT, "http://127.0.0.1:" + + collector.getAddress().getPort() + "/v1/metrics"); + p.setProperty(OtlpMetricExporter.INTERVAL, "3600000"); + OtlpMetricExporter exporter = new OtlpMetricExporter("partial"); + exporter.open(com.codename1.backend.Config.of(p, "test")); + try { + answer[0] = io.opentelemetry.proto.collector.metrics.v1 + .ExportMetricsServiceResponse.newBuilder() + .setPartialSuccess(io.opentelemetry.proto.collector.metrics.v1 + .ExportMetricsPartialSuccess.newBuilder() + .setRejectedDataPoints(3).setErrorMessage("too old").build()) + .build().toByteArray(); + type[0] = "application/x-protobuf"; + assertFalse(exporter.export(), "a partial success counted as healthy"); + assertEquals(Long.valueOf(3), exporter.status().get("dataPointsRejected")); + assertTrue(String.valueOf(exporter.status().get("lastError")) + .contains("too old"), String.valueOf(exporter.status())); + answer[0] = "{\"partialSuccess\":{\"rejectedDataPoints\":\"2\"}}" + .getBytes("UTF-8"); + type[0] = "application/json"; + assertFalse(exporter.export()); + assertEquals(Long.valueOf(5), exporter.status().get("dataPointsRejected")); + answer[0] = new byte[0]; + assertTrue(exporter.export(), "an empty 200 is a full success"); + } finally { + exporter.shutdown(0); + } + } finally { + collector.stop(0); + } + } + + @Test + @DisplayName("a second metrics exporter with another identity is refused") + void oneMetricsIdentityPerProcess() throws Exception { + java.util.Properties a = new java.util.Properties(); + a.setProperty(OtlpMetricExporter.ENDPOINT, "http://127.0.0.1:9/v1/metrics"); + a.setProperty(OtlpTracer.SERVICE_NAME, "service-a"); + java.util.Properties b = new java.util.Properties(a); + b.setProperty(OtlpTracer.SERVICE_NAME, "service-b"); + OtlpMetricExporter first = new OtlpMetricExporter("x"); + OtlpMetricExporter second = new OtlpMetricExporter("x"); + OtlpMetricExporter same = new OtlpMetricExporter("x"); + first.open(com.codename1.backend.Config.of(a, "test")); + try { + org.junit.jupiter.api.Assertions.assertThrows(java.io.IOException.class, + () -> second.open(com.codename1.backend.Config.of(b, "test"))); + same.open(com.codename1.backend.Config.of(a, "test")); + same.shutdown(0); + } finally { + first.shutdown(0); + } + } + + @Test + @DisplayName("a second metrics exporter with other credentials or protocol is refused") + void oneMetricsTransportPerProcess() throws Exception { + java.util.Properties a = new java.util.Properties(); + a.setProperty(OtlpMetricExporter.ENDPOINT, "http://127.0.0.1:9/v1/metrics"); + a.setProperty(OtlpTracer.SERVICE_NAME, "shared"); + a.setProperty(OtlpMetricExporter.HEADERS, "x-api-key=tenant-a,x-team=one"); + java.util.Properties otherKey = new java.util.Properties(a); + otherKey.setProperty(OtlpMetricExporter.HEADERS, "x-api-key=tenant-b,x-team=one"); + java.util.Properties json = new java.util.Properties(a); + json.setProperty(OtlpMetricExporter.PROTOCOL, "http/json"); + java.util.Properties reordered = new java.util.Properties(a); + reordered.setProperty(OtlpMetricExporter.HEADERS, "x-team=one,x-api-key=tenant-a"); + OtlpMetricExporter first = new OtlpMetricExporter("x"); + // Shut down even when the refusal fails, so a wrongly opened exporter + // cannot collide with the next test's. + final OtlpMetricExporter keyed = new OtlpMetricExporter("x"); + final OtlpMetricExporter asJson = new OtlpMetricExporter("x"); + first.open(com.codename1.backend.Config.of(a, "test")); + try { + java.io.IOException refused = org.junit.jupiter.api.Assertions.assertThrows( + java.io.IOException.class, + () -> keyed.open(com.codename1.backend.Config.of(otherKey, "test"))); + assertTrue(refused.getMessage().contains("different"), refused.getMessage()); + assertFalse(refused.getMessage().contains("tenant"), + "the refusal printed a header value: " + refused.getMessage()); + org.junit.jupiter.api.Assertions.assertThrows(java.io.IOException.class, + () -> asJson.open(com.codename1.backend.Config.of(json, "test"))); + OtlpMetricExporter same = new OtlpMetricExporter("x"); + same.open(com.codename1.backend.Config.of(reordered, "test")); + same.shutdown(0); + } finally { + keyed.shutdown(0); + asJson.shutdown(0); + first.shutdown(0); + } + } + + @Test + @DisplayName("an exporter opened again after a shutdown exports periodically again") + void reopenedExporterKeepsExporting() throws Exception { + java.net.ServerSocket probe = new java.net.ServerSocket(0); + int closed = probe.getLocalPort(); + probe.close(); + java.util.Properties p = new java.util.Properties(); + // Nothing listens there, so every export counts as a failure -- which is + // what shows the thread is running. + p.setProperty(OtlpMetricExporter.ENDPOINT, "http://127.0.0.1:" + closed + "/v1/metrics"); + p.setProperty(OtlpMetricExporter.INTERVAL, "20"); + com.codename1.backend.Config config = com.codename1.backend.Config.of(p, "test"); + OtlpMetricExporter exporter = new OtlpMetricExporter("reopen"); + exporter.open(config); + exporter.shutdown(0); + exporter.open(config); + try { + long deadline = System.currentTimeMillis() + 5000; + long failures = 0; + while(System.currentTimeMillis() < deadline && failures < 3) { + Thread.sleep(20); + failures = ((Number)exporter.status().get("failures")).longValue(); + } + assertTrue(failures >= 3, "the reopened exporter stopped after " + failures + + " export(s)"); + } finally { + exporter.shutdown(0); + } + } + + @Test + @DisplayName("two exporters of one identity: one exports, the other takes over when it stops") + void oneExporterPerIdentityExports() throws Exception { + java.net.ServerSocket probe = new java.net.ServerSocket(0); + int closed = probe.getLocalPort(); + probe.close(); + java.util.Properties p = new java.util.Properties(); + p.setProperty(OtlpMetricExporter.ENDPOINT, "http://127.0.0.1:" + closed + "/v1/metrics"); + p.setProperty(OtlpMetricExporter.INTERVAL, "20"); + com.codename1.backend.Config config = com.codename1.backend.Config.of(p, "test"); + OtlpMetricExporter first = new OtlpMetricExporter("dup"); + OtlpMetricExporter second = new OtlpMetricExporter("dup"); + first.open(config); + second.open(config); + try { + org.junit.jupiter.api.Assertions.assertThrows(java.io.IOException.class, + () -> first.open(config), "the same exporter opened twice"); + long deadline = System.currentTimeMillis() + 5000; + while(System.currentTimeMillis() < deadline && failures(first) < 3) { + Thread.sleep(20); + } + assertTrue(failures(first) >= 3); + assertEquals(0L, failures(second), "both exporters sent the process's metrics"); + first.shutdown(0); + deadline = System.currentTimeMillis() + 5000; + while(System.currentTimeMillis() < deadline && failures(second) < 3) { + Thread.sleep(20); + } + assertTrue(failures(second) >= 3, "nobody exported after the first stopped"); + } finally { + first.shutdown(0); + second.shutdown(0); + } + } + + private static long failures(OtlpMetricExporter e) { + return ((Number) e.status().get("failures")).longValue(); + } + + @Test + @DisplayName("an exporter that shut down is not started by a late hand-over") + void aStoppedSuccessorIsNotStarted() throws Exception { + java.net.ServerSocket probe = new java.net.ServerSocket(0); + int closed = probe.getLocalPort(); + probe.close(); + java.util.Properties p = new java.util.Properties(); + p.setProperty(OtlpMetricExporter.ENDPOINT, "http://127.0.0.1:" + closed + "/v1/metrics"); + p.setProperty(OtlpMetricExporter.INTERVAL, "20"); + com.codename1.backend.Config config = com.codename1.backend.Config.of(p, "test"); + OtlpMetricExporter leader = new OtlpMetricExporter("race"); + OtlpMetricExporter successor = new OtlpMetricExporter("race"); + leader.open(config); + successor.open(config); + successor.shutdown(0); // stops first, while the leader is deciding + successor.takeOver(); // the leader's hand-over, arriving late + leader.shutdown(0); + Thread.sleep(200); + assertEquals(0L, failures(successor), "a shut-down exporter was started and exports"); + } + + @Test + @DisplayName("a successor starts exporting only once the old exporter's thread has exited") + void aSuccessorWaitsForTheOldExport() throws Exception { + final java.net.ServerSocket slow = new java.net.ServerSocket(0); + final java.util.List held = java.util.Collections.synchronizedList( + new java.util.ArrayList()); + Thread acceptor = new Thread(new Runnable() { + public void run() { + try { + while(true) { + held.add(slow.accept()); // accepted, never answered + } + } catch (java.io.IOException closed) { + // released + } + } + }); + acceptor.setDaemon(true); + acceptor.start(); + java.util.Properties p = new java.util.Properties(); + p.setProperty(OtlpMetricExporter.ENDPOINT, "http://127.0.0.1:" + slow.getLocalPort() + + "/v1/metrics"); + p.setProperty(OtlpMetricExporter.INTERVAL, "20"); + com.codename1.backend.Config config = com.codename1.backend.Config.of(p, "test"); + OtlpMetricExporter first = new OtlpMetricExporter("handoff"); + OtlpMetricExporter second = new OtlpMetricExporter("handoff"); + first.open(config); + second.open(config); + try { + long deadline = System.currentTimeMillis() + 5000; + while(held.isEmpty() && System.currentTimeMillis() < deadline) { + Thread.sleep(10); // the first export is now stuck in flight + } + assertEquals(1, held.size()); + first.shutdown(0); // hands over without waiting + Thread.sleep(300); + assertEquals(1, held.size(), + "the successor exported while the old export was still in flight"); + } finally { + slow.close(); + synchronized (held) { + for(Object s : held) { + ((java.net.Socket) s).close(); + } + } + second.shutdown(0); + } + } + + @Test + @DisplayName("a reopened exporter waits for its old thread's export before starting") + void aReopenWaitsForTheOldExport() throws Exception { + final java.net.ServerSocket slow = new java.net.ServerSocket(0); + final java.util.List held = java.util.Collections.synchronizedList( + new java.util.ArrayList()); + Thread acceptor = new Thread(new Runnable() { + public void run() { + try { + while(true) { + held.add(slow.accept()); // accepted, never answered + } + } catch (java.io.IOException closed) { + // released + } + } + }); + acceptor.setDaemon(true); + acceptor.start(); + java.util.Properties p = new java.util.Properties(); + p.setProperty(OtlpMetricExporter.ENDPOINT, "http://127.0.0.1:" + slow.getLocalPort() + + "/v1/metrics"); + p.setProperty(OtlpMetricExporter.INTERVAL, "20"); + final com.codename1.backend.Config config = com.codename1.backend.Config.of(p, "test"); + final OtlpMetricExporter exporter = new OtlpMetricExporter("reopen-wait"); + exporter.open(config); + final Throwable[] reopenFailed = new Throwable[1]; + Thread reopen = new Thread(new Runnable() { + public void run() { + try { + exporter.open(config); + } catch (Throwable err) { + reopenFailed[0] = err; + } + } + }); + try { + long deadline = System.currentTimeMillis() + 5000; + while(held.isEmpty() && System.currentTimeMillis() < deadline) { + Thread.sleep(10); // the export is now stuck in flight + } + assertEquals(1, held.size()); + exporter.shutdown(0); // returns while it is still stuck + reopen.start(); + Thread.sleep(300); + assertEquals(1, held.size(), + "the reopened exporter exported beside its old thread's export"); + assertTrue(reopen.isAlive(), "the reopen did not wait for the old thread"); + } finally { + synchronized (held) { + for(Object s : held) { + ((java.net.Socket) s).close(); // the old export fails and its thread ends + } + } + reopen.join(10000); + slow.close(); + exporter.shutdown(0); + } + assertTrue(reopenFailed[0] == null, String.valueOf(reopenFailed[0])); + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpTracerTest.java b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpTracerTest.java index bbf6dd71f45..80d68aae6f0 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpTracerTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpTracerTest.java @@ -939,6 +939,19 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { HttpURLConnection connection = (HttpURLConnection)new URL( "http://127.0.0.1:" + port + "/x").openConnection(); assertEquals(200, connection.getResponseCode()); + // The request's span ends after its response is written, so the client + // can read the 200 before the span is queued -- and a flush then has + // nothing to wait for and returns at once. Wait for the span first. + long deadline = System.currentTimeMillis() + 5000; + while (System.currentTimeMillis() < deadline) { + java.util.Map now = backend.getServer().getMetrics(); + if (((Number) now.get("spansQueued")).intValue() > 0 + || ((Number) now.get("spansExported")).longValue() > 0 + || now.get("spansRejected") != null) { + break; + } + Thread.sleep(10); + } Tracing.getTracer().flush(5000); metrics = backend.getServer().getMetrics(); } finally { diff --git a/maven/backend/src/test/java/com/codename1/backend/otel/TraceContextTest.java b/maven/backend/src/test/java/com/codename1/backend/otel/TraceContextTest.java index da47e24c278..8c3d5b1d8e5 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/TraceContextTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/TraceContextTest.java @@ -165,7 +165,10 @@ void unsendableRelayTokensAreRefused() throws Exception { + refused.getMessage()); tracer.shutdown(0); } - assertTrue(OtlpTracer.sendableFieldValue("s3cret with\tinner space")); + java.util.Properties inner = new java.util.Properties(); + inner.setProperty(OtlpTracer.RELAY_TOKEN, "s3cret with\tinner space"); + assertEquals("s3cret with\tinner space", com.codename1.backend.Config.of(inner, "test") + .getHeaderSecret(OtlpTracer.RELAY_TOKEN)); } @Test diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/application-dev.properties b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/application-dev.properties index 2cbe9fa13d0..e0f6c3aee48 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/application-dev.properties +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/application-dev.properties @@ -9,3 +9,11 @@ # Production points cn1.datasource.url at the real database instead -- a # postgres:// or mysql:// URL -- and the same code runs against it. cn1.datasource.url=:memory: + +# On this profile the server also serves, without a token: +# +# /manage/health, /manage/metrics, /manage/prometheus, /manage/jobs +# /mcp -- the development MCP tools, for an agent working on this backend +# +# Neither is served outside a development profile unless it is turned on and +# given a token (cn1.management.token, cn1.mcp.token). diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Api.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Api.java index bd18bd1a62a..638a5d23827 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Api.java +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Api.java @@ -23,6 +23,7 @@ package ${package}; import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PathVariable; import com.codename1.backend.annotations.RequestParam; import com.codename1.backend.annotations.RestController; @@ -61,13 +62,23 @@ * * CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend * - * A controller that needs the database says so in its constructor. Declare one - * taking a com.codename1.backend.DataSource for SQL, or one taking a + * A controller is a bean like any other, so its constructor says what it needs + * and the generated entry point passes it in: another bean such as the Greeter + * below, a com.codename1.backend.DataSource for SQL, or a * com.codename1.backend.orm.EntityManager for the daos generated from the - * project's @Entity classes, and the generated entry point passes it in. + * project's @Entity classes. + * + * On the dev profile the running server also answers MCP at /mcp, with tools + * that list its routes and beans, call its endpoints and query its database -- + * see the backend reference in this project's agent skill. */ @RestController public class Api { + private final Greeter greeter; + + public Api(Greeter greeter) { + this.greeter = greeter; + } /** What a load balancer polls. A String answer is sent as text. */ @GetMapping("/healthz") @@ -75,6 +86,12 @@ public String health() { return "ok"; } + /** Delegates to the injected service. */ + @GetMapping("/greet/{name}") + public String greet(@PathVariable("name") String name) { + return greeter.greet(name); + } + /** Anything that is not a String is sent as JSON. */ @GetMapping("/echo") public Map echo(@RequestParam(value = "say", defaultValue = "hello") String say) { diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java new file mode 100644 index 00000000000..4c196509d6e --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java @@ -0,0 +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. + */ +package ${package}; + +import com.codename1.backend.annotations.Service; + +/** + * A service: business logic with no HTTP in it, which the controller is given. + * + * The build finds every @Service, @Component and @Repository, works out + * what each one's constructor needs, and writes the `new` calls into the entry + * point it generates -- so this reads like Spring, and there is no container, no + * reflection and no start-up scan once it runs. A missing or ambiguous dependency + * is a build error that names the injection point. + * + * The same pass handles @Autowired fields, @Value configuration + * settings, @Transactional and @Async methods, @Scheduled jobs and + * @McpTool methods an agent can call. See the Backend chapter of the developer + * guide, or the backend reference in this project's agent skill. + */ +@Service +public class Greeter { + /** Greets someone. */ + public String greet(String name) { + return "Hello, " + name; + } +} diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index 555115aa847..67b3f91dc6c 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -67,6 +67,14 @@ ${project.version} test + + + org.xerial + sqlite-jdbc + 3.46.1.0 + test + junit junit diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java index 0e2a5f60f18..58bc7033b51 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java @@ -171,6 +171,15 @@ public class BackendPackageMojo extends AbstractMojo { @Parameter(property = "cn1.backend.checkedCasts", defaultValue = "true") private boolean checkedCasts; + /** + * Whether the packaged server carries the development MCP tools. Off by + * default: they read the database and call the server on an agent's behalf, + * and a production binary should not contain them at all -- not merely have + * them switched off. {@code cn1:backend} runs the JVM build, which has them. + */ + @Parameter(property = "cn1.backend.devTools", defaultValue = "false") + private boolean devTools; + public void execute() throws MojoExecutionException, MojoFailureException { File jdk = resolveJdk(); File work = new File(project.getBuild().getDirectory(), "cn1-backend"); @@ -250,6 +259,7 @@ private void generateControllers(File classes, File work) throws MojoExecutionEx + err.getMessage(), err); } RestControllerAnnotationProcessor processor = new RestControllerAnnotationProcessor(); + processor.setDevTools(devTools); ProcessorContext ctx = new ProcessorContext(classes, new File(work, "stubs"), index, getLog(), project.getBasedir(), new Properties(), mainClass, java.util.Collections.emptyList(), "UTF-8", @@ -297,6 +307,8 @@ private void generateControllers(File classes, File work) throws MojoExecutionEx processor.processClass(cls, ctx); } } + // finish() runs the bean pass -- the wiring, the rewritten classes and + // the classes generated beside them -- into this same tree. processor.finish(ctx); entities.enhance(ctx); } catch (ProcessingException err) { @@ -304,7 +316,7 @@ private void generateControllers(File classes, File work) throws MojoExecutionEx + err.getMessage(), err); } if (ctx.hasErrors()) { - StringBuilder sb = new StringBuilder("@RestController could not be processed:"); + StringBuilder sb = new StringBuilder("The backend's annotations could not be processed:"); for (ProcessorContext.ProcessingError e : ctx.getErrors()) { sb.append("\n ").append(e); } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java index 69ecf4c32dd..6e3f8713129 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java @@ -134,6 +134,11 @@ public String getSourceName() { void setSourceName(String sourceName) { this.sourceName = sourceName; } void setSourceFile(String sourceFile) { this.sourceFile = sourceFile; } + private boolean accessible = true; + void setAccessible(boolean accessible) { this.accessible = accessible; } + /// Whether code in another package can name this class: it and every class + /// enclosing it are public. + public boolean isAccessibleFromAnywhere() { return accessible; } private String sourceName; @@ -150,6 +155,7 @@ public String getSourceName() { public boolean isInterface() { return (access & Opcodes.ACC_INTERFACE) != 0; } public boolean isPublic() { return (access & Opcodes.ACC_PUBLIC) != 0; } public boolean isSynthetic() { return (access & Opcodes.ACC_SYNTHETIC) != 0; } + public boolean isFinal() { return (access & Opcodes.ACC_FINAL) != 0; } /// `true` when the class file's `ACC_RECORD` flag is set (Java 16+ record). /// Inlined as a constant so this code keeps compiling against ASM versions diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java index d93fc2bab82..2a473e9aa15 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java @@ -159,6 +159,7 @@ AnnotatedClass build() { classAnnotations, methods, fields, source); out.setSourceFile(sourceFile); out.setSourceName(sourceNameOf(internalName)); + out.setAccessible(accessibleFromAnywhere(internalName)); return out; } @@ -186,12 +187,39 @@ public void visitSource(String sourceFileName, String debug) { */ private final Map nesting = new LinkedHashMap(); + /** The access flags each member class in the chain was declared with. */ + private final Map innerAccess = new LinkedHashMap(); + @Override public void visitInnerClass(String name, String outerName, String innerName, int innerAccess) { if (name != null && outerName != null && innerName != null) { nesting.put(name, new String[] { outerName, innerName }); + this.innerAccess.put(name, Integer.valueOf(innerAccess)); + } + } + + /** + * Whether code in ANOTHER package can name this class: it is public, and so + * is every class it is nested in. The class file's own flags cannot say + * for a member class -- a private one is written package-private -- so + * the InnerClasses entries, which keep the declared flags, decide. + */ + private boolean accessibleFromAnywhere(String internal) { + String current = internal; + while (current != null) { + Integer declared = innerAccess.get(current); + String[] entry = nesting.get(current); + if (declared == null || entry == null) { + return current.equals(internal) + ? (access & Opcodes.ACC_PUBLIC) != 0 : true; + } + if ((declared.intValue() & Opcodes.ACC_PUBLIC) == 0) { + return false; + } + current = entry[0]; } + return true; } /** @@ -235,6 +263,7 @@ public MethodVisitor visitMethod(int access, String name, String descriptor, final String mName = name; final String mDesc = descriptor; final String mSig = signature; + final String[] mExceptions = exceptions; final Map mAnnotations = new LinkedHashMap(); // Parameter-annotation maps are created lazily on first write so @@ -264,7 +293,7 @@ public AnnotationVisitor visitParameterAnnotation(int parameter, @Override public void visitEnd() { methods.add(new MethodInfo(mName, mDesc, mSig, mAccess, - mAnnotations, paramAnnotations)); + mAnnotations, paramAnnotations, mExceptions)); } }; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java index 864c1927772..3a382643fc9 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java @@ -65,6 +65,7 @@ public final class FieldInfo { public boolean isPublic() { return (access & Opcodes.ACC_PUBLIC) != 0; } public boolean isStatic() { return (access & Opcodes.ACC_STATIC) != 0; } public boolean isFinal() { return (access & Opcodes.ACC_FINAL) != 0; } + public boolean isPrivate() { return (access & Opcodes.ACC_PRIVATE) != 0; } public Map getAnnotations() { return annotations; } public AnnotationValues getAnnotation(String descriptor) { return annotations.get(descriptor); } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/MethodInfo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/MethodInfo.java index 0a4364a066e..0009bd59641 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/MethodInfo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/MethodInfo.java @@ -45,9 +45,20 @@ public final class MethodInfo { private final Map annotations; private final List> parameterAnnotations; + private final List exceptions; + MethodInfo(String name, String descriptor, String signature, int access, Map annotations, List> parameterAnnotations) { + this(name, descriptor, signature, access, annotations, parameterAnnotations, null); + } + + MethodInfo(String name, String descriptor, String signature, int access, + Map annotations, + List> parameterAnnotations, + String[] exceptions) { + this.exceptions = exceptions == null ? Collections.emptyList() + : Collections.unmodifiableList(java.util.Arrays.asList(exceptions.clone())); this.name = name; this.descriptor = descriptor; this.signature = signature; @@ -71,6 +82,11 @@ public final class MethodInfo { } public String getName() { return name; } + /// The internal names of the checked exceptions the method declares + /// (`java/io/IOException`), in declaration order. + public List getExceptions() { return exceptions; } + public boolean isPrivate() { return (access & Opcodes.ACC_PRIVATE) != 0; } + public boolean isFinal() { return (access & Opcodes.ACC_FINAL) != 0; } public String getDescriptor() { return descriptor; } /// The JVM generic-type signature (e.g. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessorContext.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessorContext.java index 820af810b87..9402f8c976c 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessorContext.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessorContext.java @@ -213,6 +213,15 @@ public Map getEmittedResources() { return Collections.unmodifiableMap(emittedResources); } + private final Map attributes = new LinkedHashMap(); + + /// Shares a result between processors of one run -- the backend's bean graph + /// is computed once and read by the processor that writes the entry point. + public void setAttribute(String key, Object value) { attributes.put(key, value); } + + /// A value set by [#setAttribute] in this run, or null. + public Object getAttribute(String key) { return attributes.get(key); } + public boolean hasErrors() { return !errors.isEmpty(); } public List getErrors() { return Collections.unmodifiableList(errors); } public Map getEmittedClasses() { return Collections.unmodifiableMap(emittedClasses); } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeanAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeanAnnotationProcessor.java new file mode 100644 index 00000000000..599cf0bbfb3 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeanAnnotationProcessor.java @@ -0,0 +1,58 @@ +/* + * 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. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AbstractAnnotationProcessor; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; + +import java.util.Set; + +/// Runs the backend's bean pass -- see [BackendBeans] -- in every module that +/// uses its annotations, including one with no controller: a library of services +/// still needs its `@Transactional` methods rewritten. +/// +/// Registered before [RestControllerAnnotationProcessor], which reads the result +/// to write the entry point. The pass itself runs once per build whichever of the +/// two asks first. +public final class BackendBeanAnnotationProcessor extends AbstractAnnotationProcessor { + @Override + public Set getAnnotationDescriptors() { + return BackendBeans.DESCRIPTORS; + } + + @Override + public void processClass(AnnotatedClass cls, ProcessorContext ctx) { + // Nothing per class: the pass needs every class at once, and reads them + // from the index in finish(). + } + + @Override + public void finish(ProcessorContext ctx) throws ProcessingException { + if (ctx.hasErrors()) { + return; + } + BackendBeans.prepare(ctx); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeans.java new file mode 100644 index 00000000000..1543a72d73b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -0,0 +1,2800 @@ +/* + * 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. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.FieldInfo; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.MethodInfo; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +/// The backend's beans, resolved at build time. +/// +/// Every class carrying a stereotype -- `@Component`, `@Service`, `@Repository`, +/// `@Configuration`, `@RestController`, `@WebSocketMapping` -- and every `@Bean` +/// method is a bean. This works out, for each one, which constructor the +/// generated entry point calls and what it passes, which bean every `@Autowired` +/// field and setter receives, in what order they are built, and which of their +/// methods are scheduled, published as MCP tools or managed. Everything that +/// cannot be satisfied is a build error naming the injection point. +/// +/// The result is written down as code by [BackendWiringWriter]: `new` calls, +/// setter calls and direct method calls, with no registry, lookup or reflection +/// left for run time. The same pass rewrites the compiled classes -- see +/// [BackendWeaver] -- and generates, as Java source, the small classes the +/// aspects, scopes and adapters need. +/// +/// Computed once per build and shared through the processor context, because two +/// processors need it: the one that owns these annotations, which must rewrite +/// the classes even in a module with no controller, and the one that writes the +/// entry point. +final class BackendBeans { + static final String ATTRIBUTE = "cn1.backend.beans"; + + static final String PKG = "Lcom/codename1/backend/annotations/"; + static final String COMPONENT = PKG + "Component;"; + static final String SERVICE = PKG + "Service;"; + static final String REPOSITORY = PKG + "Repository;"; + static final String CONFIGURATION = PKG + "Configuration;"; + static final String BEAN = PKG + "Bean;"; + static final String AUTOWIRED = PKG + "Autowired;"; + static final String QUALIFIER = PKG + "Qualifier;"; + static final String PRIMARY = PKG + "Primary;"; + static final String LAZY = PKG + "Lazy;"; + static final String VALUE = PKG + "Value;"; + static final String CONFIG_PROPERTIES = PKG + "ConfigurationProperties;"; + static final String SCOPE = PKG + "Scope;"; + static final String REQUEST_SCOPE = PKG + "RequestScope;"; + static final String SESSION_SCOPE = PKG + "SessionScope;"; + static final String PROFILE = PKG + "Profile;"; + static final String ON_PROPERTY = PKG + "ConditionalOnProperty;"; + static final String ON_MISSING = PKG + "ConditionalOnMissingBean;"; + static final String POST_CONSTRUCT = PKG + "PostConstruct;"; + static final String PRE_DESTROY = PKG + "PreDestroy;"; + static final String TRANSACTIONAL = PKG + "Transactional;"; + static final String ASYNC = PKG + "Async;"; + static final String SCHEDULED = PKG + "Scheduled;"; + static final String MANAGED_RESOURCE = PKG + "ManagedResource;"; + static final String MANAGED_ATTRIBUTE = PKG + "ManagedAttribute;"; + static final String MANAGED_OPERATION = PKG + "ManagedOperation;"; + static final String TIMED = PKG + "Timed;"; + static final String COUNTED = PKG + "Counted;"; + static final String MCP_TOOL = PKG + "McpTool;"; + static final String MCP_PARAM = PKG + "McpParam;"; + static final String REST_CONTROLLER = PKG + "RestController;"; + static final String WEBSOCKET_MAPPING = PKG + "WebSocketMapping;"; + static final String GENERATED = PKG + "Generated;"; + + /// Every annotation this pass reads, for the processor's declared interest. + static final Set DESCRIPTORS = Collections.unmodifiableSet( + new LinkedHashSet(java.util.Arrays.asList(COMPONENT, SERVICE, REPOSITORY, + CONFIGURATION, BEAN, AUTOWIRED, VALUE, CONFIG_PROPERTIES, SCOPE, + REQUEST_SCOPE, SESSION_SCOPE, PROFILE, ON_PROPERTY, ON_MISSING, + POST_CONSTRUCT, PRE_DESTROY, TRANSACTIONAL, ASYNC, SCHEDULED, + MANAGED_RESOURCE, MANAGED_ATTRIBUTE, MANAGED_OPERATION, TIMED, COUNTED, + MCP_TOOL, REST_CONTROLLER, WEBSOCKET_MAPPING))); + + private static final String[] STEREOTYPES = {COMPONENT, SERVICE, REPOSITORY, CONFIGURATION, + REST_CONTROLLER, WEBSOCKET_MAPPING}; + + static final String CONFIG_TYPE = "com/codename1/backend/Config"; + static final String DATASOURCE_TYPE = "com/codename1/backend/DataSource"; + static final String ENTITIES_TYPE = "com/codename1/backend/orm/EntityManager"; + static final String SESSION_TYPE = "com/codename1/orm/session/Session"; + static final String REQUEST_TYPE = "com/codename1/backend/HttpServer$Request"; + static final String HTTP_SESSION_TYPE = "com/codename1/backend/HttpSession"; + + static final String SINGLETON = "singleton"; + static final String PROTOTYPE = "prototype"; + static final String REQUEST = "request"; + static final String SESSION = "session"; + + // ------------------------------------------------------------------ model + + /// One injection point: a constructor or method parameter, or a field. + static final class Point { + final String where; + final Type type; + /// The generic signature of the point's type, when there is one. + final String genericType; + String qualifier; + boolean required = true; + /// The @Value expression, or null. + String value; + /// The bean or beans it receives, once resolved. + final List candidates = new ArrayList(); + /// A built-in: config, dataSource, entities, session, request, httpSession. + String builtin; + /// Whether the point takes every bean of its element type, as a List. + boolean list; + /// Whether several conditional candidates are chosen between at start-up. + boolean choice; + /// The first candidate is a CONDITIONAL @Primary: used when it exists, the + /// others considered only when its condition leaves it out. + boolean preferFirst; + + Point(String where, Type type, String genericType) { + this.where = where; + this.type = type; + this.genericType = genericType; + } + } + + /// A method called with injected arguments: an `@Autowired` setter. + static final class Call { + final MethodInfo method; + final List points = new ArrayList(); + + Call(MethodInfo method) { + this.method = method; + } + } + + /// One `@Scheduled` method. + static final class Job { + final MethodInfo method; + /// The class's simple name and the method; qualified when that clashes. + String name; + /// The class the method is in, for qualifying a clashing name. + String ownerBinary; + String cron; + CronCompiler masks; + String zone = ""; + long fixedRate = -1; + long fixedDelay = -1; + long initialDelay = -1; + String fixedRateText; + String fixedDelayText; + String initialDelayText; + String thread = "PLATFORM"; + String executor = ""; + String lock = ""; + long lockAtMostFor = -1; + + Job(MethodInfo method, String name) { + this.method = method; + this.name = name; + } + } + + /// One `@McpTool` method. + static final class Tool { + final MethodInfo method; + final String name; + final String description; + final List paramNames = new ArrayList(); + final List paramDescriptions = new ArrayList(); + final List paramRequired = new ArrayList(); + String adapterBinary; + + Tool(MethodInfo method, String name, String description) { + this.method = method; + this.name = name; + this.description = description; + } + } + + /// A `@ManagedResource` bean's attributes and operations. + static final class Managed { + String objectName; + String description; + final List attributes = new ArrayList(); + final List attributeNames = new ArrayList(); + final List attributeDescriptions = new ArrayList(); + final List attributeUnits = new ArrayList(); + final List operations = new ArrayList(); + final List operationDescriptions = new ArrayList(); + final List> operationParams = new ArrayList>(); + String adapterBinary; + } + + /// One bean. + static final class Bean { + String name; + /// Internal name of the bean's type: the class, or a factory's return type. + String type; + /// The class, when it is in the project or on the classpath. + AnnotatedClass cls; + /// For a factory bean: the configuration bean and method, and the class + /// that declares the method -- all a static factory needs. + Bean owner; + MethodInfo factory; + AnnotatedClass factoryOwnerClass; + String scope = SINGLETON; + boolean primary; + boolean lazy; + boolean onMissing; + /// @ConditionalOnMissingBean's explicit types, empty for the default. + final List missingTypes = new ArrayList(); + /// Each entry is one @Profile's names, of which one must be active; every + /// entry must hold. More than one only for a factory bean, which also + /// carries its configuration class's @Profile. + final List profiles = new ArrayList(); + final List propertyConditions = new ArrayList(); + MethodInfo constructor; + final List constructorPoints = new ArrayList(); + final Map fields = new LinkedHashMap(); + final List setters = new ArrayList(); + final List postConstruct = new ArrayList(); + final List preDestroy = new ArrayList(); + String initMethod; + String destroyMethod; + String propertiesPrefix; + final List propertySetters = new ArrayList(); + final Set types = new LinkedHashSet(); + String var; + boolean controller; + boolean webSocket; + final List jobs = new ArrayList(); + final List tools = new ArrayList(); + Managed managed; + /// Request, session and lazy beans: the number the build gives each. + int slot = -1; + /// The generated stand-in class, for a request, session or lazy bean. + String proxyBinary; + + boolean isConditional() { + return !profiles.isEmpty() || !propertyConditions.isEmpty(); + } + + /// Whether it is reached through a generated stand-in rather than held. + boolean isProxied() { + return REQUEST.equals(scope) || SESSION.equals(scope) || lazy; + } + + boolean isEager() { + return SINGLETON.equals(scope) && !lazy; + } + + String describe() { + return name + " (" + type.replace('/', '.') + ")"; + } + } + + /// One class's aspects, for the helper class and the weaver. + static final class Aspects { + final AnnotatedClass cls; + final String helperBinary; + final List methods = new ArrayList(); + + Aspects(AnnotatedClass cls, String helperBinary) { + this.cls = cls; + this.helperBinary = helperBinary; + } + } + + /// The aspects of one method. + static final class Aspect { + final MethodInfo method; + AnnotationValues transactional; + AnnotationValues async; + AnnotationValues timed; + AnnotationValues counted; + String asyncTaskBinary; + + Aspect(MethodInfo method) { + this.method = method; + } + } + + // ------------------------------------------------------------------ state + + final ProcessorContext ctx; + final List beans = new ArrayList(); + final Map byName = new LinkedHashMap(); + final Map aspects = new TreeMap(); + /// Metric name -> "histogram" or "counter", with where it was declared, for + /// the @Timed and @Counted instruments the woven code registers. + private final Map aspectMetrics = new LinkedHashMap(); + /// Prometheus series name -> the aspect metric exporting it, and where. + private final Map aspectSeries = new LinkedHashMap(); + final Map plans = new TreeMap(); + /// Eager singletons (and the prototypes they need) in construction order. + final List order = new ArrayList(); + boolean needsDatabase; + boolean needsEntities; + boolean needsSession; + int requestSlots; + int sessionSlots; + int lazySlots; + /// The package the entry point is written into. + String entryPackage; + /// Sources this pass compiled, by binary name, so tests can read them. + final Map sources = new LinkedHashMap(); + + private BackendBeans(ProcessorContext ctx) { + this.ctx = ctx; + } + + /// The beans of this build: computed, validated, woven and their support + /// classes compiled on the first call, and the same object after that. + static BackendBeans prepare(ProcessorContext ctx) throws ProcessingException { + Object cached = ctx.getAttribute(ATTRIBUTE); + if (cached instanceof BackendBeans) { + return (BackendBeans) cached; + } + BackendBeans out = new BackendBeans(ctx); + ctx.setAttribute(ATTRIBUTE, out); + if (!usesAnyAnnotation(ctx)) { + // Every project runs this processor -- an app's client module too -- + // and nearly all of them use none of this. Settled in memory, before + // any source file is looked at. + return out; + } + out.discover(); + if (!ctx.hasErrors()) { + out.resolve(); + } + if (!ctx.hasErrors()) { + out.collectAspects(); + } + if (!ctx.hasErrors()) { + out.checkExecutorKinds(); + } + if (!ctx.hasErrors()) { + out.plan(); + } + if (!ctx.hasErrors()) { + out.emit(); + } + return out; + } + + private static boolean usesAnyAnnotation(ProcessorContext ctx) { + for (AnnotatedClass cls : ctx.getClassIndex().values()) { + for (String d : cls.getAllAnnotationDescriptors()) { + if (DESCRIPTORS.contains(d)) { + return true; + } + } + } + return false; + } + + /// Whether this build has anything for the entry point to wire beyond what + /// the controllers and endpoints themselves need. + boolean hasApplicationBeans() { + for (Bean b : beans) { + if (!b.controller && !b.webSocket) { + return true; + } + } + return false; + } + + boolean hasJobs() { + for (Bean b : beans) { + if (!b.jobs.isEmpty()) { + return true; + } + } + return false; + } + + boolean hasManaged() { + for (Bean b : beans) { + if (b.managed != null) { + return true; + } + } + return false; + } + + boolean hasTools() { + for (Bean b : beans) { + if (!b.tools.isEmpty()) { + return true; + } + } + return false; + } + + /// The bean for a controller or endpoint class, or null. + Bean beanOfClass(String binaryName) { + String internal = binaryName.replace('.', '/'); + for (Bean b : beans) { + if (b.factory == null && b.type.equals(internal)) { + return b; + } + } + return null; + } + + // --------------------------------------------------------------- discovery + + private void discover() { + List classes = new ArrayList(ctx.getClassIndex().values()); + Collections.sort(classes, new java.util.Comparator() { + public int compare(AnnotatedClass a, AnnotatedClass b) { + return a.getInternalName().compareTo(b.getInternalName()); + } + }); + for (AnnotatedClass cls : classes) { + if (!concerns(cls)) { + continue; + } + if (!isStereotyped(cls)) { + continue; + } + if (cls.isInterface() || cls.isAbstract()) { + ctx.error(cls, "A bean must be a concrete class; " + cls.getBinaryName() + + " is " + (cls.isInterface() ? "an interface" : "abstract") + + ". Annotate its implementation instead."); + continue; + } + if (isInnerClass(cls)) { + ctx.error(cls, cls.getSourceName() + " is an inner class, so it cannot be " + + "built without an instance of the class around it. Make it static."); + continue; + } + Bean bean = classBean(cls); + if (bean != null) { + add(bean); + } + } + // Factory methods second, so their owners exist. + for (Bean owner : new ArrayList(beans)) { + if (owner.cls == null) { + continue; + } + for (MethodInfo m : owner.cls.getMethods()) { + if (m.getAnnotation(BEAN) != null) { + Bean bean = factoryBean(owner, m); + if (bean != null) { + add(bean); + } + } + } + } + dropSteppedAside(); + checkUniqueNames(); + pickEntryPackage(); + } + + /// Whether the class belongs to this build: a source still backs it, and it + /// is not one of the classes the processors generate. + private boolean concerns(AnnotatedClass cls) { + if (cls.isSynthetic() || cls.getClassAnnotation(GENERATED) != null) { + return false; + } + return BuildHintAnnotationProcessor.hasBackingSource(cls, ctx.getCompileSourceRoots(), + ctx.getSourceEncoding()); + } + + private static boolean isStereotyped(AnnotatedClass cls) { + for (String s : STEREOTYPES) { + if (cls.getClassAnnotation(s) != null) { + return true; + } + } + return false; + } + + private static boolean isInnerClass(AnnotatedClass cls) { + for (FieldInfo f : cls.getFields()) { + if (f.getName().startsWith("this$") && (f.getAccess() & Opcodes.ACC_SYNTHETIC) != 0) { + return true; + } + } + return false; + } + + private void add(Bean bean) { + Bean clash = byName.get(bean.name); + if (clash != null) { + ctx.error(bean.cls != null ? bean.cls : bean.owner.cls, "Two beans are named \"" + + bean.name + "\": " + clash.describe() + " and " + bean.describe() + + ". Give one a name of its own in its annotation."); + return; + } + byName.put(bean.name, bean); + beans.add(bean); + } + + private Bean classBean(AnnotatedClass cls) { + Bean bean = new Bean(); + bean.cls = cls; + bean.type = cls.getInternalName(); + bean.name = explicitName(cls); + if (bean.name == null || bean.name.length() == 0) { + bean.name = decapitalize(RestClientAnnotationProcessor.simpleName( + cls.getBinaryName().replace('$', '.'))); + } + bean.controller = cls.getClassAnnotation(REST_CONTROLLER) != null; + bean.webSocket = cls.getClassAnnotation(WEBSOCKET_MAPPING) != null; + readModifiers(bean, cls.getClassAnnotations(), cls); + bean.types.addAll(assignableTypes(cls.getInternalName())); + if (!chooseConstructor(bean)) { + return null; + } + hierarchy(bean, cls, false); + AnnotationValues props = cls.getClassAnnotation(CONFIG_PROPERTIES); + if (props != null) { + bindProperties(bean, props, cls); + } + if (REQUEST.equals(bean.scope) || SESSION.equals(bean.scope)) { + // Inherited ones too: an @Async method a superclass declares queues a + // task holding the scoped instance just the same. + for (MethodInfo m : inheritedMembers(cls)) { + if (wovenAsync(cls, m)) { + // The task would hold the scoped instance after its request or + // session ended and destroyed it. A warning, as Spring runs it: + // the task calls the instance itself, destroyed or not. + String scope = REQUEST.equals(bean.scope) ? "request" : "session"; + ctx.getLog().warn("cn1: @Async method " + cls.getSourceName() + "." + m.getName() + + " is on a @" + (REQUEST.equals(bean.scope) ? "Request" : "Session") + + "Scope bean, which is destroyed when its " + scope + " ends -- " + + "possibly before the task runs. Move the method to a singleton " + + "and pass it what it needs."); + break; + } + } + } + collectJobs(bean); + collectTools(bean); + collectManaged(bean); + return bean; + } + + /// Walks `cls` and its superclasses, top first, collecting what each declares. + /// + /// @param lifecycleOnly only the @PostConstruct and @PreDestroy methods -- for + /// the object a @Bean method returns, which the build does not inject + private void hierarchy(Bean bean, AnnotatedClass cls, boolean lifecycleOnly) { + // Superclasses first, as Spring injects and initializes them: a field an + // abstract base declares @Autowired is as much a dependency as one the + // bean declares, and AnnotatedClass lists only DECLARED members. + List chain = new ArrayList(); + for (AnnotatedClass c = cls; c != null && chain.size() < 64; ) { + chain.add(c); + String sup = c.getSuperInternalName(); + c = sup == null || "java/lang/Object".equals(sup) ? null + : RestControllerAnnotationProcessor.resolveClass(ctx, sup); + } + Set fieldNames = new HashSet(); + for (int level = chain.size() - 1; level >= 0; level--) { + Set overridden = new HashSet(); + Set declaredBelow = new HashSet(); + for (int below = 0; below < level; below++) { + for (MethodInfo m : chain.get(below).getMethods()) { + if (m.isConstructor()) { + continue; + } + declaredBelow.add(m.getName() + m.getDescriptor()); + if (!m.isPrivate() && !m.isStatic()) { + overridden.add(m.getName() + m.getDescriptor()); + } + } + } + members(bean, cls, chain.get(level), level > 0 && !concerns(chain.get(level)), + overridden, declaredBelow, fieldNames, lifecycleOnly); + } + } + + /// The injection points and lifecycle methods one class of a bean's + /// hierarchy declares. + /// + /// @param unwoven the class is not rewritten by this build, so nothing that + /// needs a woven setter or bridge can be used from it + /// @param overridden methods a subclass redeclares, which are skipped here + /// @param declaredBelow every method a subclass declares, private ones too + private void members(Bean bean, AnnotatedClass cls, AnnotatedClass declaring, + boolean unwoven, Set overridden, Set declaredBelow, + Set fieldNames, boolean lifecycleOnly) { + for (FieldInfo f : lifecycleOnly ? new ArrayList() : declaring.getFields()) { + boolean autowired = f.getAnnotation(AUTOWIRED) != null; + AnnotationValues value = f.getAnnotation(VALUE); + if (!autowired && value == null) { + continue; + } + String where = "field " + f.getName() + " of " + declaring.getSourceName(); + if (f.isStatic()) { + ctx.error(cls, "The build injects instances, and " + where + " is static. " + + "Make it an instance field, or give the value to an instance."); + continue; + } + if (f.isFinal()) { + ctx.error(cls, where + " is final, so it can only be set by the constructor. " + + "Take it as a constructor parameter, or drop the final."); + continue; + } + if (!fieldNames.add(f.getName())) { + ctx.error(cls, where + " has the name of another injected field of " + + cls.getSourceName() + "'s class hierarchy, and the build injects a " + + "private field through a setter named after it. Rename one."); + continue; + } + if (unwoven) { + ctx.error(cls, cls.getSourceName() + " inherits " + where + ", which the build " + + "cannot inject: its class is not compiled from this project's " + + "sources. Take the dependency in " + cls.getSourceName() + + " instead."); + continue; + } + Point p = new Point(where, Type.getType(f.getDescriptor()), f.getSignature()); + readPoint(p, f.getAnnotations()); + bean.fields.put(f, p); + } + for (MethodInfo m : declaring.getMethods()) { + if (m.isConstructor() || overridden.contains(m.getName() + m.getDescriptor())) { + // An overriding method is the one that runs, and it carries its + // own annotations or none, as in Spring. + continue; + } + if (m.isPrivate() && callsFromWiring(m) + && declaredBelow.contains(m.getName() + m.getDescriptor())) { + // Both classes get a public bridge of the same signature, and the + // subclass's would override the base's -- running its own method + // twice and the base's never. + ctx.error(cls, declaring.getSourceName() + "." + m.getName() + " is private and " + + "a subclass in " + cls.getSourceName() + "'s hierarchy declares a " + + "method of the same signature, so the build cannot call both. " + + "Rename one."); + continue; + } + if (unwoven && !m.isPublic() && callsFromWiring(m)) { + ctx.error(cls, cls.getSourceName() + " inherits " + declaring.getSourceName() + + "." + m.getName() + ", which is not public and so needs a bridge " + + "the build can only add to a class compiled from this project's " + + "sources. Make it public."); + continue; + } + String where = declaring.getSourceName() + "." + m.getName(); + if (!lifecycleOnly && m.getAnnotation(AUTOWIRED) != null) { + if (m.isStatic()) { + ctx.error(cls, "@Autowired method " + where + " is static; the build " + + "injects instances."); + continue; + } + Call call = new Call(m); + Type[] args = Type.getArgumentTypes(m.getDescriptor()); + String[] generics = parameterSignatures(m); + // @Autowired(required = false) on the METHOD makes its arguments + // optional, as in Spring: the method is then not called unless + // they resolve. A parameter's own @Autowired still decides for it. + boolean methodRequired = m.getAnnotation(AUTOWIRED) + .getBoolOrDefault("required", true); + for (int i = 0; i < args.length; i++) { + Point p = new Point("parameter " + (i + 1) + " of " + where, args[i], + generics == null ? null : generics[i]); + p.required = methodRequired; + readPoint(p, parameterAnnotations(m, i)); + call.points.add(p); + } + bean.setters.add(call); + } + if (m.getAnnotation(POST_CONSTRUCT) != null + && lifecycle(declaring, m, "@PostConstruct")) { + bean.postConstruct.add(m); + } + if (m.getAnnotation(PRE_DESTROY) != null && lifecycle(declaring, m, "@PreDestroy")) { + bean.preDestroy.add(m); + } + } + } + + private boolean lifecycle(AnnotatedClass cls, MethodInfo m, String what) { + if (m.isStatic() || Type.getArgumentTypes(m.getDescriptor()).length != 0) { + ctx.error(cls, what + " method " + cls.getSourceName() + "." + m.getName() + + " must be an instance method that takes no arguments."); + return false; + } + if (returnsFuture(m)) { + // Spring ignores a lifecycle method's return value, and so does this -- + // but a Future means work still running when the call returns. + ctx.getLog().warn("cn1: " + what + " method " + cls.getSourceName() + "." + + m.getName() + " returns a Future, which is ignored: the server goes " + + "on -- " + ("@PostConstruct".equals(what) ? "to serve requests" + : "to tear down beans and close the database") + " -- when the method " + + "returns, not when that work finishes."); + } + return true; + } + + private static String explicitName(AnnotatedClass cls) { + for (String s : STEREOTYPES) { + AnnotationValues v = cls.getClassAnnotation(s); + if (v != null && v.getString("value") != null && !REST_CONTROLLER.equals(s) + && !WEBSOCKET_MAPPING.equals(s)) { + String name = v.getString("value").trim(); + if (name.length() > 0) { + return name; + } + } + } + return null; + } + + /// Scope, primary, lazy and the conditions, from a class's or a factory + /// method's annotations. + private void readModifiers(Bean bean, Map annotations, + AnnotatedClass where) { + AnnotationValues scope = annotations.get(SCOPE); + if (scope != null) { + String s = scope.getStringOrDefault("value", SINGLETON).trim(); + if (!SINGLETON.equals(s) && !PROTOTYPE.equals(s) && !REQUEST.equals(s) + && !SESSION.equals(s)) { + ctx.error(where, "@Scope(\"" + s + "\") on " + bean.name + " names no scope " + + "this runtime has; use singleton, prototype, request or session."); + } + bean.scope = s; + } + if (annotations.get(REQUEST_SCOPE) != null) { + bean.scope = REQUEST; + } + if (annotations.get(SESSION_SCOPE) != null) { + bean.scope = SESSION; + } + bean.primary = annotations.get(PRIMARY) != null; + AnnotationValues lazy = annotations.get(LAZY); + bean.lazy = lazy != null && lazy.getBoolOrDefault("value", true); + if (bean.lazy && !SINGLETON.equals(bean.scope)) { + // A request, session or prototype bean is already built on demand. + bean.lazy = false; + } + AnnotationValues missing = annotations.get(ON_MISSING); + bean.onMissing = missing != null; + if (missing != null && missing.get("value") instanceof List) { + for (Object o : (List) missing.get("value")) { + if (o instanceof Type) { + bean.missingTypes.add(((Type) o).getInternalName()); + } + } + } + AnnotationValues profile = annotations.get(PROFILE); + if (profile != null) { + List values = strings(profile.get("value")); + if (values.isEmpty()) { + ctx.error(where, "@Profile on " + bean.name + " names no profile."); + } + for (String v : values) { + String name = v.trim(); + if (name.startsWith("!")) { + name = name.substring(1).trim(); + } + if (name.length() == 0) { + // "!" negates nothing: compared with the active profile, the + // empty name never matches, so the bean was on under EVERY + // profile, production included. + ctx.error(where, "@Profile on " + bean.name + " has \"" + v + + "\", which names no profile."); + } + } + bean.profiles.add(values.toArray(new String[values.size()])); + } + AnnotationValues prop = annotations.get(ON_PROPERTY); + if (prop != null) { + List keys = strings(prop.get("value")); + keys.addAll(strings(prop.get("name"))); + String prefix = prop.getStringOrDefault("prefix", ""); + if (keys.isEmpty()) { + ctx.error(where, "@ConditionalOnProperty on " + bean.name + " names no key."); + } + for (String k : keys) { + String key = prefix.length() == 0 ? k : prefix + "." + k; + bean.propertyConditions.add(new String[] {key, + prop.getStringOrDefault("havingValue", ""), + String.valueOf(prop.getBoolOrDefault("matchIfMissing", false))}); + } + } + } + + private boolean chooseConstructor(Bean bean) { + AnnotatedClass cls = bean.cls; + List constructors = new ArrayList(); + List marked = new ArrayList(); + MethodInfo noArg = null; + for (MethodInfo m : cls.getMethods()) { + if (!m.isConstructor() || m.isSynthetic()) { + continue; + } + constructors.add(m); + if (m.getAnnotation(AUTOWIRED) != null) { + marked.add(m); + } + if (Type.getArgumentTypes(m.getDescriptor()).length == 0) { + noArg = m; + } + } + MethodInfo chosen; + if (marked.size() > 1) { + ctx.error(cls, cls.getSourceName() + " marks " + marked.size() + " constructors " + + "@Autowired; the build can call only one. Mark one."); + return false; + } else if (marked.size() == 1) { + chosen = marked.get(0); + } else if (constructors.size() == 1) { + chosen = constructors.get(0); + } else if (noArg != null) { + chosen = noArg; + } else { + ctx.error(cls, cls.getSourceName() + " has " + constructors.size() + + " constructors and none is marked @Autowired or takes no arguments, so " + + "the build cannot tell which one to call. Mark one @Autowired."); + return false; + } + bean.constructor = chosen; + Type[] args = Type.getArgumentTypes(chosen.getDescriptor()); + String[] generics = parameterSignatures(chosen); + for (int i = 0; i < args.length; i++) { + Point p = new Point("constructor parameter " + (i + 1) + " of " + cls.getSourceName(), + args[i], generics == null ? null : generics[i]); + readPoint(p, parameterAnnotations(chosen, i)); + bean.constructorPoints.add(p); + } + return true; + } + + private Bean factoryBean(Bean owner, MethodInfo m) { + AnnotatedClass cls = owner.cls; + String where = cls.getSourceName() + "." + m.getName(); + Type ret = Type.getReturnType(m.getDescriptor()); + if (ret.getSort() != Type.OBJECT && ret.getSort() != Type.ARRAY) { + ctx.error(cls, "@Bean method " + where + " returns " + ret.getClassName() + + "; a bean is an object."); + return null; + } + if (ret.getSort() == Type.ARRAY) { + ctx.error(cls, "@Bean method " + where + " returns an array; return a List or " + + "an object holding it."); + return null; + } + if (m.isAbstract()) { + ctx.error(cls, "@Bean method " + where + " is abstract."); + return null; + } + Bean bean = new Bean(); + AnnotationValues values = m.getAnnotation(BEAN); + String name = values.getStringOrDefault("value", "").trim(); + bean.name = name.length() > 0 ? name : m.getName(); + bean.type = ret.getInternalName(); + bean.owner = m.isStatic() ? null : owner; + bean.factory = m; + bean.cls = RestControllerAnnotationProcessor.resolveClass(ctx, bean.type); + bean.initMethod = emptyToNull(values.getString("initMethod")); + bean.destroyMethod = emptyToNull(values.getString("destroyMethod")); + readModifiers(bean, m.getAnnotations(), cls); + boolean scopedOwner = REQUEST.equals(owner.scope) || SESSION.equals(owner.scope); + if (!m.isStatic() && scopedOwner && !REQUEST.equals(bean.scope) + && !SESSION.equals(bean.scope)) { + // The factory is called through the owner, which is a request- or + // session-scoped stand-in -- and a bean that is not scoped itself is + // built at start-up, when there is no request or session to find the + // owner in. The server would refuse to start. + ctx.error(cls, "@Bean method " + where + " builds a " + bean.scope + " bean, but " + + "its configuration " + owner.describe() + " is " + owner.scope + + "-scoped, so there is no instance to call it on when the bean is built. " + + "Make the method static, or give the bean the configuration's scope."); + return null; + } + // A factory bean exists only when its configuration class does, as in + // Spring: otherwise a @Profile("prod") configuration's @Bean would be + // built on every profile -- through a null owner if the method is not + // static, and against configuration a static one was never meant to see. + bean.profiles.addAll(owner.profiles); + bean.propertyConditions.addAll(owner.propertyConditions); + bean.types.addAll(assignableTypes(bean.type)); + Type[] args = Type.getArgumentTypes(m.getDescriptor()); + String[] generics = parameterSignatures(m); + for (int i = 0; i < args.length; i++) { + Point p = new Point("parameter " + (i + 1) + " of @Bean " + where, args[i], + generics == null ? null : generics[i]); + readPoint(p, parameterAnnotations(m, i)); + bean.constructorPoints.add(p); + } + if (bean.cls != null && ctx.lookup(bean.type) != null) { + // The whole hierarchy, as for a class bean: an initializer or + // destructor the returned class inherits is as much its own. + hierarchy(bean, bean.cls, true); + // And its operational surface: a project class a factory builds is + // a bean like any other, so its @Scheduled jobs, @McpTool methods + // and @ManagedResource attributes must not silently disappear just + // because @Bean rather than a stereotype created it. + collectJobs(bean); + collectTools(bean); + collectManaged(bean); + } + AnnotationValues props = m.getAnnotation(CONFIG_PROPERTIES); + if (props != null) { + if (bean.cls == null) { + ctx.error(cls, "@ConfigurationProperties on " + where + " needs the returned " + + "class's setters, and " + bean.type.replace('/', '.') + + " is not on the build's classpath."); + } else { + bindProperties(bean, props, cls); + } + } + // The declaring class: a static @Bean method needs no instance of it, + // but it still lives there. + bean.factoryOwnerClass = cls; + return bean; + } + + private static String emptyToNull(String s) { + return s == null || s.trim().length() == 0 ? null : s.trim(); + } + + private void readPoint(Point p, Map annotations) { + if (annotations == null) { + return; + } + AnnotationValues q = annotations.get(QUALIFIER); + if (q != null) { + p.qualifier = q.getString("value"); + } + AnnotationValues a = annotations.get(AUTOWIRED); + if (a != null) { + p.required = a.getBoolOrDefault("required", true); + } + AnnotationValues v = annotations.get(VALUE); + if (v != null) { + p.value = v.getString("value"); + } + } + + private void bindProperties(Bean bean, AnnotationValues props, AnnotatedClass where) { + String prefix = props.getStringOrDefault("value", ""); + if (prefix.length() == 0) { + prefix = props.getStringOrDefault("prefix", ""); + } + while (prefix.endsWith(".")) { + prefix = prefix.substring(0, prefix.length() - 1); + } + bean.propertiesPrefix = prefix; + // ONE setter per property, as Spring Boot's binder picks one: the + // overload whose parameter is the getter's type, otherwise the first + // found (the subclass's before its base's). Every overload used to bind + // from the same key, one after the other, and the last to run decided. + Map byProperty = new LinkedHashMap(); + Set overridden = new HashSet(); + AnnotatedClass c = bean.cls; + while (c != null) { + for (MethodInfo m : c.getMethods()) { + if (!m.isPublic() || m.isStatic() || !m.getName().startsWith("set") + || m.getName().length() < 4) { + continue; + } + Type[] args = Type.getArgumentTypes(m.getDescriptor()); + if (args.length != 1 || !bindable(args[0])) { + continue; + } + if (!overridden.add(m.getName() + m.getDescriptor())) { + continue; // overridden below: already seen + } + MethodInfo current = byProperty.get(m.getName()); + if (current == null || (!matchesGetter(bean.cls, current) + && matchesGetter(bean.cls, m))) { + byProperty.put(m.getName(), m); + } + } + // Setters a library base class declares bind as well. + String parent = c.getSuperInternalName(); + c = parent == null || "java/lang/Object".equals(parent) ? null + : RestControllerAnnotationProcessor.resolveClass(ctx, parent); + } + bean.propertySetters.addAll(byProperty.values()); + if (bean.propertySetters.isEmpty()) { + ctx.error(where, "@ConfigurationProperties(\"" + prefix + "\") on " + bean.name + + " binds nothing: " + bean.type.replace('/', '.') + " has no public " + + "setter taking a String, a number, a boolean or an enum."); + } + } + + /// Whether `setter`'s parameter is the type its property's getter returns, + /// looking through `cls`'s superclasses. + private boolean matchesGetter(AnnotatedClass cls, MethodInfo setter) { + String property = setter.getName().substring(3); + Type arg = Type.getArgumentTypes(setter.getDescriptor())[0]; + AnnotatedClass c = cls; + for (int depth = 0; c != null && depth < 64; depth++) { + for (MethodInfo m : c.getMethods()) { + if (m.isStatic() || Type.getArgumentTypes(m.getDescriptor()).length != 0) { + continue; + } + if (("get" + property).equals(m.getName()) + || ("is" + property).equals(m.getName())) { + return Type.getReturnType(m.getDescriptor()).equals(arg); + } + } + String parent = c.getSuperInternalName(); + c = parent == null || "java/lang/Object".equals(parent) ? null + : RestControllerAnnotationProcessor.resolveClass(ctx, parent); + } + return false; + } + + /// Whether a configuration value can be converted to this type. + boolean bindable(Type t) { + switch (t.getSort()) { + case Type.BOOLEAN: + case Type.CHAR: + case Type.BYTE: + case Type.SHORT: + case Type.INT: + case Type.LONG: + case Type.FLOAT: + case Type.DOUBLE: + return true; + case Type.OBJECT: + String n = t.getInternalName(); + if (n.equals("java/lang/String") || n.equals("java/lang/Integer") + || n.equals("java/lang/Long") || n.equals("java/lang/Boolean") + || n.equals("java/lang/Double") || n.equals("java/lang/Float") + || n.equals("java/lang/Short") || n.equals("java/lang/Byte") + || n.equals("java/lang/Character")) { + return true; + } + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(ctx, n); + return c != null && c.isEnum(); + default: + return false; + } + } + + /// A `@ConditionalOnMissingBean` bean steps aside for any other bean of its type. + private void dropSteppedAside() { + List drop = new ArrayList(); + for (Bean b : beans) { + if (!b.onMissing) { + continue; + } + List wanted = stepAsideTypes(b); + for (Bean other : beans) { + if (other == b || other.onMissing) { + continue; + } + boolean replaces = false; + for (String t : wanted) { + if (other.types.contains(t)) { + replaces = true; + break; + } + } + if (replaces) { + drop.add(b); + break; + } + } + } + // A configuration that steps aside takes its @Bean methods with it, + // static ones included, as its @Profile and @ConditionalOnProperty + // already do: they are its declarations, and a non-static one would + // otherwise be built through an owner that no longer exists. + List withFactories = new ArrayList(drop); + for (Bean b : beans) { + if (b.factory == null || withFactories.contains(b)) { + continue; + } + for (Bean gone : drop) { + if (gone.cls != null && b.factoryOwnerClass == gone.cls) { + withFactories.add(b); + break; + } + } + } + for (Bean b : withFactories) { + beans.remove(b); + byName.remove(b.name); + ctx.getLog().info("cn1: " + b.describe() + (drop.contains(b) + ? " steps aside: another bean has its type" + : " steps aside with its configuration class")); + } + } + + /// MCP tools and managed resources are found by name alone, so two with one + /// name leave one unreachable -- which one depending on registration order. + /// Two that are always built are refused here; two conditional ones may be + /// meant for different profiles, and the server refuses them at start-up if + /// both turn out active. + private void checkUniqueNames() { + // Jobs are named after their class's simple name, which two packages can + // share -- and a manual trigger, the job listing and the metric label all + // pick a job by name. Clashing ones are named by the qualified class. + Map> jobs = new LinkedHashMap>(); + for (Bean b : beans) { + for (Job j : b.jobs) { + List same = jobs.get(j.name); + if (same == null) { + same = new ArrayList(); + jobs.put(j.name, same); + } + same.add(j); + } + } + for (List same : jobs.values()) { + if (same.size() > 1) { + for (Job j : same) { + j.name = j.ownerBinary.replace('$', '.') + "." + j.method.getName(); + } + } + } + // Still clashing: one class, two beans -- two @Bean factories of it -- and + // each schedules its own, as in Spring. Named by the bean then, or the + // second registration refused the start. + Map named = new LinkedHashMap(); + for (Bean b : beans) { + for (Job j : b.jobs) { + Integer n = named.get(j.name); + named.put(j.name, Integer.valueOf(n == null ? 1 : n.intValue() + 1)); + } + } + for (Bean b : beans) { + for (Job j : b.jobs) { + if (named.get(j.name).intValue() > 1) { + j.name = b.name + "." + j.method.getName(); + } + } + } + Map tools = new LinkedHashMap(); + Map managed = new LinkedHashMap(); + for (Bean b : beans) { + for (Tool t : b.tools) { + Bean other = tools.get(t.name); + // The same bean twice is always a clash; two beans only when + // they cannot be told apart by their conditions. + if (other != null && (other == b || !(other.isConditional() + && b.isConditional()))) { + ctx.error(b.cls, "Two @McpTool methods are named \"" + t.name + "\" -- in " + + other.describe() + " and " + b.describe() + " -- and a tool is " + + "called by name. Give one a distinct name with " + + "@McpTool(name = ...)."); + } else if (other == null) { + tools.put(t.name, b); + } + } + if (b.managed != null) { + Bean other = managed.get(b.managed.objectName); + if (other != null && !(other.isConditional() && b.isConditional())) { + ctx.error(b.cls, "Two @ManagedResource beans are named \"" + + b.managed.objectName + "\" -- " + other.describe() + " and " + + b.describe() + " -- and operations are invoked by that name. " + + "Set objectName on one."); + } else if (other == null) { + managed.put(b.managed.objectName, b); + } + } + } + } + + /// The types another bean must have for a @ConditionalOnMissingBean one to + /// step aside. By default every type it is injected as except the JDK's -- + /// the concrete class alone would never match the application's own + /// implementation of the interface the default provides, leaving both and + /// an ambiguous injection -- and java.* and javax.* types are left out so a + /// default that happens to be Closeable does not yield to every Closeable. + private static List stepAsideTypes(Bean b) { + if (!b.missingTypes.isEmpty()) { + return b.missingTypes; + } + List out = new ArrayList(); + for (String t : b.types) { + if (!t.startsWith("java/") && !t.startsWith("javax/")) { + out.add(t); + } + } + if (out.isEmpty()) { + // A JDK type returned by a @Bean method: its own type is all it has. + out.add(b.type); + } + return out; + } + + /// Where the entry point goes: the first controller's package, as it always + /// was, then the first endpoint's, then the first bean's. + private void pickEntryPackage() { + String best = null; + for (int pass = 0; pass < 3 && best == null; pass++) { + String first = null; + for (Bean b : beans) { + if (b.factory != null) { + continue; + } + boolean eligible = pass == 0 ? b.controller : pass == 1 ? b.webSocket : true; + String binary = b.type.replace('/', '.'); + if (eligible && (first == null || binary.compareTo(first) < 0)) { + first = binary; + } + } + if (first != null) { + best = RestClientAnnotationProcessor.packageOf(first); + } + } + entryPackage = best; + } + + /// Scheduled, tool and managed methods, for a bean class. + /** + * The methods of {@code cls} and of the superclasses it inherits them from, + * minus the ones a subclass overrides: a bean's @Scheduled, @McpTool and + * managed methods are its own whether it declares or inherits them, as its + * injection points and lifecycle methods already are. A non-public one in a + * class this build does not compile needs a bridge it cannot add, and is an + * error. + */ + private final Set reportedUnwoven = new HashSet(); + + /// Whether the bean has a method the build runs on an executor: `@Async` on + /// the method, or on the class for its public instance methods. + private boolean hasAsync(Bean b) { + if (b.cls == null) { + return false; + } + boolean onClass = b.cls.getClassAnnotation(ASYNC) != null; + for (MethodInfo m : inheritedMembers(b.cls)) { + if (m.getAnnotation(ASYNC) != null || (onClass && m.isPublic() && !m.isStatic() + && !m.isConstructor())) { + return true; + } + } + return false; + } + + private List inheritedMembers(AnnotatedClass cls) { + List out = new ArrayList(); + Set seen = new HashSet(); + AnnotatedClass c = cls; + for (int depth = 0; c != null && depth < 64; depth++) { + boolean unwoven = c != cls && !concerns(c); + for (MethodInfo m : c.getMethods()) { + if (m.isConstructor() || m.isStatic() && c != cls) { + continue; + } + String key = m.getName() + m.getDescriptor(); + if (!m.isPrivate() || c == cls) { + if (!seen.add(key)) { + continue; // overridden below + } + } else if (seen.contains(key)) { + continue; + } + if (c != cls && unwoven && !m.isPublic() && callsFromWiring(m)) { + if (!reportedUnwoven.add(cls.getInternalName() + " " + key)) { + continue; // said once, not per collector + } + ctx.error(cls, cls.getSourceName() + " inherits " + c.getSourceName() + "." + + m.getName() + ", which is not public and so needs a bridge the " + + "build can only add to a class compiled from this project's " + + "sources. Make it public."); + continue; + } + out.add(m); + } + String sup = c.getSuperInternalName(); + c = sup == null || "java/lang/Object".equals(sup) ? null + : RestControllerAnnotationProcessor.resolveClass(ctx, sup); + } + return out; + } + + /// Whether `m`, a member of `cls` or inherited by it, is woven to run as + /// an @Async task: annotated itself, or a public instance method of a class + /// annotated @Async. By the class that DECLARES it, exactly as + /// collectAspects() weaves -- a class-level annotation reaches only that + /// class's own methods, so an inherited method stays synchronous under a + /// subclass's @Async, and reporting it as asynchronous refused a valid bean. + private boolean wovenAsync(AnnotatedClass cls, MethodInfo m) { + if (m.getAnnotation(ASYNC) != null) { + return true; + } + if (!m.isPublic() || m.isStatic() || m.isConstructor() || m.isSynthetic()) { + return false; + } + AnnotatedClass declaring = declaringClass(cls, m); + return declaring != null && !declaring.isInterface() + && declaring.getClassAnnotation(ASYNC) != null; + } + + /// The class in `cls`'s superclass chain whose own methods include `m`, or + /// null when none does. + private AnnotatedClass declaringClass(AnnotatedClass cls, MethodInfo m) { + AnnotatedClass c = cls; + for (int depth = 0; c != null && depth < 64; depth++) { + for (MethodInfo own : c.getMethods()) { + if (own == m) { //NOPMD CompareObjectsWithEquals - the same parsed method, by identity + return c; + } + } + String sup = c.getSuperInternalName(); + c = sup == null || "java/lang/Object".equals(sup) ? null + : RestControllerAnnotationProcessor.resolveClass(ctx, sup); + } + return null; + } + + private void collectJobs(Bean bean) { + AnnotatedClass cls = bean.cls; + for (MethodInfo m : inheritedMembers(cls)) { + AnnotationValues s = m.getAnnotation(SCHEDULED); + if (s == null) { + continue; + } + String where = cls.getSourceName() + "." + m.getName(); + if (m.isStatic() || Type.getArgumentTypes(m.getDescriptor()).length != 0) { + ctx.error(cls, "@Scheduled method " + where + " must be an instance method " + + "that takes no arguments."); + continue; + } + if (wovenAsync(cls, m)) { + // The scheduler would call the stub, which returns once the body + // is queued: the run would count as over -- and a lock be + // released -- while it is still going, so runs could overlap. + ctx.error(cls, "@Scheduled method " + where + " is also @Async. A scheduled " + + "run already happens off the request thread, on the executor " + + "@Scheduled names, and it must end when its work does; drop " + + "@Async and pick the thread with @Scheduled(thread = ...)."); + continue; + } + if (returnsFuture(m)) { + // Spring ignores a scheduled method's return value, and so does + // this -- but a Future means work that outlives the run, so the + // run ends, and its lock is released, while that work goes on. + ctx.getLog().warn("cn1: @Scheduled method " + where + " returns a Future, " + + "which is ignored: the run ends -- and its lock is released -- " + + "when the method returns, not when that work does."); + } + Job job = new Job(m, RestClientAnnotationProcessor.simpleName( + cls.getBinaryName().replace('$', '.')) + "." + m.getName()); + job.ownerBinary = cls.getBinaryName(); + job.cron = emptyToNull(s.getString("cron")); + job.zone = s.getStringOrDefault("zone", "").trim(); + job.fixedRate = longOf(s.get("fixedRate")); + job.fixedDelay = longOf(s.get("fixedDelay")); + job.initialDelay = longOf(s.get("initialDelay")); + job.fixedRateText = emptyToNull(s.getString("fixedRateString")); + job.fixedDelayText = emptyToNull(s.getString("fixedDelayString")); + job.initialDelayText = emptyToNull(s.getString("initialDelayString")); + job.thread = enumName(s.get("thread"), "PLATFORM"); + job.executor = s.getStringOrDefault("executor", ""); + job.lock = s.getStringOrDefault("lock", ""); + job.lockAtMostFor = longOf(s.get("lockAtMostFor")); + int kinds = (job.cron != null ? 1 : 0) + + (job.fixedRate > 0 || job.fixedRateText != null ? 1 : 0) + + (job.fixedDelay > 0 || job.fixedDelayText != null ? 1 : 0); + if (kinds != 1) { + ctx.error(cls, "@Scheduled on " + where + " must give exactly one of cron, " + + "fixedRate and fixedDelay; it gives " + kinds + "."); + continue; + } + if (job.fixedRateText != null && job.fixedRate > 0 + || job.fixedDelayText != null && job.fixedDelay > 0) { + ctx.error(cls, "@Scheduled on " + where + " gives a period both as a number " + + "and as text; keep one."); + continue; + } + if (job.cron != null && job.cron.indexOf("${") < 0) { + try { + job.masks = CronCompiler.compile(job.cron); + } catch (IllegalArgumentException err) { + ctx.error(cls, "@Scheduled on " + where + ": " + err.getMessage() + "."); + continue; + } + } + if (job.cron == null && job.zone.length() > 0) { + ctx.error(cls, "@Scheduled on " + where + " sets a zone, which only a cron " + + "expression uses."); + continue; + } + if (!CronCompiler.knownZone(job.zone)) { + ctx.error(cls, "@Scheduled on " + where + " names time zone \"" + job.zone + + "\", which is not a zone ID, UTC, or an offset such as +02:00."); + continue; + } + if (job.lock.length() > 0) { + needsDatabase = true; + } + bean.jobs.add(job); + } + } + + private void collectTools(Bean bean) { + AnnotatedClass cls = bean.cls; + for (MethodInfo m : inheritedMembers(cls)) { + AnnotationValues t = m.getAnnotation(MCP_TOOL); + if (t == null) { + continue; + } + String where = cls.getSourceName() + "." + m.getName(); + if (m.isStatic()) { + ctx.error(cls, "@McpTool method " + where + " must be an instance method."); + continue; + } + if (wovenAsync(cls, m)) { + // The adapter would call the stub and receive the queued task at + // once, and the agent would get neither the value nor the failure. + ctx.error(cls, "@McpTool method " + where + " is also @Async. A tool call " + + "answers with what the method returns, so it must run to the " + + "end; drop @Async, or have the tool start the work and return an " + + "id to ask about it by."); + continue; + } + if (returnsFuture(m)) { + // Not only a woven stub's: a Future from any executor or API is + // written as its toString() while it is still pending, a success + // with a bogus body and its failure lost -- as a route refuses. + ctx.error(cls, "@McpTool method " + where + " returns a Future. A tool call " + + "answers with what the method returns, so it would send the " + + "pending task, not its result; return the value, or start the work " + + "and return an id to ask about it by."); + continue; + } + String name = t.getStringOrDefault("name", "").trim(); + Tool tool = new Tool(m, name.length() > 0 ? name : m.getName(), + t.getStringOrDefault("description", "")); + if (!tool.name.matches("[A-Za-z0-9_.-]{1,128}")) { + ctx.error(cls, "@McpTool on " + where + " is named \"" + tool.name + "\"; a " + + "tool name is letters, digits, _, - and . only."); + continue; + } + Type[] args = Type.getArgumentTypes(m.getDescriptor()); + boolean ok = true; + for (int i = 0; i < args.length; i++) { + AnnotationValues p = parameterAnnotations(m, i).get(MCP_PARAM); + if (p == null) { + ctx.error(cls, "Parameter " + (i + 1) + " of @McpTool " + where + " has no " + + "@McpParam. A Java parameter name does not survive compilation, " + + "so the tool's argument needs one to be called by."); + ok = false; + continue; + } + if (!toolArgument(args[i])) { + ctx.error(cls, "Parameter " + (i + 1) + " of @McpTool " + where + " is a " + + args[i].getClassName() + "; a tool argument is a String, a " + + "number, a boolean, an enum, a Map or a List."); + ok = false; + continue; + } + String paramName = p.getString("value"); + if (paramName == null || paramName.trim().length() == 0 + || tool.paramNames.contains(paramName)) { + // One JSON property per name: a second with the same name + // would overwrite the first in the schema and be read for both. + ctx.error(cls, "Parameter " + (i + 1) + " of @McpTool " + where + " is " + + (paramName == null || paramName.trim().length() == 0 + ? "named nothing" : "named \"" + paramName + "\" like another") + + "; each argument needs a name of its own."); + ok = false; + continue; + } + tool.paramNames.add(paramName); + tool.paramDescriptions.add(p.getStringOrDefault("description", "")); + tool.paramRequired.add(Boolean.valueOf(p.getBoolOrDefault("required", true))); + } + if (ok) { + bean.tools.add(tool); + } + } + } + + boolean toolArgument(Type t) { + if (bindable(t)) { + return true; + } + if (t.getSort() != Type.OBJECT) { + return false; + } + String n = t.getInternalName(); + return n.equals("java/util/Map") || n.equals("java/util/List") + || n.equals("java/util/Collection") || n.equals("java/lang/Object"); + } + + private void collectManaged(Bean bean) { + AnnotatedClass cls = bean.cls; + AnnotationValues resource = cls.getClassAnnotation(MANAGED_RESOURCE); + boolean any = resource != null; + for (MethodInfo m : inheritedMembers(cls)) { + if (m.getAnnotation(MANAGED_ATTRIBUTE) != null + || m.getAnnotation(MANAGED_OPERATION) != null) { + if (resource == null) { + ctx.error(cls, cls.getSourceName() + "." + m.getName() + " is managed, but " + + "its class has no @ManagedResource."); + return; + } + } + } + if (!any) { + return; + } + if (!SINGLETON.equals(bean.scope)) { + // Its gauges are read by the metrics exporter and its operations by + // the management endpoint, on threads with no request or session to + // find a scoped instance in -- every read would fail and the gauge + // would silently export nothing. A prototype has no one instance. + ctx.error(cls, cls.getSourceName() + " is a @ManagedResource with scope " + + bean.scope + ". A managed resource is read outside any request, so it " + + "must be a singleton; keep the per-" + bean.scope + " state in a " + + "singleton it reports on."); + return; + } + Managed managed = new Managed(); + String objectName = resource.getStringOrDefault("objectName", "").trim(); + managed.objectName = objectName.length() > 0 ? objectName + : RestClientAnnotationProcessor.simpleName(cls.getBinaryName().replace('$', '.')); + managed.description = resource.getStringOrDefault("description", ""); + if (!managed.objectName.matches("[A-Za-z0-9_.-]{1,128}")) { + // It is one segment of /manage/managed/{bean}/{operation}, matched + // without decoding: a '/' makes two segments, and anything escaped + // never matches -- listed, and never invocable. + ctx.error(cls, "@ManagedResource on " + cls.getSourceName() + " is named \"" + + managed.objectName + "\"; an objectName is letters, digits, _, - and . " + + "only, since it is a segment of the management URL."); + return; + } + for (MethodInfo m : inheritedMembers(cls)) { + String where = cls.getSourceName() + "." + m.getName(); + AnnotationValues attr = m.getAnnotation(MANAGED_ATTRIBUTE); + if (attr != null) { + Type ret = Type.getReturnType(m.getDescriptor()); + if (m.isStatic() || Type.getArgumentTypes(m.getDescriptor()).length != 0 + || ret.getSort() == Type.VOID || ret.getSort() == Type.ARRAY) { + ctx.error(cls, "@ManagedAttribute " + where + " must be an instance getter " + + "that takes no arguments and returns a value."); + continue; + } + if (returnsFuture(m) || wovenAsync(cls, m)) { + // Read and written as its value: a pending task -- a woven + // @Async getter's, or any Future -- came out as the task's + // toString(), a successful reading of nothing, its failure lost. + ctx.error(cls, "@ManagedAttribute " + where + " returns a Future or is " + + "@Async. An attribute is read as the value its getter returns, so " + + "the reader would get a pending task; return the value itself."); + continue; + } + if (managed.attributeNames.contains(attributeName(m.getName()))) { + ctx.error(cls, "@ManagedAttribute " + where + " has the attribute name " + + attributeName(m.getName()) + ", which another getter of " + + cls.getSourceName() + " already reports under."); + continue; + } + managed.attributes.add(m); + managed.attributeNames.add(attributeName(m.getName())); + managed.attributeDescriptions.add(attr.getStringOrDefault("description", "")); + managed.attributeUnits.add(attr.getStringOrDefault("unit", "")); + } + AnnotationValues op = m.getAnnotation(MANAGED_OPERATION); + if (op != null) { + if (m.isStatic()) { + ctx.error(cls, "@ManagedOperation " + where + " must be an instance method."); + continue; + } + if (returnsFuture(m)) { + // The management endpoint answers with the result, so an @Async + // one's queued task -- or any pending Future -- was sent as its + // toString(), a success while the work still ran. + ctx.error(cls, "@ManagedOperation " + where + " returns a Future. An " + + "operation answers with what the method returns, so the caller " + + "would get the pending task, not its result; return the value, " + + "or return nothing and let @Async run it in the background."); + continue; + } + Type[] args = Type.getArgumentTypes(m.getDescriptor()); + List names = new ArrayList(); + boolean ok = true; + for (int i = 0; i < args.length; i++) { + if (!bindable(args[i])) { + ctx.error(cls, "Parameter " + (i + 1) + " of @ManagedOperation " + where + + " is a " + args[i].getClassName() + "; an operation takes " + + "strings, numbers, booleans and enums."); + ok = false; + } + AnnotationValues named = parameterAnnotations(m, i).get(MCP_PARAM); + String paramName = named != null ? named.getString("value") : "arg" + i; + if (paramName == null || paramName.trim().length() == 0 + || names.contains(paramName)) { + ctx.error(cls, "Parameter " + (i + 1) + " of @ManagedOperation " + + where + " needs a name of its own: arguments are passed " + + "by name."); + ok = false; + } + names.add(paramName); + } + for (MethodInfo other : managed.operations) { + if (other.getName().equals(m.getName())) { + // Management and MCP name an operation by its method + // name alone, so an overload could never be reached. + ctx.error(cls, "@ManagedOperation " + where + " is overloaded; an " + + "operation is invoked by name, so only one method of " + + "that name can be one. Rename the others."); + ok = false; + break; + } + } + if (ok) { + managed.operations.add(m); + managed.operationDescriptions.add(op.getStringOrDefault("description", "")); + managed.operationParams.add(names); + } + } + } + bean.managed = managed; + } + + /// Future types the JDK and the backend provide, which the class index + /// cannot look inside. + private static final Set FUTURE_TYPES = new HashSet(Arrays.asList( + "java/util/concurrent/Future", "java/util/concurrent/RunnableFuture", + "java/util/concurrent/ScheduledFuture", "java/util/concurrent/RunnableScheduledFuture", + "java/util/concurrent/FutureTask", "java/util/concurrent/CompletableFuture", + "java/util/concurrent/ForkJoinTask", "com/codename1/backend/AsyncResult", + "com/codename1/backend/AsyncTask")); + + /// Whether `m` returns a Future: declared as one, or a type implementing it. + private boolean returnsFuture(MethodInfo m) { + Type ret = Type.getReturnType(m.getDescriptor()); + return ret.getSort() == Type.OBJECT + && isFutureType(ret.getInternalName(), new HashSet()); + } + + private boolean isFutureType(String internal, Set seen) { + if (internal == null || !seen.add(internal)) { + return false; + } + if (FUTURE_TYPES.contains(internal)) { + return true; + } + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(ctx, internal); + if (c == null) { + return false; + } + for (String i : c.getInterfaceInternalNames()) { + if (isFutureType(i, seen)) { + return true; + } + } + return isFutureType(c.getSuperInternalName(), seen); + } + + static String attributeName(String getter) { + String base = getter; + if (getter.startsWith("get") && getter.length() > 3) { + base = getter.substring(3); + } else if (getter.startsWith("is") && getter.length() > 2) { + base = getter.substring(2); + } + return decapitalize(base); + } + + // ---------------------------------------------------------------- resolve + + private void resolve() { + // Names for the generated code, once the set of beans is final. + Set used = new LinkedHashSet(); + for (Bean b : beans) { + String base = "b_" + identifier(b.name); + String var = base; + int n = 2; + while (!used.add(var)) { + var = base + n++; + } + b.var = var; + if (REQUEST.equals(b.scope)) { + b.slot = requestSlots++; + } else if (SESSION.equals(b.scope)) { + b.slot = sessionSlots++; + } else if (b.lazy) { + b.slot = lazySlots++; + } + } + for (Bean b : beans) { + checkAccessibility(b); + AnnotatedClass where = b.cls != null && ctx.lookup(b.type) != null ? b.cls + : b.factoryOwnerClass; + for (Point p : b.constructorPoints) { + resolvePoint(b, p, where); + } + for (Point p : b.fields.values()) { + resolvePoint(b, p, where); + } + for (Call c : b.setters) { + for (Point p : c.points) { + resolvePoint(b, p, where); + } + } + if (b.isProxied()) { + checkProxyable(b); + } + if (b.webSocket && !SINGLETON.equals(b.scope)) { + ctx.error(b.cls, "Websocket endpoint " + b.describe() + " is " + + b.scope + "-scoped; an endpoint serves many connections for the " + + "life of the server, so it must be a singleton."); + } + if (!b.jobs.isEmpty() && (REQUEST.equals(b.scope) || SESSION.equals(b.scope))) { + ctx.error(b.cls, b.describe() + " has @Scheduled methods but is " + b.scope + + "-scoped; a job runs outside any request."); + } + if (!b.jobs.isEmpty() && PROTOTYPE.equals(b.scope)) { + // A warning, as Spring runs it: its post-processor schedules the + // instance it is given and never destroys a prototype either. But + // not silent -- the one instance built for the jobs runs them for + // the life of the server, like a singleton, and its @PreDestroy or + // destroy method is never called. + ctx.getLog().warn("cn1: " + b.describe() + " has @Scheduled methods but is " + + "prototype-scoped. One instance is built to run its jobs and keeps " + + "running them for the life of the server, and a prototype is never " + + "destroyed, so its @PreDestroy never runs. Make it a singleton if " + + "that is what it is."); + } + } + // Every candidate is resolved now, so what runs outside any HTTP request + // can be checked through the whole graph: a websocket callback, a + // scheduled run and a managed-attribute read all happen on threads with no + // request current, and a request- or session-scoped stand-in reached from + // there -- directly, or through a singleton that injects it -- throws on + // first use. + // + // A WARNING, as Spring has it: Spring starts such an application and + // the scoped proxy throws IllegalStateException only when it is really + // used with no request current, which the generated stand-in does too. + // Reachability through the graph is an over-approximation -- the job may + // never call the method that touches the scoped bean -- so refusing the + // build would reject programs Spring runs correctly. + for (Bean b : beans) { + boolean async = hasAsync(b); + String role = b.webSocket ? "Websocket endpoint" + : !b.jobs.isEmpty() ? "Bean with @Scheduled methods" + : b.managed != null ? "@ManagedResource bean" + : async ? "Bean with @Async methods" : null; + if (role == null || b.cls == null) { + continue; + } + List path = scopedReach(b, new ArrayList(), new HashSet()); + if (path == null) { + continue; + } + Bean d = path.get(path.size() - 1); + StringBuilder via = new StringBuilder(); + for (int i = 1; i < path.size() - 1; i++) { + via.append(i == 1 ? " through " : " -> ").append(path.get(i).describe()); + } + String outside = b.webSocket ? "A websocket callback runs" + : !b.jobs.isEmpty() ? "A scheduled job runs" + : b.managed != null ? "A managed attribute is read" + : "An @Async call runs on an executor, even one a request made,"; + ctx.getLog().warn("cn1: " + role + " " + b.describe() + " reaches " + d.describe() + + via + ", which is " + d.scope + "-scoped. " + outside + " outside any " + + "HTTP request, where there is no " + d.scope + " to find it in, and using " + + "it there throws IllegalStateException. Inject a singleton instead" + + (b.webSocket ? ", and keep per-connection state in the " + + "WebSocketSession's attachment" : "") + ", unless that path never " + + "touches it."); + } + if (!ctx.hasErrors()) { + orderConstruction(); + } + } + + /// The path from `from` to the first request- or session-scoped bean it + /// injects, directly or through other beans; null when there is none. + private List scopedReach(Bean from, List path, Set seen) { + if (!seen.add(from)) { + return null; + } + path.add(from); + if (path.size() > 1 && (REQUEST.equals(from.scope) || SESSION.equals(from.scope))) { + return path; + } + List all = new ArrayList(from.constructorPoints); + all.addAll(from.fields.values()); + for (Call c : from.setters) { + all.addAll(c.points); + } + for (Point p : all) { + for (Bean d : p.candidates) { + List found = scopedReach(d, path, seen); + if (found != null) { + return found; + } + } + } + path.remove(path.size() - 1); + return null; + } + + /// The generated wiring names the bean's type, so the type must be visible + /// from the entry package. + private void checkAccessibility(Bean b) { + if (b.cls == null || entryPackage == null) { + return; + } + String pkg = RestClientAnnotationProcessor.packageOf(b.type.replace('/', '.')); + if (!b.cls.isAccessibleFromAnywhere() && !pkg.equals(entryPackage)) { + AnnotatedClass where = ctx.lookup(b.type) != null ? b.cls : b.factoryOwnerClass; + ctx.error(where, "Bean " + b.describe() + " is not public, and the generated " + + "entry point is in package " + (entryPackage.length() == 0 ? "(default)" + : entryPackage) + ", where it cannot be named. Make the class public" + + (b.type.indexOf('$') >= 0 ? ", along with the classes it is nested in" + : "") + "."); + } + } + + private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { + if (p.value != null) { + if (!bindable(p.type)) { + ctx.error(where, "@Value on " + p.where + " cannot convert text to " + + p.type.getClassName() + "; use a String, a number, a boolean or an " + + "enum."); + } + warnIfUnset(p, where); + return; + } + if (p.type.getSort() != Type.OBJECT) { + ctx.error(where, p.where + " is a " + p.type.getClassName() + ", which no bean " + + "can be. Give it @Value(\"${some.key}\") to read it from configuration."); + return; + } + String type = p.type.getInternalName(); + if (CONFIG_TYPE.equals(type)) { + p.builtin = "config"; + return; + } + if (DATASOURCE_TYPE.equals(type)) { + p.builtin = "dataSource"; + needsDatabase = true; + return; + } + if (ENTITIES_TYPE.equals(type)) { + p.builtin = "entities"; + needsDatabase = true; + needsEntities = true; + return; + } + if (SESSION_TYPE.equals(type)) { + p.builtin = "session"; + needsDatabase = true; + needsEntities = true; + needsSession = true; + return; + } + if (REQUEST_TYPE.equals(type) || HTTP_SESSION_TYPE.equals(type)) { + if (!REQUEST.equals(owner.scope) && !SESSION.equals(owner.scope)) { + ctx.error(where, p.where + " asks for the current " + + (REQUEST_TYPE.equals(type) ? "request" : "session") + ", which only " + + "a @RequestScope or @SessionScope bean has. Take it as a parameter of " + + "the handler method instead, or scope the bean."); + return; + } + if (SESSION.equals(owner.scope)) { + // Both are the objects of the request that BUILT the bean. The + // request is gone when it ends; the session is a per-request copy + // with the database store, so the bean would read stale + // attributes and write to a copy nobody saves. + ctx.error(where, p.where + " asks for the " + (REQUEST_TYPE.equals(type) + ? "request" : "session") + ", but a session bean outlives the request " + + "that built it and the copy of the session that request loaded. " + + "Read the current one when it is needed: " + + "Backend.currentRequest().getSession(true)."); + return; + } + p.builtin = REQUEST_TYPE.equals(type) ? "request" : "httpSession"; + return; + } + if (("java/util/List".equals(type) || "java/util/Collection".equals(type)) + && p.genericType != null) { + String element = elementType(p.genericType); + if (element != null) { + p.list = true; + for (Bean b : beans) { + if (b != owner && b.types.contains(element) + && (p.qualifier == null || p.qualifier.equals(b.name))) { + p.candidates.add(b); + } + } + return; + } + } + List matches = new ArrayList(); + for (Bean b : beans) { + if (b != owner && b.types.contains(type)) { + matches.add(b); + } + } + if (p.qualifier != null) { + List named = new ArrayList(); + for (Bean b : matches) { + if (b.name.equals(p.qualifier)) { + named.add(b); + } + } + if (named.isEmpty() && p.required) { + ctx.error(where, p.where + " asks for the bean named \"" + p.qualifier + + "\" of type " + p.type.getClassName() + ", and there is none" + + (matches.isEmpty() ? "" : "; the beans of that type are " + + names(matches)) + "."); + return; + } + matches = named; + } + if (matches.isEmpty()) { + if (p.required) { + ctx.error(where, p.where + " needs a " + p.type.getClassName() + ", and no " + + "bean has that type. Annotate the implementing class @Component, " + + "@Service or @Repository, or declare a @Bean method returning one."); + } + return; + } + if (matches.size() > 1) { + List primaries = new ArrayList(); + for (Bean b : matches) { + if (b.primary) { + primaries.add(b); + } + } + if (primaries.size() == 1 && primaries.get(0).isConditional()) { + // A conditional @Primary may not exist at run time -- a prod-only + // primary on a dev profile -- and then the others are the answer. + // Discarding them here made that profile fail to start. + Bean primary = primaries.get(0); + List ordered = new ArrayList(); + ordered.add(primary); + int unconditional = 0; + for (Bean b : matches) { + if (b != primary) { + ordered.add(b); + if (!b.isConditional()) { + unconditional++; + } + } + } + if (unconditional > 1) { + ctx.error(where, p.where + " could receive any of " + names(ordered) + + " whenever the @Primary " + primary.describe() + " is not " + + "active. Mark the rest conditional, or name one with @Qualifier."); + return; + } + matches = ordered; + p.choice = true; + p.preferFirst = true; + } else if (primaries.size() == 1) { + matches = primaries; + } else if (primaries.size() > 1) { + ctx.error(where, p.where + " could receive any of " + names(primaries) + + ", which are all @Primary. Keep one, or name one with @Qualifier."); + return; + } else { + boolean allConditional = true; + for (Bean b : matches) { + allConditional &= b.isConditional(); + } + if (!allConditional) { + ctx.error(where, p.where + " could receive any of " + names(matches) + + ". Mark one @Primary, or name one with @Qualifier."); + return; + } + p.choice = true; + } + } + // Candidates that are all conditional are accepted here and checked at + // START, as Spring checks them: whether @Profile or @ConditionalOnProperty + // holds is a fact of the deployment, not of the build, and + // Wiring.single() refuses the start naming this injection point when none + // is on. Proving at build time that every combination of profiles and + // properties is covered is not attempted -- Spring does not either. + p.candidates.addAll(matches); + } + + private void warnIfUnset(Point p, AnnotatedClass where) { + String expression = p.value; + int open = expression.indexOf("${"); + while (open >= 0) { + int close = expression.indexOf('}', open); + if (close < 0) { + ctx.error(where, "@Value on " + p.where + " has an unterminated ${ in \"" + + expression + "\"."); + return; + } + String inner = expression.substring(open + 2, close); + if (inner.indexOf(':') < 0 && !RestControllerAnnotationProcessor + .applicationPropertyKnown(ctx, inner.trim())) { + ctx.getLog().warn("cn1: @Value on " + p.where + " reads \"" + inner.trim() + + "\", which application.properties does not set and which has no " + + "fallback; the server refuses to start unless the environment " + + "sets it."); + } + open = expression.indexOf("${", close); + } + } + + private static String names(List list) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(list.get(i).describe()); + } + return sb.toString(); + } + + private void checkProxyable(Bean b) { + String what = b.lazy ? "@Lazy" : "@" + (REQUEST.equals(b.scope) ? "Request" : "Session") + + "Scope"; + AnnotatedClass where = ctx.lookup(b.type) != null ? b.cls : b.factoryOwnerClass; + if (b.cls == null) { + ctx.error(where, what + " bean " + b.describe() + " is reached through a class " + + "the build generates to stand in for it, which extends its type, and " + + "that type is not on the build's classpath."); + return; + } + if (b.cls.isFinal() || b.cls.isInterface()) { + ctx.error(where, what + " bean " + b.describe() + " is reached through a class " + + "the build generates to stand in for it, which extends it -- so it " + + "cannot be " + (b.cls.isFinal() ? "final" : "an interface") + "."); + return; + } + MethodInfo noArg = null; + for (MethodInfo m : b.cls.getMethods()) { + if (m.isConstructor() && Type.getArgumentTypes(m.getDescriptor()).length == 0 + && !m.isPrivate()) { + noArg = m; + } + } + if (noArg == null) { + ctx.error(where, what + " bean " + b.describe() + " is reached through a class " + + "the build generates to stand in for it, which extends it and so must " + + "call one of its constructors. Give it a constructor that takes no " + + "arguments (it may be protected), and inject the rest with fields."); + return; + } + String unreadable = unreadableAncestor(b.cls); + if (unreadable != null) { + ctx.error(where, what + " bean " + b.describe() + " extends " + unreadable + + ", which is not on the build's classpath, so the stand-in the build " + + "generates cannot forward the methods it inherits from it."); + return; + } + for (MethodInfo m : proxiedMethods(b.cls, true)) { + if (m.isFinal()) { + ctx.error(where, what + " bean " + b.describe() + " has final method " + + m.getName() + ", which the stand-in cannot forward: a call to it " + + "would run on the stand-in rather than the bean. Drop the final."); + } + } + String pkg = RestClientAnnotationProcessor.packageOf(b.type.replace('/', '.')); + String name = qualify(pkg, baseName(b.type) + (b.lazy ? "Cn1Lazy" : "Cn1Scoped")); + // Per bean, not per type: two @Bean methods of one class, both scoped or + // lazy, each need their own stand-in -- sharing the name, the second + // source replaced the first and both injections built the second bean. + String unique = name; + for (int n = 2 ; !proxyNames.add(unique) ; n++) { + unique = name + n; + } + b.proxyBinary = unique; + } + + /// Stand-in class names already given out, so two beans never share one. + private final Set proxyNames = new HashSet(); + + /// The overridable methods of a class and its project superclasses. + List proxiedMethods(AnnotatedClass cls, boolean includeFinal) { + List out = new ArrayList(); + Set seen = new LinkedHashSet(); + AnnotatedClass c = cls; + String pkg = RestClientAnnotationProcessor.packageOf(cls.getBinaryName()); + while (c != null) { + boolean samePackage = RestClientAnnotationProcessor.packageOf(c.getBinaryName()) + .equals(pkg); + for (MethodInfo m : c.getMethods()) { + if (m.isConstructor() || m.isStatic() || m.isPrivate() || m.isSynthetic() + || "".equals(m.getName()) + || BackendWeaver.isBody(m.getName()) + || m.getName().startsWith(BackendWeaver.BRIDGE_PREFIX)) { + continue; + } + boolean packagePrivate = (m.getAccess() & (Opcodes.ACC_PUBLIC + | Opcodes.ACC_PROTECTED)) == 0; + if (packagePrivate && !samePackage) { + continue; + } + if (!seen.add(m.getName() + m.getDescriptor().substring(0, + m.getDescriptor().indexOf(')') + 1))) { + continue; + } + if (m.isFinal() && !includeFinal) { + continue; + } + out.add(m); + } + // On through the compile classpath, not just this project's classes: + // a public method a library base class declares is called on the + // stand-in too, and unforwarded it would run on the stand-in's own, + // never-initialized state instead of the scoped bean's. + String parent = c.getSuperInternalName(); + c = parent == null || "java/lang/Object".equals(parent) ? null + : RestControllerAnnotationProcessor.resolveClass(ctx, parent); + } + return out; + } + + /// The first superclass of `cls` the build cannot read, or null. + private String unreadableAncestor(AnnotatedClass cls) { + AnnotatedClass c = cls; + while (c != null) { + String parent = c.getSuperInternalName(); + if (parent == null || "java/lang/Object".equals(parent)) { + return null; + } + AnnotatedClass next = RestControllerAnnotationProcessor.resolveClass(ctx, parent); + if (next == null) { + return parent.replace('/', '.'); + } + c = next; + } + return null; + } + + /// The eager singletons in dependency order, through constructor and factory + /// dependencies; prototypes are placed so what they need is built first. + private void orderConstruction() { + Map state = new LinkedHashMap(); + for (Bean b : beans) { + if (b.isEager() || PROTOTYPE.equals(b.scope)) { + visit(b, state, new ArrayList()); + if (ctx.hasErrors()) { + return; + } + } + } + } + + private void visit(Bean b, Map state, List path) { + Integer s = state.get(b); + if (s != null && s.intValue() == 2) { + return; + } + if (s != null && s.intValue() == 1) { + StringBuilder cycle = new StringBuilder(); + int from = path.indexOf(b); + for (int i = from; i < path.size(); i++) { + cycle.append(path.get(i).name).append(" -> "); + } + cycle.append(b.name); + AnnotatedClass where = b.cls != null && ctx.lookup(b.type) != null ? b.cls + : b.factoryOwnerClass; + ctx.error(where, "The constructors form a cycle: " + cycle + ". Nothing can be " + + "built first. Inject one of them through an @Autowired field or setter, " + + "which the build sets after every bean is constructed."); + return; + } + state.put(b, Integer.valueOf(1)); + path.add(b); + List deps = new ArrayList(); + if (b.owner != null) { + deps.add(b.owner); + } + for (Point p : b.constructorPoints) { + deps.addAll(p.candidates); + } + if (PROTOTYPE.equals(b.scope)) { + // Built at each injection point, fields included, so everything it + // injects has to exist when the first of them is built. + for (Point p : b.fields.values()) { + deps.addAll(p.candidates); + } + for (Call c : b.setters) { + for (Point p : c.points) { + deps.addAll(p.candidates); + } + } + } + for (Bean d : deps) { + if (d.isEager() || PROTOTYPE.equals(d.scope)) { + visit(d, state, path); + if (ctx.hasErrors()) { + return; + } + } + } + path.remove(path.size() - 1); + state.put(b, Integer.valueOf(2)); + order.add(b); + } + + // ---------------------------------------------------------------- aspects + + /// A class-level @Transactional or @Async covers the public methods the class + /// DECLARES, as in Spring, whose reference says an inherited method has to be + /// redeclared to take part: the weaver rewrites bodies in place, and an + /// inherited body lives in another class. Silence would let such a method + /// run outside the transaction or on the caller's thread unnoticed, so the + /// build names each one. + /// Refuses @Transactional, @Async, @Timed and @Counted on a project interface. + /// The build weaves classes, not interfaces, so such an annotation -- on a + /// default method, an abstract one, or the interface itself -- is applied to + /// nothing: a transactional default method committed each write on its own. + /// Spring's runtime proxies would honour it, so ignoring it silently is the + /// one answer that cannot be right. + private void refuseInterfaceAspects(AnnotatedClass cls) { + String[] names = {TRANSACTIONAL, ASYNC, TIMED, COUNTED}; + String[] shown = {"@Transactional", "@Async", "@Timed", "@Counted"}; + for (int i = 0; i < names.length; i++) { + if (cls.getClassAnnotation(names[i]) != null) { + ctx.error(cls, shown[i] + " on interface " + cls.getSourceName() + " is not " + + "applied: the build weaves classes, not interfaces. Put it on the " + + "implementing class."); + } + } + for (MethodInfo m : cls.getMethods()) { + for (int i = 0; i < names.length; i++) { + if (m.getAnnotation(names[i]) != null) { + ctx.error(cls, shown[i] + " on " + cls.getSourceName() + "." + + m.getName() + " is not applied: the build weaves classes, not " + + "interfaces, so neither " + (m.isAbstract() ? "an implementation" + : "this default method") + " would get it. Put it on the " + + "implementing class's method."); + } + } + } + } + + private void warnInheritedOutsideClassAspect(AnnotatedClass cls, String what) { + Set declared = new HashSet(); + for (MethodInfo m : cls.getMethods()) { + declared.add(m.getName() + m.getDescriptor()); + } + String sup = cls.getSuperInternalName(); + AnnotatedClass c = sup == null || "java/lang/Object".equals(sup) ? null + : RestControllerAnnotationProcessor.resolveClass(ctx, sup); + for (int depth = 0; c != null && depth < 64; depth++) { + for (MethodInfo m : c.getMethods()) { + String key = m.getName() + m.getDescriptor(); + if (m.isPublic() && !m.isStatic() && !m.isConstructor() && !m.isSynthetic() + && declared.add(key)) { + ctx.getLog().warn("cn1: " + cls.getSourceName() + " is " + what + + ", which covers the methods it declares; " + m.getName() + + " is inherited from " + c.getSourceName() + " and runs without " + + "it. Override it in " + cls.getSourceName() + " to include it."); + } + } + sup = c.getSuperInternalName(); + c = sup == null || "java/lang/Object".equals(sup) ? null + : RestControllerAnnotationProcessor.resolveClass(ctx, sup); + } + } + + /// Every project class with a transactional, async, timed or counted method: + /// beans or not, since the rewrite applies to `new` as much as to injection. + /// Refuses one named executor asked for two kinds of thread. An executor is + /// created once, by whichever declaration runs first, so the other's kind + /// would be ignored depending on call order -- a PLATFORM method written to + /// block in SQLite could end up on the virtual hosts. AUTO agrees with either; + /// unnamed executors are already named by their kind. + /// + /// There is deliberately no warning for a VIRTUAL method that injects a + /// DataSource. A PostgreSQL or MySQL query parks its virtual thread as any + /// socket wait does; only SQLite blocks the host, and which engine a + /// DataSource reaches is cn1.datasource.url, read at start-up from a + /// deployment's environment -- so the build cannot tell, and warning on every + /// such method would be wrong for the engines a server is usually deployed + /// against. Spring does not warn here either. + private void checkExecutorKinds() { + Map kinds = new TreeMap(); + for (Aspects owner : aspects.values()) { + for (Aspect a : owner.methods) { + if (a.async != null) { + noteExecutorKind(kinds, a.async.getStringOrDefault("value", ""), + enumName(a.async.get("thread"), "PLATFORM"), owner.cls, + "@Async " + owner.cls.getSourceName() + "." + a.method.getName()); + } + } + } + for (Bean b : beans) { + for (Job j : b.jobs) { + noteExecutorKind(kinds, j.executor, j.thread, b.cls, + "@Scheduled " + j.method.getName() + " of " + b.describe()); + } + } + } + + private void noteExecutorKind(Map kinds, String executor, String thread, + AnnotatedClass where, String who) { + if (executor == null || executor.trim().length() == 0 || "AUTO".equals(thread)) { + return; + } + String name = executor.trim(); + String[] seen = kinds.get(name); + if (seen == null) { + kinds.put(name, new String[] {thread, who}); + } else if (!seen[0].equals(thread)) { + ctx.error(where, "Executor \"" + name + "\" is asked for " + seen[0] + " threads by " + + seen[1] + " and for " + thread + " threads by " + who + ". One executor " + + "has one kind of thread, and the first to run would decide; give them " + + "different executor names, or the same thread kind."); + } + } + + /// Claims the instruments `m`'s @Timed and @Counted register -- named as + /// BackendSources names them -- and refuses a name used for both kinds. At + /// run time the second registration throws, and it happens in the woven + /// finally, AFTER the body ran: a call whose work succeeded would be reported + /// as failed, and possibly retried. Metrics are the process's, so a clash + /// between two methods counts as much as one within a method. + private void claimAspectMetrics(AnnotatedClass cls, MethodInfo m, AnnotationValues timed, + AnnotationValues counted, String where) { + String base = cls.getBinaryName() + "." + m.getName(); + if (timed != null) { + String name = timed.getStringOrDefault("value", ""); + claimAspectMetric(cls, name.length() > 0 ? name : base + ".duration", "histogram", + "@Timed on " + where); + } + if (counted != null) { + String name = counted.getStringOrDefault("value", ""); + String calls = name.length() > 0 ? name : base + ".calls"; + claimAspectMetric(cls, calls, "counter", "@Counted on " + where); + claimAspectMetric(cls, calls + ".failures", "counter", "@Counted on " + where); + } + } + + private void claimAspectMetric(AnnotatedClass cls, String name, String kind, String by) { + String[] earlier = aspectMetrics.get(name); + if (earlier != null) { + if (!earlier[0].equals(kind)) { + ctx.error(cls, "Metric " + name + " is a " + kind + " for " + by + " and a " + + earlier[0] + " for " + earlier[1] + ". One name cannot be both: " + + "give one of them another name."); + } + return; + } + aspectMetrics.put(name, new String[] {kind, by}); + // And the Prometheus series it exports, as Metrics.claimPrometheusNames + // claims them at run time: two DIFFERENT names that fold alike -- + // latency.ms and latency_ms, or a counter's _total and a histogram's + // series -- are refused there, in the woven finally, after the body ran. + String base = promName(name); + String[] series = "counter".equals(kind) ? new String[] {base + "_total"} + : new String[] {base, base + "_bucket", base + "_sum", base + "_count"}; + for (String element : series) { + String[] owner = aspectSeries.get(element); + if (owner != null && !owner[0].equals(name)) { + ctx.error(cls, "Metric " + name + " (" + by + ") would be exported to " + + "Prometheus as " + element + ", which metric " + owner[0] + " (" + + owner[1] + ") already is; rename one."); + return; + } + } + for (String element : series) { + aspectSeries.put(element, new String[] {name, by}); + } + } + + /// A metric name in Prometheus's alphabet -- the same fold Metrics.promName + /// applies at run time, which this must match exactly. + static String promName(String name) { + StringBuilder sb = new StringBuilder(name.length()); + for (int iter = 0 ; iter < name.length() ; iter++) { + char c = name.charAt(iter); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' + || c == ':' || (iter > 0 && c >= '0' && c <= '9'); + sb.append(ok ? c : '_'); + } + return sb.toString(); + } + + private void collectAspects() { + for (AnnotatedClass cls : ctx.getClassIndex().values()) { + if (concerns(cls) && cls.isInterface()) { + refuseInterfaceAspects(cls); + continue; + } + if (!concerns(cls)) { + continue; + } + AnnotationValues classTx = cls.getClassAnnotation(TRANSACTIONAL); + AnnotationValues classAsync = cls.getClassAnnotation(ASYNC); + if (classTx != null || classAsync != null) { + warnInheritedOutsideClassAspect(cls, classTx != null ? "@Transactional" + : "@Async"); + } + Aspects found = null; + for (MethodInfo m : cls.getMethods()) { + if (m.isConstructor() || m.isSynthetic() || "".equals(m.getName()) + || BackendWeaver.isBody(m.getName()) + || m.getName().startsWith(BackendWeaver.BRIDGE_PREFIX)) { + continue; + } + AnnotationValues tx = m.getAnnotation(TRANSACTIONAL); + AnnotationValues async = m.getAnnotation(ASYNC); + if (tx == null && classTx != null && m.isPublic() && !m.isStatic()) { + tx = classTx; + } + if (async == null && classAsync != null && m.isPublic() && !m.isStatic()) { + async = classAsync; + } + if (async != null && (m.getAnnotation(POST_CONSTRUCT) != null + || m.getAnnotation(PRE_DESTROY) != null)) { + // Spring calls a lifecycle method on the bean itself, not + // through its proxy, so @Async never applies to it -- and here + // it would be worse than ignored: @PostConstruct would return + // before initialising, and @PreDestroy runs after the executors + // stop, so its body would never run at all. + ctx.getLog().warn("cn1: " + cls.getSourceName() + "." + m.getName() + + " is a lifecycle method, which runs synchronously whatever " + + "@Async says -- as Spring runs it."); + async = null; + } + AnnotationValues timed = m.getAnnotation(TIMED); + AnnotationValues counted = m.getAnnotation(COUNTED); + if (tx == null && async == null && timed == null && counted == null) { + continue; + } + String where = cls.getSourceName() + "." + m.getName(); + if (m.isAbstract() || (m.getAccess() & Opcodes.ACC_NATIVE) != 0) { + ctx.error(cls, where + " is " + (m.isAbstract() ? "abstract" : "native") + + ", so there is no body to wrap. Annotate the implementation."); + continue; + } + if (async != null) { + Type ret = Type.getReturnType(m.getDescriptor()); + if (ret.getSort() != Type.VOID && !(ret.getSort() == Type.OBJECT + && "java/util/concurrent/Future".equals(ret.getInternalName()))) { + ctx.error(cls, "@Async method " + where + " returns " + + ret.getClassName() + "; its caller returns before the work " + + "is done, so it can only receive nothing or a " + + "java.util.concurrent.Future. Return AsyncResult.of(value) " + + "from a method declared to return Future."); + continue; + } + } + if (found == null) { + String pkg = RestClientAnnotationProcessor.packageOf(cls.getBinaryName()); + found = new Aspects(cls, qualify(pkg, baseName(cls.getInternalName()) + + "Cn1Aspects")); + aspects.put(cls.getInternalName(), found); + } + Aspect a = new Aspect(m); + a.transactional = tx; + a.async = async; + a.timed = timed; + a.counted = counted; + claimAspectMetrics(cls, m, timed, counted, where); + if (async != null) { + String pkg = RestClientAnnotationProcessor.packageOf(cls.getBinaryName()); + a.asyncTaskBinary = qualify(pkg, baseName(cls.getInternalName()) + + "Cn1Async" + found.methods.size()); + } + found.methods.add(a); + } + } + } + + // ------------------------------------------------------------------- plan + + /// What the weaver does to each class. + private void plan() { + for (AnnotatedClass cls : ctx.getClassIndex().values()) { + if (!concerns(cls)) { + continue; + } + BackendWeaver.Plan plan = null; + Aspects a = aspects.get(cls.getInternalName()); + String helper = a == null ? null : a.helperBinary.replace('.', '/'); + for (FieldInfo f : cls.getFields()) { + if ((f.getAnnotation(AUTOWIRED) != null || f.getAnnotation(VALUE) != null) + && !f.isStatic() && !f.isFinal()) { + plan = plan(plan, cls, helper); + plan.injectFields.put(f.getName(), f.getDescriptor()); + } + } + boolean stereotyped = isStereotyped(cls); + for (MethodInfo m : cls.getMethods()) { + if (m.isSynthetic()) { + continue; + } + if (m.isConstructor()) { + if (stereotyped && !m.isPublic()) { + plan = plan(plan, cls, helper); + plan.constructors.add(m.getDescriptor()); + } + continue; + } + if (!m.isPublic() && callsFromWiring(m)) { + plan = plan(plan, cls, helper); + String key = m.getName() + m.getDescriptor(); + plan.bridges.add(key); + if (m.isStatic()) { + plan.staticBridges.add(key); + } + if (m.isPrivate()) { + plan.privateBridges.add(key); + } + } + } + if (a != null) { + plan = plan(plan, cls, helper); + for (Aspect aspect : a.methods) { + plan.aspects.add(aspect.method.getName() + aspect.method.getDescriptor()); + } + } + if (plan != null) { + plans.put(cls.getInternalName(), plan); + } + } + } + + private static BackendWeaver.Plan plan(BackendWeaver.Plan existing, AnnotatedClass cls, + String helper) { + return existing != null ? existing : new BackendWeaver.Plan(cls.getInternalName(), + helper); + } + + /// Whether generated code outside the class calls this method. + private static boolean callsFromWiring(MethodInfo m) { + return m.getAnnotation(AUTOWIRED) != null || m.getAnnotation(POST_CONSTRUCT) != null + || m.getAnnotation(PRE_DESTROY) != null || m.getAnnotation(BEAN) != null + || m.getAnnotation(SCHEDULED) != null || m.getAnnotation(MCP_TOOL) != null + || m.getAnnotation(MANAGED_ATTRIBUTE) != null + || m.getAnnotation(MANAGED_OPERATION) != null; + } + + /// Whether code in another class calls `m` through its bridge. + static boolean bridged(MethodInfo m) { + return !m.isPublic() && callsFromWiring(m); + } + + // ------------------------------------------------------------------- emit + + /// Weaves the classes, then compiles the classes generated beside them. + private void emit() throws ProcessingException { + File out = ctx.getOutputClassDir(); + int woven = 0; + try { + for (BackendWeaver.Plan plan : plans.values()) { + if (BackendWeaver.weave(out, plan)) { + woven++; + } + } + } catch (IOException err) { + throw new ProcessingException("Could not rewrite the backend classes: " + + err.getMessage(), err); + } + BackendSources writer = new BackendSources(this); + for (Aspects a : aspects.values()) { + writer.aspects(a, sources); + } + for (Bean b : beans) { + if (b.proxyBinary != null) { + sources.put(b.proxyBinary, writer.proxy(b)); + } + for (int i = 0; i < b.tools.size(); i++) { + Tool t = b.tools.get(i); + String pkg = RestClientAnnotationProcessor.packageOf(b.type.replace('/', '.')); + t.adapterBinary = qualify(pkg, baseName(b.type) + "Cn1Tool" + i); + sources.put(t.adapterBinary, writer.tool(b, t)); + } + if (b.managed != null) { + String pkg = RestClientAnnotationProcessor.packageOf(b.type.replace('/', '.')); + b.managed.adapterBinary = qualify(pkg, baseName(b.type) + "Cn1Managed"); + sources.put(b.managed.adapterBinary, writer.managed(b)); + } + } + if (sources.isEmpty()) { + if (woven > 0) { + ctx.getLog().info("cn1: rewrote " + woven + " backend class(es)"); + } + return; + } + try { + List cp = new ArrayList(); + cp.add(out); + for (String element : ctx.getCompileClasspath()) { + cp.add(new File(element)); + } + JavaSourceCompiler.compile(sources, out, cp); + } catch (IOException err) { + throw new ProcessingException("Could not compile the classes generated for the " + + "backend's beans: " + err.getMessage(), err); + } + ctx.getLog().info("cn1: rewrote " + woven + " backend class(es) and generated " + + sources.size() + " support class(es)"); + } + + // ---------------------------------------------------------------- helpers + + /// Every type a value of `internal` can be assigned to: itself, its + /// superclasses and its interfaces, as far as the build can see them. + Set assignableTypes(String internal) { + Set out = new LinkedHashSet(); + List queue = new ArrayList(); + queue.add(internal); + while (!queue.isEmpty()) { + String next = queue.remove(0); + if (next == null || "java/lang/Object".equals(next) || !out.add(next)) { + continue; + } + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(ctx, next); + if (c == null) { + continue; + } + queue.add(c.getSuperInternalName()); + queue.addAll(c.getInterfaceInternalNames()); + } + return out; + } + + /// The erased element type of `List` or `Collection`, from a field or + /// parameter signature. + static String elementType(String signature) { + int lt = signature.indexOf('<'); + int gt = signature.lastIndexOf('>'); + if (lt < 0 || gt < lt) { + return null; + } + String arg = signature.substring(lt + 1, gt); + if (arg.startsWith("+")) { + arg = arg.substring(1); + } + if (!arg.startsWith("L")) { + return null; + } + int end = arg.indexOf('<'); + if (end < 0) { + end = arg.indexOf(';'); + } + return end < 0 ? null : arg.substring(1, end); + } + + /// Each parameter's generic signature, or null when the method has none. + static String[] parameterSignatures(MethodInfo m) { + String sig = m.getSignature(); + if (sig == null) { + return null; + } + int open = sig.indexOf('('); + int close = sig.lastIndexOf(')'); + if (open < 0 || close < open) { + return null; + } + List out = new ArrayList(); + String params = sig.substring(open + 1, close); + int i = 0; + while (i < params.length()) { + int start = i; + while (params.charAt(i) == '[') { + i++; + } + char c = params.charAt(i); + if (c == 'L' || c == 'T') { + int depth = 0; + while (i < params.length()) { + char d = params.charAt(i); + if (d == '<') { + depth++; + } else if (d == '>') { + depth--; + } else if (d == ';' && depth == 0) { + break; + } + i++; + } + } + i++; + out.add(params.substring(start, i)); + } + int count = Type.getArgumentTypes(m.getDescriptor()).length; + if (out.size() != count) { + // A signature that skips synthetic parameters: the positions would + // not line up, and a wrong element type is worse than none. + return null; + } + return out.toArray(new String[out.size()]); + } + + static Map parameterAnnotations(MethodInfo m, int index) { + List> all = m.getParameterAnnotations(); + if (index < all.size()) { + return all.get(index); + } + return Collections.emptyMap(); + } + + private static List strings(Object value) { + List out = new ArrayList(); + if (value instanceof List) { + for (Object o : (List) value) { + if (o instanceof String && ((String) o).trim().length() > 0) { + out.add(((String) o).trim()); + } + } + } else if (value instanceof String && ((String) value).trim().length() > 0) { + out.add(((String) value).trim()); + } + return out; + } + + private static long longOf(Object value) { + return value instanceof Number ? ((Number) value).longValue() : -1; + } + + static String enumName(Object value, String fallback) { + if (value instanceof String[] && ((String[]) value).length == 2) { + return ((String[]) value)[1]; + } + return fallback; + } + + /// Spring's rule, which is java.beans.Introspector's: the first letter + /// lower-cased, unless the first two are both capitals (URLService stays). + static String decapitalize(String name) { + if (name == null || name.length() == 0) { + return name; + } + if (name.length() > 1 && Character.isUpperCase(name.charAt(0)) + && Character.isUpperCase(name.charAt(1))) { + return name; + } + return Character.toLowerCase(name.charAt(0)) + name.substring(1); + } + + static String identifier(String name) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + sb.append((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '_' ? c : '_'); + } + return sb.toString(); + } + + /// A class's name within its package with `$` turned into `_`: the base the + /// generated classes beside it are named from. + /// The class's name in its package, as a Java identifier for the support + /// classes named after it. Injective: `_` becomes `__` and `$` becomes `_S`, + /// so every escape is `_` plus a character saying which, and no two names + /// can meet -- Outer_Inner and Outer$Inner, or A$_B and A_$B, each folded to + /// one name under a plainer scheme, and one helper replaced the other. + static String baseName(String internal) { + String simple = internal.substring(internal.lastIndexOf('/') + 1); + StringBuilder sb = new StringBuilder(simple.length() + 4); + for (int iter = 0 ; iter < simple.length() ; iter++) { + char c = simple.charAt(iter); + if (c == '_') { + sb.append("__"); + } else if (c == '$') { + sb.append("_S"); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + static String qualify(String pkg, String simple) { + return pkg == null || pkg.length() == 0 ? simple : pkg + "." + simple; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java new file mode 100644 index 00000000000..9e7b9b9dc9d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java @@ -0,0 +1,1232 @@ +/* + * 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. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.FieldInfo; +import com.codename1.maven.annotations.MethodInfo; +import com.codename1.maven.annotations.ProcessorContext; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +/// JSON codecs for the application's own classes, written at build time -- what +/// Jackson does for a Spring controller at run time, without reflection. +/// +/// A route that returns or accepts an entity or a DTO gets, for that class and +/// every class its fields reach, a `Cn1Json` class in the same package with +/// a `write` and, where a body needs it, a `read`. The router calls them directly. +/// +/// The JSON form is the one the app's `@Mapped` mapper uses, so one class shared +/// between the app and the server has one JSON form: the class's non-static, +/// non-transient fields -- a public one directly, any other through a +/// `getX`/`isX` and `setX` pair -- named by the field or `@JsonProperty`, and +/// `@JsonIgnore` leaving one out. A `Date` is milliseconds since the epoch (read +/// back from that or ISO-8601), a `byte[]` base64, an enum its name. Unknown +/// members in a body are ignored and absent ones leave the field's default, as a +/// Spring Boot application does. +final class BackendJsonCodecs { + static final String JSON_PROPERTY = "Lcom/codename1/annotations/JsonProperty;"; + static final String JSON_IGNORE = "Lcom/codename1/annotations/JsonIgnore;"; + private static final String WRITABLE = "com/codename1/backend/Json$Writable"; + private static final String JSON = "com.codename1.backend.Json"; + private static final String CODEC = "com.codename1.backend.JsonCodec"; + private static final String SINK = "com.codename1.backend.ByteSink"; + + private static final Set PRIMITIVES = new HashSet(Arrays.asList( + "int", "long", "short", "byte", "double", "float", "boolean", "char")); + private static final Set BOXES = new HashSet(Arrays.asList( + "java.lang.Integer", "java.lang.Long", "java.lang.Short", "java.lang.Byte", + "java.lang.Double", "java.lang.Float", "java.lang.Boolean", "java.lang.Character")); + /// Collection declarations, and what a body's array becomes for each. + private static final Map COLLECTIONS = new LinkedHashMap(); + /// Map declarations, and what a body's object becomes for each. + private static final Map MAPS = new LinkedHashMap(); + + static { + COLLECTIONS.put("java.util.List", "java.util.ArrayList"); + COLLECTIONS.put("java.util.Collection", "java.util.ArrayList"); + COLLECTIONS.put("java.util.ArrayList", "java.util.ArrayList"); + COLLECTIONS.put("java.util.LinkedList", "java.util.LinkedList"); + COLLECTIONS.put("java.util.Set", "java.util.LinkedHashSet"); + COLLECTIONS.put("java.util.HashSet", "java.util.HashSet"); + COLLECTIONS.put("java.util.LinkedHashSet", "java.util.LinkedHashSet"); + COLLECTIONS.put("java.util.TreeSet", "java.util.TreeSet"); + MAPS.put("java.util.Map", "java.util.LinkedHashMap"); + MAPS.put("java.util.HashMap", "java.util.HashMap"); + MAPS.put("java.util.LinkedHashMap", "java.util.LinkedHashMap"); + MAPS.put("java.util.TreeMap", "java.util.TreeMap"); + } + + private enum Kind { PRIMITIVE, BOX, STRING, DATE, BYTES, ENUM, ANY, RAW_MAP, RAW_LIST, + COLLECTION, MAP, DTO } + + /// One property of a class: its JSON name, declared type, and how to reach it. + static final class Prop { + String json; + String type; + /// How the writer reads it off `v` -- `v.name` or `v.getName()` -- or null. + String get; + /// The field the reader assigns directly, or null. + String field; + /// The setter the reader calls instead, or null. + String setter; + } + + /// A class that gets a codec. + static final class Dto { + final String binary; + final AnnotatedClass cls; + final List props = new ArrayList(); + final List subclasses = new ArrayList(); + String problem; + String readProblem; + boolean ownWriter; + boolean write; + boolean read; + + Dto(String binary, AnnotatedClass cls) { + this.binary = binary; + this.cls = cls; + } + } + + /// The generated class that writes a value whose declared type says nothing + /// -- an `Object` or raw `Map` or `List` field -- by what it turns out to be. + static final String VALUES = "cn1app.JsonValues"; + + private final ProcessorContext ctx; + private final Map dtos = new LinkedHashMap(); + private int counter; + /// Whether any codec writes a value through [#VALUES]. + private boolean valuesUsed; + + BackendJsonCodecs(ProcessorContext ctx) { + this.ctx = ctx; + } + + /// Null when a value of `javaType` can be written as JSON, or why not. + String checkWrite(String javaType) { + return check(javaType, false, new HashSet()); + } + + /// Null when a body can be read into `javaType`, or why not. + String checkRead(String javaType) { + return check(javaType, true, new HashSet()); + } + + /// Whether any codec is needed at all. + boolean isEmpty() { + return dtos.isEmpty(); + } + + // ------------------------------------------------------------------ + // Analysis + // ------------------------------------------------------------------ + + private String check(String type, boolean read, Set visiting) { + type = strip(type); + if (type == null) { + return "a wildcard or type variable, which names no type the build can write " + + "code for; declare the concrete type"; + } + Kind kind = kindOf(type); + if (kind == null) { + return whyNot(type); + } + switch (kind) { + case COLLECTION: { + List args = args(type); + if (read && "java.util.TreeSet".equals(raw(type))) { + // A TreeSet orders by compareTo, so the codec's first add of an + // element that is not Comparable fails -- and on the translated + // runtime a failed cast is not even an exception. Refused here. + String element = args.isEmpty() ? "java.lang.Object" : strip(args.get(0)); + if (element == null || !isComparable(element)) { + return "a TreeSet of " + element + ", which is not Comparable, so a " + + "body cannot fill one; implement Comparable or use a Set"; + } + } + return args.isEmpty() ? null : check(args.get(0), read, visiting); + } + case MAP: { + List args = args(type); + if (args.isEmpty()) { + return null; + } + if (!"java.lang.String".equals(strip(args.get(0)))) { + return "a JSON object's names are strings, so a map in JSON is keyed by " + + "String, not " + args.get(0); + } + return check(args.get(1), read, visiting); + } + case DTO: + return checkDto(raw(type), read, visiting); + default: + return null; + } + } + + private String checkDto(String binary, boolean read, Set visiting) { + Dto dto = dto(binary); + if (dto.problem != null) { + return dto.problem; + } + if (read) { + dto.read = true; + if (dto.readProblem != null) { + return dto.readProblem; + } + } else { + dto.write = true; + } + if (!visiting.add((read ? "r:" : "w:") + binary)) { + return null; // a class reaching itself is fine; data decides + } + if (!read && dto.ownWriter) { + return null; + } + for (Prop p : dto.props) { + if (read ? p.field == null && p.setter == null : p.get == null) { + continue; + } + String why = check(p.type, read, visiting); + if (why != null) { + return "field " + p.json + " of " + binary + " is " + why; + } + } + if (!read) { + for (String sub : dto.subclasses) { + String why = checkDto(sub, false, visiting); + if (why != null) { + return why; + } + } + } + return null; + } + + private Dto dto(String binary) { + Dto existing = dtos.get(binary); + if (existing != null) { + return existing; + } + AnnotatedClass cls = RestControllerAnnotationProcessor.resolveClass(ctx, + binary.replace('.', '/')); + Dto dto = new Dto(binary, cls); + dtos.put(binary, dto); + if (cls == null) { + dto.problem = binary + ", which the build cannot find on its class path to read " + + "its fields"; + return dto; + } + if (cls.isInterface()) { + dto.problem = "the interface " + binary + "; a JSON object needs a class whose " + + "fields say what it holds"; + return dto; + } + if (isInnerClass(cls)) { + dto.problem = "the inner class " + binary + ", which can only be created by an " + + "instance of its outer class; make it static"; + return dto; + } + dto.ownWriter = implementsWritable(cls, new HashSet()); + collectProps(dto, cls); + if (dto.problem != null) { + return dto; + } + if (cls.isAbstract()) { + dto.readProblem = "the abstract class " + binary + ", which a body cannot " + + "create; accept a concrete subclass"; + } else if (!hasNoArgConstructor(cls)) { + dto.readProblem = binary + ", which has no constructor without arguments for a " + + "body to create it with; add one"; + } + dto.subclasses.addAll(subclassesOf(binary)); + return dto; + } + + private void collectProps(Dto dto, AnnotatedClass cls) { + List chain = new ArrayList(); + for (AnnotatedClass c = cls; c != null; ) { + chain.add(0, c); + String parent = c.getSuperInternalName(); + if (parent == null || parent.startsWith("java/")) { + break; + } + c = RestControllerAnnotationProcessor.resolveClass(ctx, parent); + } + String pkg = packageOf(dto.binary); + Set names = new HashSet(); + for (AnnotatedClass c : chain) { + for (FieldInfo f : c.getFields()) { + int access = f.getAccess(); + if ((access & (Opcodes.ACC_STATIC | Opcodes.ACC_TRANSIENT + | Opcodes.ACC_SYNTHETIC)) != 0 || f.getName().startsWith("this$") + || f.getAnnotation(JSON_IGNORE) != null) { + continue; + } + Prop p = new Prop(); + AnnotationValues renamed = f.getAnnotation(JSON_PROPERTY); + String json = renamed == null ? null : renamed.getString("value"); + p.json = json == null || json.length() == 0 ? f.getName() : json; + if (hasTypeVariable(f.getSignature())) { + dto.problem = "a class whose field " + f.getName() + " has a type variable " + + "for its type (" + c.getBinaryName() + "), so the build cannot " + + "know what it holds; declare a concrete subclass"; + return; + } + p.type = RestClientAnnotationProcessor.javaTypeFor( + Type.getType(f.getDescriptor()), f.getSignature()); + // The codec lives in the class's own package, so a field it can + // name is read and written directly; any other goes through the + // JavaBeans accessors, as it does for the app's mapper. + boolean reachable = f.isPublic() || (!f.isPrivate() + && packageOf(c.getBinaryName()).equals(pkg)); + String cap = Character.toUpperCase(f.getName().charAt(0)) + + f.getName().substring(1); + if (reachable) { + p.get = "v." + f.getName(); + } else { + String getter = findMethod(chain, "get" + cap, "()" + f.getDescriptor()); + if (getter == null && "Z".equals(f.getDescriptor())) { + getter = findMethod(chain, "is" + cap, "()Z"); + } + p.get = getter == null ? null : "v." + getter + "()"; + } + if (reachable && !f.isFinal()) { + p.field = f.getName(); + } else { + p.setter = findMethod(chain, "set" + cap, "(" + f.getDescriptor() + ")V"); + } + if (p.get == null && p.field == null && p.setter == null) { + continue; // not a property, as for the app's mapper + } + if (!names.add(p.json)) { + dto.problem = "a class with two fields named \"" + p.json + "\" in JSON (" + + dto.binary + "); rename one with @JsonProperty"; + return; + } + dto.props.add(p); + } + } + } + + private static String findMethod(List chain, String name, String descriptor) { + for (int i = chain.size() - 1; i >= 0; i--) { + for (MethodInfo m : chain.get(i).getMethods()) { + if (m.isPublic() && !m.isStatic() && name.equals(m.getName()) + && descriptor.equals(m.getDescriptor())) { + return name; + } + } + } + return null; + } + + private List subclassesOf(String binary) { + String internal = binary.replace('.', '/'); + List out = new ArrayList(); + final Map depth = new LinkedHashMap(); + for (AnnotatedClass c : ctx.getClassIndex().values()) { + if (c.isSynthetic() || c.isInterface() || isAnonymous(c.getBinaryName())) { + continue; + } + int d = 0; + String parent = c.getSuperInternalName(); + while (parent != null && !parent.startsWith("java/")) { + d++; + if (parent.equals(internal)) { + out.add(c.getBinaryName()); + depth.put(c.getBinaryName(), Integer.valueOf(d)); + break; + } + AnnotatedClass up = ctx.lookup(parent); + parent = up == null ? null : up.getSuperInternalName(); + } + } + // Most derived first, so each value is written as the class it really is. + Collections.sort(out, (a, b) -> depth.get(b).compareTo(depth.get(a))); + return out; + } + + private boolean implementsWritable(AnnotatedClass cls, Set seen) { + return implementsInterface(cls, WRITABLE, seen); + } + + private boolean implementsInterface(AnnotatedClass cls, String itfName, Set seen) { + if (cls == null) { + return false; + } + for (String itf : cls.getInterfaceInternalNames()) { + if (itfName.equals(itf)) { + return true; + } + if (seen.add(itf) && implementsInterface( + RestControllerAnnotationProcessor.resolveClass(ctx, itf), itfName, seen)) { + return true; + } + } + String parent = cls.getSuperInternalName(); + if (parent == null || parent.startsWith("java/") || !seen.add(parent)) { + return false; + } + return implementsInterface(RestControllerAnnotationProcessor.resolveClass(ctx, parent), + itfName, seen); + } + + /// Whether a TreeSet can order values of `type`. + private boolean isComparable(String type) { + Kind kind = kindOf(type); + if (kind == Kind.STRING || kind == Kind.BOX || kind == Kind.DATE || kind == Kind.ENUM) { + return true; + } + if (kind != Kind.DTO) { + return false; + } + AnnotatedClass cls = RestControllerAnnotationProcessor.resolveClass(ctx, + raw(type).replace('.', '/')); + return implementsInterface(cls, "java/lang/Comparable", new HashSet()); + } + + private static boolean hasNoArgConstructor(AnnotatedClass cls) { + for (MethodInfo m : cls.getMethods()) { + if (m.isConstructor() && "()V".equals(m.getDescriptor()) && !m.isPrivate()) { + return true; + } + } + return false; + } + + private static boolean isInnerClass(AnnotatedClass cls) { + for (FieldInfo f : cls.getFields()) { + if (f.getName().startsWith("this$") && !f.isStatic()) { + return true; + } + } + return false; + } + + private static boolean isAnonymous(String binary) { + int dollar = binary.lastIndexOf('$'); + return dollar >= 0 && dollar + 1 < binary.length() + && Character.isDigit(binary.charAt(dollar + 1)); + } + + /// Whether a field's generic signature uses a type variable anywhere, which + /// the signature parser erases to Object. + static boolean hasTypeVariable(String signature) { + if (signature == null) { + return false; + } + for (int i = 0; i < signature.length(); i++) { + char c = signature.charAt(i); + if (c == 'T' && (i == 0 || "<;[+-".indexOf(signature.charAt(i - 1)) >= 0)) { + return true; + } + if (c == 'L') { + // Skip a class name, which may itself contain a T. + while (i < signature.length() && signature.charAt(i) != ';' + && signature.charAt(i) != '<') { + i++; + } + if (i < signature.length() && signature.charAt(i) == '<') { + i--; // let the loop see the '<' + } + } + } + return false; + } + + private Kind kindOf(String type) { + if (PRIMITIVES.contains(type)) { + return Kind.PRIMITIVE; + } + if (BOXES.contains(type)) { + return Kind.BOX; + } + if ("java.lang.String".equals(type)) { + return Kind.STRING; + } + if ("java.util.Date".equals(type)) { + return Kind.DATE; + } + if ("byte[]".equals(type)) { + return Kind.BYTES; + } + if ("java.lang.Object".equals(type)) { + return Kind.ANY; + } + if (type.endsWith("[]")) { + return null; + } + String raw = raw(type); + boolean generic = type.indexOf('<') >= 0; + if ("java.util.Map".equals(raw) && !generic) { + return Kind.RAW_MAP; + } + if (("java.util.List".equals(raw) || "java.util.Collection".equals(raw)) && !generic) { + return Kind.RAW_LIST; + } + if (COLLECTIONS.containsKey(raw)) { + return Kind.COLLECTION; + } + if (MAPS.containsKey(raw)) { + return Kind.MAP; + } + // The JDK's classes and the runtime's own -- an HttpServer.Response + // inside a list -- are not data with fields to write; their fields are + // the implementation's. + if (raw.startsWith("java.") || raw.startsWith("com.codename1.backend.")) { + return null; + } + AnnotatedClass cls = RestControllerAnnotationProcessor.resolveClass(ctx, + raw.replace('.', '/')); + if (cls != null && cls.isEnum()) { + return Kind.ENUM; + } + return Kind.DTO; + } + + private static String whyNot(String type) { + if (type.endsWith("[]")) { + return "an array, which has no JSON form here other than byte[]; use a List"; + } + if (type.startsWith("com.codename1.backend.")) { + return "the runtime type " + type + ", which is not data to write as JSON"; + } + return "the JDK type " + type + ", which has no JSON form here"; + } + + // ------------------------------------------------------------------ + // Code + // ------------------------------------------------------------------ + + /// Statements writing `expr`, a value of `type`, into the sink `out`, as a + /// value nested `depth` objects deep. + String writeStatements(String type, String expr, String depth, String indent) { + StringBuilder sb = new StringBuilder(); + write(sb, strip(type), expr, depth, indent); + return sb.toString(); + } + + /// Statements reading `json`, the parsed JSON at member `name` or element + /// `index` of `at`, into `target`, which is already declared. + String readStatements(String type, String json, String at, String name, String index, + String depth, String target, String indent) { + StringBuilder sb = new StringBuilder(); + read(sb, strip(type), json, at, name, index, depth, target, indent); + return sb.toString(); + } + + private void write(StringBuilder sb, String type, String expr, String depth, String ind) { + Kind kind = kindOf(type); + switch (kind) { + case PRIMITIVE: + if ("double".equals(type)) { + sb.append(ind).append(JSON).append(".writeValue(Double.valueOf(").append(expr) + .append("), out);\n"); + } else if ("float".equals(type)) { + sb.append(ind).append(JSON).append(".writeValue(Float.valueOf(").append(expr) + .append("), out);\n"); + } else if ("boolean".equals(type)) { + sb.append(ind).append("out.putAscii((").append(expr) + .append(") ? \"true\" : \"false\");\n"); + } else if ("char".equals(type)) { + sb.append(ind).append(JSON).append(".writeString(String.valueOf(").append(expr) + .append("), out);\n"); + } else { + sb.append(ind).append("out.putNumber(").append(expr).append(");\n"); + } + return; + case BOX: + case STRING: + sb.append(ind).append(JSON).append(".writeValue(").append(expr) + .append(", out);\n"); + return; + case ANY: + case RAW_MAP: + case RAW_LIST: + // Its declared type says nothing, and Json.writeValue knows no + // generated codec: an application object in here would be written + // as its toString(). The dispatcher asks what it is at run time. + valuesUsed = true; + sb.append(ind).append(VALUES).append(".write(").append(expr) + .append(", out, ").append(depth).append(");\n"); + return; + case DATE: + sb.append(ind).append(CODEC).append(".writeDate(").append(expr) + .append(", out);\n"); + return; + case BYTES: { + String v = fresh("b"); + sb.append(ind).append("byte[] ").append(v).append(" = ").append(expr).append(";\n"); + sb.append(ind).append("if (").append(v).append(" == null) {\n"); + sb.append(ind).append(" out.putAscii(\"null\");\n"); + sb.append(ind).append("} else {\n"); + sb.append(ind).append(" ").append(JSON).append(".writeString(") + .append("com.codename1.backend.Base64.encode(").append(v).append("), out);\n"); + sb.append(ind).append("}\n"); + return; + } + case ENUM: { + String v = fresh("e"); + sb.append(ind).append(source(type)).append(' ').append(v).append(" = ") + .append(expr).append(";\n"); + sb.append(ind).append("if (").append(v).append(" == null) {\n"); + sb.append(ind).append(" out.putAscii(\"null\");\n"); + sb.append(ind).append("} else {\n"); + sb.append(ind).append(" ").append(JSON).append(".writeString(").append(v) + .append(".name(), out);\n"); + sb.append(ind).append("}\n"); + return; + } + case DTO: + sb.append(ind).append(codecSource(raw(type))).append(".write(").append(expr) + .append(", out, ").append(depth).append(" + 1);\n"); + return; + case COLLECTION: { + String element = elementType(type, 0); + String c = fresh("c"); + String first = fresh("f"); + String it = fresh("i"); + String e = fresh("e"); + sb.append(ind).append("java.util.Collection ").append(c).append(" = ") + .append(expr).append(";\n"); + sb.append(ind).append("if (").append(c).append(" == null) {\n"); + sb.append(ind).append(" out.putAscii(\"null\");\n"); + sb.append(ind).append("} else {\n"); + sb.append(ind).append(" out.put('[');\n"); + sb.append(ind).append(" boolean ").append(first).append(" = true;\n"); + sb.append(ind).append(" for (java.util.Iterator ").append(it).append(" = ") + .append(c).append(".iterator(); ").append(it).append(".hasNext();) {\n"); + sb.append(ind).append(" Object ").append(e).append(" = ").append(it) + .append(".next();\n"); + sb.append(ind).append(" if (").append(first).append(") {\n"); + sb.append(ind).append(" ").append(first).append(" = false;\n"); + sb.append(ind).append(" } else {\n"); + sb.append(ind).append(" out.put(',');\n"); + sb.append(ind).append(" }\n"); + write(sb, element, cast(element, e), depth, ind + " "); + sb.append(ind).append(" }\n"); + sb.append(ind).append(" out.put(']');\n"); + sb.append(ind).append("}\n"); + return; + } + case MAP: { + String value = elementType(type, 1); + String m = fresh("m"); + String first = fresh("f"); + String it = fresh("i"); + String e = fresh("e"); + String v = fresh("v"); + sb.append(ind).append("java.util.Map ").append(m).append(" = ").append(expr) + .append(";\n"); + sb.append(ind).append("if (").append(m).append(" == null) {\n"); + sb.append(ind).append(" out.putAscii(\"null\");\n"); + sb.append(ind).append("} else {\n"); + sb.append(ind).append(" out.put('{');\n"); + sb.append(ind).append(" boolean ").append(first).append(" = true;\n"); + sb.append(ind).append(" for (java.util.Iterator ").append(it).append(" = ") + .append(m).append(".entrySet().iterator(); ").append(it) + .append(".hasNext();) {\n"); + sb.append(ind).append(" java.util.Map.Entry ").append(e) + .append(" = (java.util.Map.Entry) ").append(it).append(".next();\n"); + sb.append(ind).append(" if (").append(first).append(") {\n"); + sb.append(ind).append(" ").append(first).append(" = false;\n"); + sb.append(ind).append(" } else {\n"); + sb.append(ind).append(" out.put(',');\n"); + sb.append(ind).append(" }\n"); + sb.append(ind).append(" ").append(JSON).append(".writeString(String.valueOf(") + .append(e).append(".getKey()), out);\n"); + sb.append(ind).append(" out.put(':');\n"); + sb.append(ind).append(" Object ").append(v).append(" = ").append(e) + .append(".getValue();\n"); + write(sb, value, cast(value, v), depth, ind + " "); + sb.append(ind).append(" }\n"); + sb.append(ind).append(" out.put('}');\n"); + sb.append(ind).append("}\n"); + return; + } + default: + throw new IllegalStateException(type); + } + } + + private void read(StringBuilder sb, String type, String json, String at, String name, + String index, String depth, String target, String ind) { + Kind kind = kindOf(type); + String where = at + ", " + name + ", " + index; + switch (kind) { + case PRIMITIVE: + sb.append(ind).append("if (").append(json).append(" != null) {\n"); + sb.append(ind).append(" ").append(target).append(" = ") + .append(scalar(type, json, where)).append(";\n"); + sb.append(ind).append("}\n"); + return; + case BOX: { + String prim = unbox(type); + sb.append(ind).append(target).append(" = ").append(json).append(" == null ? null : ") + .append(type).append(".valueOf(").append(scalar(prim, json, where)) + .append(");\n"); + return; + } + case STRING: + sb.append(ind).append(target).append(" = ").append(CODEC).append(".readString(") + .append(json).append(", ").append(where).append(");\n"); + return; + case DATE: + sb.append(ind).append(target).append(" = ").append(CODEC).append(".readDate(") + .append(json).append(", ").append(where).append(");\n"); + return; + case BYTES: + sb.append(ind).append(target).append(" = ").append(CODEC).append(".readBytes(") + .append(json).append(", ").append(where).append(");\n"); + return; + case ANY: + sb.append(ind).append(target).append(" = ").append(json).append(";\n"); + return; + case RAW_MAP: + case RAW_LIST: { + String shape = kind == Kind.RAW_MAP ? "java.util.Map" : "java.util.List"; + sb.append(ind).append("if (").append(json).append(" != null && !(").append(json) + .append(" instanceof ").append(shape).append(")) {\n"); + sb.append(ind).append(" throw ").append(CODEC).append(".mismatch(").append(where) + .append(", \"").append(kind == Kind.RAW_MAP ? "an object" : "an array") + .append("\", ").append(json).append(");\n"); + sb.append(ind).append("}\n"); + sb.append(ind).append(target).append(" = (").append(shape).append(") ") + .append(json).append(";\n"); + return; + } + case ENUM: { + String s = fresh("s"); + String err = fresh("x"); + sb.append(ind).append("if (").append(json).append(" == null) {\n"); + sb.append(ind).append(" ").append(target).append(" = null;\n"); + sb.append(ind).append("} else {\n"); + sb.append(ind).append(" if (!(").append(json).append(" instanceof String)) {\n"); + sb.append(ind).append(" throw ").append(CODEC).append(".mismatch(") + .append(where).append(", ").append(quote(enumChoices(type))).append(", ") + .append(json).append(");\n"); + sb.append(ind).append(" }\n"); + sb.append(ind).append(" String ").append(s).append(" = (String) ").append(json) + .append(";\n"); + sb.append(ind).append(" try {\n"); + sb.append(ind).append(" ").append(target).append(" = ").append(source(type)) + .append(".valueOf(").append(s).append(");\n"); + sb.append(ind).append(" } catch (IllegalArgumentException ").append(err) + .append(") {\n"); + sb.append(ind).append(" throw ").append(CODEC).append(".mismatch(") + .append(where).append(", ").append(quote(enumChoices(type))).append(", ") + .append(json).append(");\n"); + sb.append(ind).append(" }\n"); + sb.append(ind).append("}\n"); + return; + } + case DTO: + sb.append(ind).append(target).append(" = ").append(codecSource(raw(type))) + .append(".read(").append(json).append(", ").append(where).append(", ") + .append(depth).append(" + 1);\n"); + return; + case COLLECTION: + case MAP: { + boolean map = kind == Kind.MAP; + String element = elementType(type, map ? 1 : 0); + String concrete = (map ? MAPS : COLLECTIONS).get(raw(type)); + String declared = map ? "java.util.Map" : "java.util.List"; + String src = fresh("l"); + String path = fresh("p"); + String out = fresh("c"); + String loop = fresh("i"); + String item = fresh("j"); + String value = fresh("v"); + String key = fresh("k"); + sb.append(ind).append("if (").append(json).append(" == null) {\n"); + sb.append(ind).append(" ").append(target).append(" = null;\n"); + sb.append(ind).append("} else {\n"); + sb.append(ind).append(" if (!(").append(json).append(" instanceof ") + .append(declared).append(")) {\n"); + sb.append(ind).append(" throw ").append(CODEC).append(".mismatch(") + .append(where).append(", \"").append(map ? "an object" : "an array") + .append("\", ").append(json).append(");\n"); + sb.append(ind).append(" }\n"); + sb.append(ind).append(" ").append(declared).append(' ').append(src) + .append(" = (").append(declared).append(") ").append(json).append(";\n"); + sb.append(ind).append(" ").append(CODEC).append(".Path ").append(path) + .append(" = ").append(CODEC).append(".enter(").append(where).append(");\n"); + String elementSource = source(element); + String generic = map ? "" : "<" + elementSource + ">"; + sb.append(ind).append(" ").append(concrete).append(generic).append(' ') + .append(out).append(" = new ").append(concrete).append(generic).append("();\n"); + if (map) { + sb.append(ind).append(" for (java.util.Iterator ").append(loop) + .append(" = ").append(src).append(".entrySet().iterator(); ").append(loop) + .append(".hasNext();) {\n"); + sb.append(ind).append(" java.util.Map.Entry ").append(value) + .append("$e = (java.util.Map.Entry) ").append(loop).append(".next();\n"); + sb.append(ind).append(" String ").append(key).append(" = String.valueOf(") + .append(value).append("$e.getKey());\n"); + sb.append(ind).append(" Object ").append(item).append(" = ") + .append(value).append("$e.getValue();\n"); + sb.append(ind).append(" ").append(elementSource).append(' ') + .append(value).append(" = null;\n"); + read(sb, element, item, path, key, "-1", depth, value, ind + " "); + sb.append(ind).append(" ").append(out).append(".put(").append(key) + .append(", ").append(value).append(");\n"); + } else { + sb.append(ind).append(" for (int ").append(loop).append(" = 0; ") + .append(loop).append(" < ").append(src).append(".size(); ").append(loop) + .append("++) {\n"); + sb.append(ind).append(" Object ").append(item).append(" = ").append(src) + .append(".get(").append(loop).append(");\n"); + sb.append(ind).append(" ").append(elementSource).append(' ') + .append(value).append(" = null;\n"); + read(sb, element, item, path, "null", loop, depth, value, ind + " "); + sb.append(ind).append(" ").append(out).append(".add(").append(value) + .append(");\n"); + } + sb.append(ind).append(" }\n"); + sb.append(ind).append(" ").append(target).append(" = ").append(out).append(";\n"); + sb.append(ind).append("}\n"); + return; + } + default: + throw new IllegalStateException(type); + } + } + + private static String scalar(String prim, String json, String where) { + String read = CODEC + ".readLong(" + json + ", " + where + ", "; + if ("int".equals(prim)) { + return "(int) " + read + "Integer.MIN_VALUE, Integer.MAX_VALUE)"; + } + if ("long".equals(prim)) { + return read + "Long.MIN_VALUE, Long.MAX_VALUE)"; + } + if ("short".equals(prim)) { + return "(short) " + read + "Short.MIN_VALUE, Short.MAX_VALUE)"; + } + if ("byte".equals(prim)) { + return "(byte) " + read + "Byte.MIN_VALUE, Byte.MAX_VALUE)"; + } + if ("double".equals(prim)) { + return CODEC + ".readDouble(" + json + ", " + where + ")"; + } + if ("float".equals(prim)) { + return "(float) " + CODEC + ".readDouble(" + json + ", " + where + ")"; + } + if ("boolean".equals(prim)) { + return CODEC + ".readBoolean(" + json + ", " + where + ")"; + } + return CODEC + ".readChar(" + json + ", " + where + ")"; + } + + private static String unbox(String box) { + if ("java.lang.Integer".equals(box)) { + return "int"; + } + if ("java.lang.Character".equals(box)) { + return "char"; + } + return box.substring("java.lang.".length()).toLowerCase(java.util.Locale.ROOT); + } + + private String enumChoices(String type) { + AnnotatedClass cls = RestControllerAnnotationProcessor.resolveClass(ctx, + raw(type).replace('.', '/')); + StringBuilder out = new StringBuilder("one of"); + String sep = " "; + if (cls != null) { + for (FieldInfo f : cls.getFields()) { + if ((f.getAccess() & Opcodes.ACC_ENUM) != 0) { + out.append(sep).append(f.getName()); + sep = ", "; + } + } + } + return out.toString(); + } + + /// `var`, an Object, as a value of `type` for the write code: cast only where + /// that code needs the static type, and each cast is of a value the typed + /// collection it came out of holds. + private String cast(String type, String var) { + Kind kind = kindOf(type); + switch (kind) { + case DATE: + case BYTES: + case ENUM: + case DTO: + return "((" + source(raw(type)) + ") " + var + ")"; + case COLLECTION: + return "((java.util.Collection) " + var + ")"; + case MAP: + return "((java.util.Map) " + var + ")"; + default: + return var; + } + } + + // ------------------------------------------------------------------ + // Codec classes + // ------------------------------------------------------------------ + + /// The codec classes the checks above asked for, by binary name, and the + /// value dispatcher when one of them needs it. + Map sources() { + Map out = new LinkedHashMap(); + for (Dto dto : dtos.values()) { + if (dto.problem != null || (!dto.write && !dto.read)) { + continue; + } + out.put(codecBinary(dto.binary), codecSource(dto)); + } + if (valuesUsed) { + out.put(VALUES, valuesSource()); + } + return out; + } + + /// `cn1app.JsonValues`: writes a value by its run-time class. Each of the + /// application's classes this build writes goes through its codec, the JDK's + /// JSON shapes through Json, and anything else is refused with an exception + /// the server answers with 500 -- never written as its toString(), which + /// would ship a quoted class name as data. + private String valuesSource() { + StringBuilder sb = new StringBuilder(); + sb.append("package cn1app;\n\n"); + sb.append("// Generated: writes a value by what it is. Do not edit.\n"); + sb.append("@com.codename1.backend.annotations.Generated\n"); + sb.append("public final class JsonValues {\n"); + sb.append(" private JsonValues() {\n }\n\n"); + sb.append(" public static void write(Object v, ").append(SINK) + .append(" out, int depth) {\n"); + sb.append(" if (v == null) {\n"); + sb.append(" out.putAscii(\"null\");\n"); + sb.append(" return;\n"); + sb.append(" }\n"); + sb.append(" if (depth > ").append(CODEC).append(".MAX_DEPTH) {\n"); + sb.append(" throw ").append(CODEC).append(".tooDeep(\"value\");\n"); + sb.append(" }\n"); + for (Dto dto : dtos.values()) { + if (dto.problem != null || !dto.write) { + continue; + } + // Each codec dispatches to its own subclasses, so the order here does + // not decide which fields a subclass instance is written with. + String type = source(dto.binary); + sb.append(" if (v instanceof ").append(type).append(") {\n"); + sb.append(" ").append(codecBinary(dto.binary)).append(".write((") + .append(type).append(") v, out, depth);\n"); + sb.append(" return;\n"); + sb.append(" }\n"); + } + sb.append(" if (v instanceof java.util.Map) {\n"); + sb.append(" out.put('{');\n"); + sb.append(" boolean first = true;\n"); + sb.append(" for (java.util.Iterator it = ((java.util.Map) v).entrySet().iterator(); " + + "it.hasNext();) {\n"); + sb.append(" java.util.Map.Entry e = (java.util.Map.Entry) it.next();\n"); + sb.append(" if (first) {\n"); + sb.append(" first = false;\n"); + sb.append(" } else {\n"); + sb.append(" out.put(',');\n"); + sb.append(" }\n"); + sb.append(" ").append(JSON) + .append(".writeString(String.valueOf(e.getKey()), out);\n"); + sb.append(" out.put(':');\n"); + sb.append(" write(e.getValue(), out, depth + 1);\n"); + sb.append(" }\n"); + sb.append(" out.put('}');\n"); + sb.append(" return;\n"); + sb.append(" }\n"); + sb.append(" if (v instanceof java.util.Collection) {\n"); + sb.append(" out.put('[');\n"); + sb.append(" boolean first = true;\n"); + sb.append(" for (java.util.Iterator it = ((java.util.Collection) v).iterator(); " + + "it.hasNext();) {\n"); + sb.append(" if (first) {\n"); + sb.append(" first = false;\n"); + sb.append(" } else {\n"); + sb.append(" out.put(',');\n"); + sb.append(" }\n"); + sb.append(" write(it.next(), out, depth + 1);\n"); + sb.append(" }\n"); + sb.append(" out.put(']');\n"); + sb.append(" return;\n"); + sb.append(" }\n"); + sb.append(" if (v instanceof java.util.Date) {\n"); + sb.append(" ").append(CODEC).append(".writeDate((java.util.Date) v, out);\n"); + sb.append(" return;\n"); + sb.append(" }\n"); + sb.append(" if (v instanceof Enum) {\n"); + sb.append(" ").append(JSON).append(".writeString(((Enum) v).name(), out);\n"); + sb.append(" return;\n"); + sb.append(" }\n"); + sb.append(" if (v instanceof String || v instanceof Number || v instanceof Boolean\n"); + sb.append(" || v instanceof Character || v instanceof byte[]\n"); + sb.append(" || v instanceof ").append(JSON).append(".Writable) {\n"); + sb.append(" ").append(JSON).append(".writeValue(v, out);\n"); + sb.append(" return;\n"); + sb.append(" }\n"); + sb.append(" throw new IllegalStateException(v.getClass().getName() + \" has no JSON \"\n"); + sb.append(" + \"form: it is not one of the classes this build writes a codec \"\n"); + sb.append(" + \"for. Declare the field or element with its type.\");\n"); + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + private String codecSource(Dto dto) { + String pkg = packageOf(dto.binary); + String simple = simpleOf(codecBinary(dto.binary)); + String type = source(dto.binary); + StringBuilder sb = new StringBuilder(); + if (pkg.length() > 0) { + sb.append("package ").append(pkg).append(";\n\n"); + } + sb.append("// Generated: the JSON form of ").append(type).append(". Do not edit.\n"); + sb.append("@com.codename1.backend.annotations.Generated\n"); + sb.append("public final class ").append(simple).append(" {\n"); + sb.append(" private ").append(simple).append("() {\n }\n"); + if (dto.write) { + sb.append("\n public static void write(").append(type).append(" v, ").append(SINK) + .append(" out, int depth) {\n"); + sb.append(" if (v == null) {\n"); + sb.append(" out.putAscii(\"null\");\n"); + sb.append(" return;\n"); + sb.append(" }\n"); + sb.append(" if (depth > ").append(CODEC).append(".MAX_DEPTH) {\n"); + sb.append(" throw ").append(CODEC).append(".tooDeep(") + .append(quote(simpleOf(dto.binary))).append(");\n"); + sb.append(" }\n"); + for (String sub : dto.subclasses) { + String subSource = source(sub); + sb.append(" if (v instanceof ").append(subSource).append(") {\n"); + sb.append(" ").append(codecSource(sub)).append(".write((") + .append(subSource).append(") v, out, depth);\n"); + sb.append(" return;\n"); + sb.append(" }\n"); + } + if (dto.ownWriter) { + sb.append(" v.writeTo(out);\n"); + } else { + sb.append(" out.put('{');\n"); + boolean first = true; + for (Prop p : dto.props) { + String get = p.get; + if (get == null) { + continue; + } + sb.append(" "); + if (plainName(p.json)) { + sb.append("out.putAscii(\"").append(first ? "" : ",").append("\\\"") + .append(p.json).append("\\\":\");\n"); + } else { + if (!first) { + sb.append("out.put(',');\n "); + } + sb.append(JSON).append(".writeString(").append(quote(p.json)) + .append(", out);\n out.put(':');\n"); + } + write(sb, strip(p.type), get, "depth", " "); + first = false; + } + sb.append(" out.put('}');\n"); + } + sb.append(" }\n"); + } + if (dto.read && dto.readProblem == null) { + sb.append("\n public static ").append(type).append(" read(Object json, ") + .append(CODEC).append(".Path at, String name, int index, int depth) {\n"); + sb.append(" if (json == null) {\n"); + sb.append(" return null;\n"); + sb.append(" }\n"); + sb.append(" if (!(json instanceof java.util.Map) || depth > ").append(CODEC) + .append(".MAX_DEPTH) {\n"); + sb.append(" throw ").append(CODEC) + .append(".mismatch(at, name, index, \"an object\", json);\n"); + sb.append(" }\n"); + sb.append(" java.util.Map m = (java.util.Map) json;\n"); + sb.append(" ").append(CODEC).append(".Path here = ").append(CODEC) + .append(".enter(at, name, index);\n"); + sb.append(" ").append(type).append(" v = new ").append(type).append("();\n"); + for (Prop p : dto.props) { + boolean direct = p.field != null; + if (!direct && p.setter == null) { + continue; + } + String t = strip(p.type); + String j = fresh("j"); + String tmp = fresh("t"); + boolean primitive = PRIMITIVES.contains(t); + sb.append(" Object ").append(j).append(" = m.get(").append(quote(p.json)) + .append(");\n"); + if (primitive) { + // null, like an absent member, leaves the field's default -- + // Jackson's answer for a primitive too. + String value = scalar(t, j, "here, " + quote(p.json) + ", -1"); + sb.append(" if (").append(j).append(" != null) {\n"); + sb.append(" ").append(direct ? "v." + p.field + " = " + value + ";" + : "v." + p.setter + "(" + value + ");").append("\n"); + sb.append(" }\n"); + continue; + } + sb.append(" if (").append(j).append(" != null || m.containsKey(") + .append(quote(p.json)).append(")) {\n"); + sb.append(" ").append(source(t)).append(' ').append(tmp).append(" = ") + .append(defaultOf(t)).append(";\n"); + read(sb, t, j, "here", quote(p.json), "-1", "depth", tmp, " "); + sb.append(" ").append(direct ? "v." + p.field + " = " + tmp + ";" + : "v." + p.setter + "(" + tmp + ");").append("\n"); + sb.append(" }\n"); + } + sb.append(" return v;\n"); + sb.append(" }\n"); + } + sb.append("}\n"); + return sb.toString(); + } + + private static boolean plainName(String name) { + if (name.length() == 0) { + return false; + } + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') + && c != '_' && c != '-' && c != '$' && c != '.') { + return false; + } + } + return true; + } + + private static String defaultOf(String type) { + if ("boolean".equals(type)) { + return "false"; + } + if ("char".equals(type)) { + return "'\\0'"; + } + return PRIMITIVES.contains(type) ? "0" : "null"; + } + + private static String codecBinary(String binary) { + String pkg = packageOf(binary); + String simple = binary.substring(pkg.length() == 0 ? 0 : pkg.length() + 1) + .replace('$', '_') + "Cn1Json"; + return pkg.length() == 0 ? simple : pkg + "." + simple; + } + + private static String codecSource(String binary) { + return codecBinary(binary); + } + + private String fresh(String prefix) { + return prefix + (counter++) + "$"; + } + + // ------------------------------------------------------------------ + // Type strings + // ------------------------------------------------------------------ + + /// The type with a `? extends` bound reduced to the bound; null for a bare + /// wildcard or a `? super` one, which name nothing to write code for. + private static String strip(String type) { + if (type == null) { + return null; + } + String t = type.trim(); + if (t.startsWith("? extends ")) { + return strip(t.substring("? extends ".length())); + } + if (t.startsWith("?")) { + return null; + } + return t; + } + + private static String raw(String type) { + int lt = type.indexOf('<'); + return lt < 0 ? type : type.substring(0, lt); + } + + private static List args(String type) { + int lt = type.indexOf('<'); + int gt = type.lastIndexOf('>'); + if (lt < 0 || gt <= lt) { + return Collections.emptyList(); + } + return RestControllerAnnotationProcessor.splitTypeArguments(type.substring(lt + 1, gt)); + } + + /// The `i`th type argument, or Object for a raw declaration. + private static String elementType(String type, int i) { + List a = args(type); + if (a.size() <= i) { + return "java.lang.Object"; + } + String s = strip(a.get(i)); + return s == null ? "java.lang.Object" : s; + } + + /// A type as Java source spells it: nested classes with a dot. + static String source(String type) { + return type.replace('$', '.'); + } + + private static String packageOf(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? "" : binary.substring(0, dot); + } + + private static String simpleOf(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? binary : binary.substring(dot + 1); + } + + private static String quote(String s) { + StringBuilder out = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') { + out.append('\\').append(c); + } else if (c < 0x20 || c > 0x7e) { + out.append(String.format("\\u%04x", Integer.valueOf(c))); + } else { + out.append(c); + } + } + return out.append('"').toString(); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSettings.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSettings.java new file mode 100644 index 00000000000..0fd7fd709ba --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSettings.java @@ -0,0 +1,276 @@ +/* + * 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. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.ProcessorContext; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The settings annotations -- `@ServerConfig`, `@SessionConfig`, +/// `@DataSourceConfig`, `@StaticFilesConfig`, `@EnableManagement`, +/// `@EnableMcpServer` and the exporter settings on `@OpenTelemetry` -- turned +/// into what the generated entry point passes the runtime: key and value pairs +/// for the bottom layer of its configuration, and whether to link the +/// management and MCP endpoints at all. +/// +/// Every attribute is one `cn1.*` key, so an annotation is a compiled-in +/// `application.properties` line and nothing more: the files and the environment +/// still override it, and a key the runtime would refuse is refused here first, +/// where the message can name the class. +final class BackendSettings { + private static final String PKG = "Lcom/codename1/backend/annotations/"; + static final String SERVER = PKG + "ServerConfig;"; + static final String SESSION = PKG + "SessionConfig;"; + static final String DATA_SOURCE = PKG + "DataSourceConfig;"; + static final String STATIC_FILES = PKG + "StaticFilesConfig;"; + static final String ENABLE_MANAGEMENT = PKG + "EnableManagement;"; + static final String ENABLE_MCP = PKG + "EnableMcpServer;"; + + /// Key and value pairs, in the order they were found. + final Map values = new LinkedHashMap(); + /// Which class set each key, for a conflict's message. + private final Map owners = new LinkedHashMap(); + /// Whether the build asked for the management endpoints. + boolean management; + /// Whether the build asked for the MCP endpoint, tools aside. + boolean mcp; + + private final ProcessorContext ctx; + + private BackendSettings(ProcessorContext ctx) { + this.ctx = ctx; + } + + /// Reads every settings annotation in the module, reporting conflicts and + /// values the runtime would refuse through `ctx`. + static BackendSettings resolve(ProcessorContext ctx) { + BackendSettings out = new BackendSettings(ctx); + for (AnnotatedClass cls : ctx.getClassIndex().values()) { + // The orphan rule every other annotation gets: a class whose source + // was deleted left its .class behind and must not keep a setting. + if (!BuildHintAnnotationProcessor.hasBackingSource(cls, ctx.getCompileSourceRoots(), + ctx.getSourceEncoding())) { + continue; + } + out.read(cls); + } + out.management |= RestControllerAnnotationProcessor.applicationPropertyTrue(ctx, + "cn1.management.enabled"); + out.mcp |= RestControllerAnnotationProcessor.applicationPropertyTrue(ctx, + "cn1.mcp.enabled"); + return out; + } + + /// The pairs as the flat array the runtime takes. + List flat() { + List out = new ArrayList(); + for (Map.Entry e : values.entrySet()) { + out.add(e.getKey()); + out.add(e.getValue()); + } + return out; + } + + private void read(AnnotatedClass cls) { + AnnotationValues a = cls.getClassAnnotation(SERVER); + if (a != null) { + port(cls, a); + positive(cls, a, "workers", "cn1.server.workers"); + positive(cls, a, "backlog", "cn1.server.backlog"); + nonNegative(cls, a, "shutdownTimeoutMillis", "cn1.server.shutdownTimeoutMillis"); + } + a = cls.getClassAnnotation(SESSION); + if (a != null) { + oneOf(cls, a, "store", "cn1.session.store", "memory", "db"); + nonNegative(cls, a, "timeoutSeconds", "cn1.session.timeout"); + string(cls, a, "cookie", "cn1.session.cookie"); + oneOf(cls, a, "sameSite", "cn1.session.same-site", "Lax", "Strict", "None"); + oneOf(cls, a, "secure", "cn1.session.secure", "auto", "true", "false"); + string(cls, a, "namespace", "cn1.session.namespace"); + } + a = cls.getClassAnnotation(DATA_SOURCE); + if (a != null) { + string(cls, a, "url", "cn1.datasource.url"); + positive(cls, a, "poolSize", "cn1.datasource.pool.size"); + nonNegative(cls, a, "borrowTimeoutMillis", "cn1.datasource.pool.borrowTimeoutMillis"); + nonNegative(cls, a, "busyTimeoutMillis", "cn1.datasource.busyTimeoutMillis"); + } + a = cls.getClassAnnotation(STATIC_FILES); + if (a != null) { + string(cls, a, "root", "cn1.static.root"); + path(cls, a, "prefix", "cn1.static.prefix"); + string(cls, a, "index", "cn1.static.index"); + string(cls, a, "cacheControl", "cn1.static.cacheControl"); + } + a = cls.getClassAnnotation(ENABLE_MANAGEMENT); + if (a != null) { + management = true; + set(cls, "cn1.management.enabled", "true"); + path(cls, a, "path", "cn1.management.path"); + } + a = cls.getClassAnnotation(ENABLE_MCP); + if (a != null) { + // Linked, not forced on: the endpoint's own default -- on when the + // server has a tool to serve -- still decides, as without the + // annotation. + mcp = true; + path(cls, a, "path", "cn1.mcp.path"); + List origins = strings(a.get("allowedOrigins")); + if (!origins.isEmpty()) { + StringBuilder joined = new StringBuilder(); + for (String o : origins) { + if (o.indexOf(',') >= 0) { + ctx.error(cls, "@EnableMcpServer allowedOrigins names \"" + o + + "\"; an origin has no comma in it. List each one separately."); + return; + } + if (joined.length() > 0) { + joined.append(','); + } + joined.append(o); + } + set(cls, "cn1.mcp.allowedOrigins", joined.toString()); + } + } + a = cls.getClassAnnotation(RestControllerAnnotationProcessor.OPEN_TELEMETRY); + if (a != null) { + string(cls, a, "endpoint", "cn1.otel.endpoint"); + oneOf(cls, a, "protocol", "cn1.otel.protocol", "http/protobuf", "http/json"); + string(cls, a, "sampler", "cn1.otel.sampler"); + string(cls, a, "samplerArg", "cn1.otel.sampler.arg"); + } + } + + private void string(AnnotatedClass cls, AnnotationValues a, String attribute, String key) { + String v = a.getStringOrDefault(attribute, "").trim(); + if (v.length() > 0) { + set(cls, key, v); + } + } + + private void path(AnnotatedClass cls, AnnotationValues a, String attribute, String key) { + String v = a.getStringOrDefault(attribute, "").trim(); + if (v.length() == 0) { + return; + } + if (!v.startsWith("/")) { + ctx.error(cls, "@" + simpleName(a) + " " + attribute + " is \"" + v + + "\"; a path starts with /."); + return; + } + set(cls, key, v); + } + + private void oneOf(AnnotatedClass cls, AnnotationValues a, String attribute, String key, + String... allowed) { + String v = a.getStringOrDefault(attribute, "").trim(); + if (v.length() == 0) { + return; + } + for (String candidate : allowed) { + if (candidate.equalsIgnoreCase(v)) { + set(cls, key, candidate); + return; + } + } + StringBuilder list = new StringBuilder(); + for (int i = 0; i < allowed.length; i++) { + list.append(i == 0 ? "" : i == allowed.length - 1 ? " or " : ", ").append(allowed[i]); + } + ctx.error(cls, "@" + simpleName(a) + " " + attribute + " is \"" + v + "\"; it must be " + + list + "."); + } + + private void port(AnnotatedClass cls, AnnotationValues a) { + int v = a.getIntOrDefault("port", -1); + if (v == -1) { + return; + } + if (v < 0 || v > 65535) { + ctx.error(cls, "@ServerConfig port is " + v + "; a port is 0 to 65535."); + return; + } + set(cls, "cn1.server.port", String.valueOf(v)); + } + + private void positive(AnnotatedClass cls, AnnotationValues a, String attribute, String key) { + int v = a.getIntOrDefault(attribute, -1); + if (v == -1) { + return; + } + if (v < 1) { + ctx.error(cls, "@" + simpleName(a) + " " + attribute + " is " + v + + "; it must be at least 1."); + return; + } + set(cls, key, String.valueOf(v)); + } + + private void nonNegative(AnnotatedClass cls, AnnotationValues a, String attribute, String key) { + int v = a.getIntOrDefault(attribute, -1); + if (v == -1) { + return; + } + if (v < 0) { + ctx.error(cls, "@" + simpleName(a) + " " + attribute + " is " + v + + "; it can't be negative."); + return; + } + set(cls, key, String.valueOf(v)); + } + + /// One key, from one class. Two classes giving the same key different values + /// is refused rather than resolved by scan order, which nobody can see. + private void set(AnnotatedClass cls, String key, String value) { + String existing = values.get(key); + if (existing != null && !existing.equals(value)) { + ctx.error(cls, key + " is set to \"" + value + "\" here and to \"" + existing + + "\" on " + owners.get(key) + ". Keep one of them."); + return; + } + values.put(key, value); + owners.put(key, cls.getBinaryName()); + } + + private static String simpleName(AnnotationValues a) { + String d = a.getDescriptor(); + int slash = d.lastIndexOf('/'); + return d.substring(slash + 1, d.length() - 1); + } + + private static List strings(Object value) { + List out = new ArrayList(); + if (value instanceof List) { + for (Object o : (List) value) { + if (o instanceof String && ((String) o).trim().length() > 0) { + out.add(((String) o).trim()); + } + } + } + return out; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSources.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSources.java new file mode 100644 index 00000000000..96a2be7cbdf --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSources.java @@ -0,0 +1,852 @@ +/* + * 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. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.FieldInfo; +import com.codename1.maven.annotations.MethodInfo; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +/// The Java source of the classes generated beside a backend's own: the aspect +/// helpers the woven methods call, the task class of each `@Async` method, the +/// stand-ins for request, session and lazy beans, and the adapters that publish +/// `@McpTool` and `@ManagedResource` beans. +/// +/// Source rather than bytecode because javac then writes every frame, every +/// boxing conversion and every exception table, and the result reads like code a +/// person could have written -- which is what someone debugging into it sees. +final class BackendSources { + private static final String GENERATED = "@com.codename1.backend.annotations.Generated\n"; + + private final BackendBeans beans; + + BackendSources(BackendBeans beans) { + this.beans = beans; + } + + // ---------------------------------------------------------------- aspects + + /// The helper class of one class's aspects, and the task class of each of its + /// `@Async` methods. + void aspects(BackendBeans.Aspects a, Map out) { + AnnotatedClass cls = a.cls; + String owner = cls.getSourceName(); + StringBuilder sb = header(a.helperBinary, "the aspects of " + cls.getBinaryName()); + sb.append("final class ").append(simple(a.helperBinary)).append(" {\n"); + sb.append(" private ").append(simple(a.helperBinary)).append("() {\n }\n\n"); + for (int index = 0; index < a.methods.size(); index++) { + BackendBeans.Aspect aspect = a.methods.get(index); + MethodInfo m = aspect.method; + boolean isStatic = m.isStatic(); + Type[] args = Type.getArgumentTypes(m.getDescriptor()); + Type ret = Type.getReturnType(m.getDescriptor()); + boolean isVoid = ret.getSort() == Type.VOID; + String retType = isVoid ? "void" : typeName(ret); + String params = parameters(owner, isStatic, args); + String callArgs = arguments(isStatic, args.length); + String inner = (isStatic ? owner : "self") + "." + + BackendWeaver.bodyName(cls.getInternalName(), m.getName()) + + "(" + plainArguments(args.length) + ")"; + List layers = new ArrayList(); + if (aspect.transactional != null) { + layers.add("tx"); + } + if (aspect.timed != null || aspect.counted != null) { + layers.add("metrics"); + } + for (int i = 0; i < layers.size(); i++) { + boolean outermost = i == layers.size() - 1 && aspect.async == null; + String name = outermost ? m.getName() : m.getName() + "$cn1" + layers.get(i); + sb.append(" static ").append(retType).append(' ').append(name).append('(') + .append(params).append(") throws Throwable {\n"); + if ("tx".equals(layers.get(i))) { + transaction(sb, aspect, inner, isVoid, retType); + } else { + metrics(sb, cls, aspect, index, inner, isVoid, retType); + } + sb.append(" }\n\n"); + inner = simple(a.helperBinary) + "." + name + "(" + callArgs + ")"; + } + if (aspect.async != null) { + async(sb, cls, aspect, index, params, callArgs, isVoid, retType, m); + out.put(aspect.asyncTaskBinary, task(cls, aspect, args, inner, isVoid)); + } + } + sb.append("}\n"); + out.put(a.helperBinary, flushMembers(sb.toString())); + } + + private void transaction(StringBuilder sb, BackendBeans.Aspect aspect, String inner, + boolean isVoid, String retType) { + AnnotationValues tx = aspect.transactional; + String propagation = BackendBeans.enumName(tx.get("propagation"), "REQUIRED"); + sb.append(" com.codename1.backend.Transactions.Transaction cn1Tx =\n") + .append(" com.codename1.backend.Transactions.begin(") + .append("com.codename1.backend.Transactions.").append(propagation).append(", ") + .append(tx.getBoolOrDefault("readOnly", false)).append(", ") + .append(tx.getIntOrDefault("timeout", -1)).append(");\n"); + if (!isVoid) { + sb.append(" ").append(retType).append(" cn1Result;\n"); + } + sb.append(" try {\n"); + sb.append(" ").append(isVoid ? "" : "cn1Result = ").append(inner) + .append(";\n"); + sb.append(" } catch (Throwable cn1Error) {\n"); + sb.append(" com.codename1.backend.Transactions.afterThrow(cn1Tx, ") + .append(rollbackDecision(tx)).append(");\n"); + sb.append(" throw cn1Error;\n"); + sb.append(" }\n"); + sb.append(" com.codename1.backend.Transactions.commit(cn1Tx);\n"); + if (!isVoid) { + sb.append(" return cn1Result;\n"); + } + } + + /// Spring's rollback rule as one expression over `cn1Error`: the listed types, + /// most specific first, then unchecked-rolls-back. + String rollbackDecision(AnnotationValues tx) { + List rules = new ArrayList(); + addRules(rules, tx.get("rollbackFor"), Boolean.TRUE); + addRules(rules, tx.get("noRollbackFor"), Boolean.FALSE); + final Map depth = new java.util.HashMap(); + for (Object[] rule : rules) { + depth.put((String) rule[0], Integer.valueOf(depthOf((String) rule[0]))); + } + Collections.sort(rules, new Comparator() { + public int compare(Object[] a, Object[] b) { + return depth.get((String) b[0]).intValue() - depth.get((String) a[0]).intValue(); + } + }); + StringBuilder sb = new StringBuilder(); + int open = 0; + for (Object[] rule : rules) { + sb.append("cn1Error instanceof ").append(sourceOf((String) rule[0])).append(" ? ") + .append(rule[1]).append(" : ("); + open++; + } + // Spring's default, plus DataAccessException: in Spring a failed statement + // is unchecked and rolls back, and here it is this checked subtype of + // IOException -- so listing it is what keeps the outcome Spring's. + sb.append("cn1Error instanceof java.lang.RuntimeException || cn1Error instanceof " + + "java.lang.Error || cn1Error instanceof " + + "com.codename1.backend.DataAccessException"); + for (int i = 0; i < open; i++) { + sb.append(')'); + } + return sb.toString(); + } + + private static void addRules(List rules, Object value, Boolean rollback) { + if (!(value instanceof List)) { + return; + } + for (Object o : (List) value) { + if (o instanceof Type) { + rules.add(new Object[] {((Type) o).getInternalName(), rollback}); + } + } + } + + /// How many superclasses an exception type has: the more, the more specific. + private int depthOf(String internal) { + int depth = 0; + String current = internal; + while (current != null && !"java/lang/Object".equals(current) && depth < 64) { + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(beans.ctx, current); + if (c != null) { + current = c.getSuperInternalName(); + depth++; + continue; + } + try { + Class jdk = Class.forName(current.replace('/', '.'), false, + BackendSources.class.getClassLoader()); + while (jdk != null) { + depth++; + jdk = jdk.getSuperclass(); + } + } catch (ClassNotFoundException | LinkageError err) { + // Unknown: counted as shallow, which is what an unresolvable type + // most likely is, being an exception from a library. + } + break; + } + return depth; + } + + private void metrics(StringBuilder sb, AnnotatedClass cls, BackendBeans.Aspect aspect, + int index, String inner, boolean isVoid, String retType) { + String base = cls.getBinaryName() + "." + aspect.method.getName(); + if (aspect.timed != null) { + String name = aspect.timed.getStringOrDefault("value", ""); + fieldAccessor(sb, "com.codename1.backend.metrics.Histogram", "T" + index, + "com.codename1.backend.metrics.Metrics.histogram(" + + quote(name.length() > 0 ? name : base + ".duration") + ", " + + quote(aspect.timed.getStringOrDefault("description", "")) + ", \"ms\")"); + } + if (aspect.counted != null) { + String name = aspect.counted.getStringOrDefault("value", ""); + String calls = name.length() > 0 ? name : base + ".calls"; + fieldAccessor(sb, "com.codename1.backend.metrics.Counter", "C" + index, + "com.codename1.backend.metrics.Metrics.counter(" + quote(calls) + ", " + + quote(aspect.counted.getStringOrDefault("description", "")) + ", \"{call}\")"); + fieldAccessor(sb, "com.codename1.backend.metrics.Counter", "F" + index, + "com.codename1.backend.metrics.Metrics.counter(" + quote(calls + ".failures") + + ", \"Calls that threw\", \"{call}\")"); + } + sb.append(" long cn1Start = System.nanoTime();\n"); + sb.append(" boolean cn1Ok = false;\n"); + sb.append(" try {\n"); + if (isVoid) { + sb.append(" ").append(inner).append(";\n"); + sb.append(" cn1Ok = true;\n"); + } else { + sb.append(" ").append(retType).append(" cn1Result = ").append(inner) + .append(";\n"); + sb.append(" cn1Ok = true;\n"); + sb.append(" return cn1Result;\n"); + } + sb.append(" } finally {\n"); + if (aspect.timed != null) { + sb.append(" t").append(index).append("().record((System.nanoTime() - ") + .append("cn1Start) / 1000000.0);\n"); + } + if (aspect.counted != null) { + sb.append(" c").append(index).append("().increment();\n"); + sb.append(" if (!cn1Ok) {\n f").append(index) + .append("().increment();\n }\n"); + } + sb.append(" }\n"); + } + + /// A lazily created instrument: a static field and the accessor that fills it + /// the first time. Emitted inside the method body's class, before it. + private void fieldAccessor(StringBuilder sb, String type, String field, String create) { + // Written as a local class-level member by splicing: the caller is in the + // middle of a method, so the member goes into a separate buffer that the + // class writer appends. Kept simple by emitting them as nested holders. + pendingMembers.append(" private static ").append(type).append(' ').append(field) + .append(";\n\n"); + pendingMembers.append(" static ").append(type).append(' ') + .append(Character.toLowerCase(field.charAt(0))).append(field.substring(1)) + .append("() {\n"); + pendingMembers.append(" ").append(type).append(" value = ").append(field) + .append(";\n"); + pendingMembers.append(" if (value == null) {\n value = ").append(create) + .append(";\n ").append(field).append(" = value;\n }\n"); + pendingMembers.append(" return value;\n }\n\n"); + } + + private final StringBuilder pendingMembers = new StringBuilder(); + + private void async(StringBuilder sb, AnnotatedClass cls, BackendBeans.Aspect aspect, + int index, String params, String callArgs, boolean isVoid, + String retType, MethodInfo m) { + String executor = aspect.async.getStringOrDefault("value", ""); + String thread = BackendBeans.enumName(aspect.async.get("thread"), "PLATFORM"); + // Looked up on every call, never cached in a static: the executor + // belongs to the server the calling thread works for, and two servers + // in one process -- or one started again -- each have their own. + String lookup = "com.codename1.backend.Tasks.executor(" + quote(executor) + + ", com.codename1.backend.Tasks." + thread + ")"; + sb.append(" static ").append(retType).append(' ').append(m.getName()).append('(') + .append(params).append(") throws Throwable {\n"); + sb.append(" ").append(simple(aspect.asyncTaskBinary)).append(" cn1Task = new ") + .append(simple(aspect.asyncTaskBinary)).append('(').append(callArgs).append(");\n"); + sb.append(" ").append(lookup).append(".execute(cn1Task);\n"); + if (!isVoid) { + sb.append(" return cn1Task;\n"); + } + sb.append(" }\n\n"); + } + + private String task(AnnotatedClass cls, BackendBeans.Aspect aspect, Type[] args, + String inner, boolean isVoid) { + MethodInfo m = aspect.method; + boolean isStatic = m.isStatic(); + String owner = cls.getSourceName(); + String name = simple(aspect.asyncTaskBinary); + StringBuilder sb = header(aspect.asyncTaskBinary, "@Async " + cls.getBinaryName() + "." + + m.getName()); + sb.append("final class ").append(name) + .append(" extends com.codename1.backend.AsyncTask {\n"); + if (!isStatic) { + sb.append(" private final ").append(owner).append(" self;\n"); + } + for (int i = 0; i < args.length; i++) { + sb.append(" private final ").append(typeName(args[i])).append(" a").append(i) + .append(";\n"); + } + sb.append("\n ").append(name).append('(').append(parameters(owner, isStatic, args)) + .append(") {\n"); + sb.append(" super(").append(quote(cls.getBinaryName() + "." + m.getName())) + .append(", ").append(isVoid).append(");\n"); + if (!isStatic) { + sb.append(" this.self = self;\n"); + } + for (int i = 0; i < args.length; i++) { + sb.append(" this.a").append(i).append(" = a").append(i).append(";\n"); + } + sb.append(" }\n\n"); + sb.append(" protected Object call() throws Exception {\n"); + sb.append(" try {\n"); + if (isVoid) { + sb.append(" ").append(inner).append(";\n"); + sb.append(" return null;\n"); + } else { + sb.append(" return ").append(inner).append(";\n"); + } + sb.append(" } catch (Exception cn1Error) {\n throw cn1Error;\n"); + sb.append(" } catch (Error cn1Error) {\n throw cn1Error;\n"); + sb.append(" } catch (Throwable cn1Error) {\n"); + sb.append(" throw new RuntimeException(cn1Error);\n }\n"); + sb.append(" }\n}\n"); + return sb.toString(); + } + + // ----------------------------------------------------------------- proxies + + /// The stand-in for a request, session or lazy bean: a subclass that sends + /// every call to the instance its scope holds for the current request, + /// session, or -- for a lazy bean -- the whole server. + String proxy(BackendBeans.Bean b) { + String name = simple(b.proxyBinary); + String type = b.cls.getSourceName(); + StringBuilder sb = header(b.proxyBinary, "the " + (b.lazy ? "lazy" : b.scope) + + " bean " + b.name); + sb.append("public final class ").append(name).append(" extends ").append(type) + .append(" {\n"); + sb.append(" private final com.codename1.backend.Wiring.Scope cn1Scope;\n\n"); + sb.append(" public ").append(name) + .append("(com.codename1.backend.Wiring.Scope scope) {\n"); + sb.append(" super();\n this.cn1Scope = scope;\n }\n\n"); + sb.append(" private ").append(type).append(" cn1Target() {\n"); + sb.append(" return (").append(type).append(") cn1Scope.get(").append(b.slot) + .append(");\n }\n\n"); + String pkg = RestClientAnnotationProcessor.packageOf(b.cls.getBinaryName()); + for (MethodInfo m : beans.proxiedMethods(b.cls, false)) { + boolean isProtected = (m.getAccess() & Opcodes.ACC_PROTECTED) != 0; + if (isProtected && !declaredIn(b.cls, m, pkg)) { + continue; + } + Type[] args = Type.getArgumentTypes(m.getDescriptor()); + Type ret = Type.getReturnType(m.getDescriptor()); + String access = m.isPublic() ? "public " : isProtected ? "protected " : ""; + sb.append(" ").append(access) + .append(ret.getSort() == Type.VOID ? "void" : typeName(ret)).append(' ') + .append(m.getName()).append('('); + for (int i = 0; i < args.length; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(typeName(args[i])).append(" a").append(i); + } + sb.append(')').append(throwsClause(m)).append(" {\n"); + // No scope yet means the stand-in's own constructor is running: the + // bean's constructor called an overridable method, and dispatch + // reached here before cn1Scope was assigned. That call belongs to the + // object being built, so it runs the bean's own code. + String ret0 = ret.getSort() != Type.VOID ? "return " : ""; + sb.append(" if (cn1Scope == null) {\n ").append(ret0) + .append("super.").append(m.getName()).append('(') + .append(plainArguments(args.length)).append(");\n"); + if (ret.getSort() == Type.VOID) { + sb.append(" return;\n"); + } + sb.append(" }\n ").append(ret0).append("cn1Target().") + .append(m.getName()).append('(').append(plainArguments(args.length)) + .append(");\n }\n\n"); + } + sb.append("}\n"); + return sb.toString(); + } + + /// Whether the method is declared in a class of `pkg` -- a protected method + /// inherited from another package cannot be called on another instance. + private boolean declaredIn(AnnotatedClass cls, MethodInfo m, String pkg) { + AnnotatedClass c = cls; + while (c != null) { + if (c.getMethods().contains(m)) { + return RestClientAnnotationProcessor.packageOf(c.getBinaryName()).equals(pkg); + } + String parent = c.getSuperInternalName(); + // The same walk proxiedMethods makes, library classes included. + c = parent == null || "java/lang/Object".equals(parent) ? null + : RestControllerAnnotationProcessor.resolveClass(beans.ctx, parent); + } + return false; + } + + // ------------------------------------------------------------------- tools + + /// The adapter publishing one `@McpTool` method. + String tool(BackendBeans.Bean b, BackendBeans.Tool t) { + String name = simple(t.adapterBinary); + String type = b.cls.getSourceName(); + StringBuilder sb = header(t.adapterBinary, "@McpTool " + b.cls.getBinaryName() + "." + + t.method.getName()); + sb.append("public final class ").append(name) + .append(" implements com.codename1.backend.mcp.McpTool {\n"); + sb.append(" private final ").append(type).append(" target;\n\n"); + sb.append(" public ").append(name).append('(').append(type) + .append(" target) {\n this.target = target;\n }\n\n"); + sb.append(" public String name() {\n return ").append(quote(t.name)) + .append(";\n }\n\n"); + sb.append(" public String description() {\n return ") + .append(quote(t.description)).append(";\n }\n\n"); + Type[] args = Type.getArgumentTypes(t.method.getDescriptor()); + sb.append(" public java.util.Map inputSchema() {\n"); + sb.append(" java.util.Map properties = new java.util.LinkedHashMap();\n"); + StringBuilder required = new StringBuilder(); + for (int i = 0; i < args.length; i++) { + sb.append(" properties.put(").append(quote(t.paramNames.get(i))) + .append(", com.codename1.backend.mcp.McpArgs.property(") + .append(quote(jsonType(args[i]))).append(", ") + .append(quote(t.paramDescriptions.get(i))).append(", ") + .append(enumConstants(args[i])).append("));\n"); + if (t.paramRequired.get(i).booleanValue()) { + if (required.length() > 0) { + required.append(", "); + } + required.append(quote(t.paramNames.get(i))); + } + } + sb.append(" return com.codename1.backend.mcp.McpArgs.object(properties, ") + .append("new String[] {").append(required).append("});\n }\n\n"); + sb.append(" public Object call(java.util.Map arguments) throws Exception {\n"); + StringBuilder call = new StringBuilder("target.") + .append(BackendBeans.bridged(t.method) ? BackendWeaver.bridge(t.method.getName()) + : t.method.getName()).append('('); + for (int i = 0; i < args.length; i++) { + if (i > 0) { + call.append(", "); + } + call.append(convert(args[i], "arguments", t.paramNames.get(i), + t.paramRequired.get(i).booleanValue())); + } + call.append(')'); + if (Type.getReturnType(t.method.getDescriptor()).getSort() == Type.VOID) { + sb.append(" ").append(call).append(";\n return \"done\";\n"); + } else { + sb.append(" return ").append(call).append(";\n"); + } + sb.append(" }\n}\n"); + return sb.toString(); + } + + /// The expression reading one argument out of a map of arguments, converted. + String convert(Type t, String map, String name, boolean required) { + String args = map + ", " + quote(name) + ", " + required; + String helper = "com.codename1.backend.mcp.McpArgs."; + switch (t.getSort()) { + case Type.BOOLEAN: return helper + "booleanValue(" + args + ")"; + case Type.CHAR: return helper + "charValue(" + args + ")"; + // Range-checked, never cast: a narrowing cast would run the tool + // with a different number than the caller sent. + case Type.BYTE: return helper + "byteValue(" + args + ")"; + case Type.SHORT: return helper + "shortValue(" + args + ")"; + case Type.INT: return helper + "intValue(" + args + ")"; + case Type.LONG: return helper + "longValue(" + args + ")"; + case Type.FLOAT: return helper + "floatValue(" + args + ")"; + case Type.DOUBLE: return helper + "doubleValue(" + args + ")"; + default: + break; + } + String n = t.getInternalName(); + if ("java/lang/String".equals(n)) { + return helper + "string(" + args + ")"; + } + if ("java/lang/Integer".equals(n)) { + return helper + "integerObject(" + args + ")"; + } + if ("java/lang/Long".equals(n)) { + return helper + "longObject(" + args + ")"; + } + if ("java/lang/Double".equals(n)) { + return helper + "doubleObject(" + args + ")"; + } + if ("java/lang/Float".equals(n)) { + return helper + "floatObject(" + args + ")"; + } + if ("java/lang/Short".equals(n)) { + return helper + "shortObject(" + args + ")"; + } + if ("java/lang/Byte".equals(n)) { + return helper + "byteObject(" + args + ")"; + } + if ("java/lang/Boolean".equals(n)) { + return helper + "booleanObject(" + args + ")"; + } + if ("java/lang/Character".equals(n)) { + return helper + "characterObject(" + args + ")"; + } + if ("java/util/Map".equals(n)) { + return helper + "map(" + args + ")"; + } + if ("java/util/List".equals(n) || "java/util/Collection".equals(n)) { + return helper + "list(" + args + ")"; + } + if ("java/lang/Object".equals(n)) { + return helper + "any(" + args + ")"; + } + String type = sourceOf(n); + return "(" + type + ") " + helper + "enumValue(" + type + ".values(), " + args + ")"; + } + + private static String jsonType(Type t) { + switch (t.getSort()) { + case Type.BOOLEAN: return "boolean"; + case Type.BYTE: + case Type.SHORT: + case Type.INT: + case Type.LONG: return "integer"; + case Type.FLOAT: + case Type.DOUBLE: return "number"; + case Type.CHAR: return "string"; + default: + break; + } + String n = t.getInternalName(); + if ("java/lang/Integer".equals(n) || "java/lang/Long".equals(n) + || "java/lang/Short".equals(n) || "java/lang/Byte".equals(n)) { + return "integer"; + } + if ("java/lang/Double".equals(n) || "java/lang/Float".equals(n)) { + return "number"; + } + if ("java/lang/Boolean".equals(n)) { + return "boolean"; + } + if ("java/util/Map".equals(n)) { + return "object"; + } + if ("java/util/List".equals(n) || "java/util/Collection".equals(n)) { + return "array"; + } + if ("java/lang/Object".equals(n)) { + return ""; + } + return "string"; + } + + /// `new String[] {...}` of an enum's constants, or null. + private String enumConstants(Type t) { + if (t.getSort() != Type.OBJECT) { + return "null"; + } + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(beans.ctx, + t.getInternalName()); + if (c == null || !c.isEnum()) { + return "null"; + } + StringBuilder sb = new StringBuilder("new String[] {"); + boolean first = true; + for (FieldInfo f : c.getFields()) { + if ((f.getAccess() & Opcodes.ACC_ENUM) != 0) { + if (!first) { + sb.append(", "); + } + first = false; + sb.append(quote(f.getName())); + } + } + return sb.append('}').toString(); + } + + // ----------------------------------------------------------------- managed + + /// The adapter publishing one `@ManagedResource` bean. + String managed(BackendBeans.Bean b) { + BackendBeans.Managed mg = b.managed; + String name = simple(mg.adapterBinary); + String type = b.cls.getSourceName(); + StringBuilder sb = header(mg.adapterBinary, "@ManagedResource " + b.cls.getBinaryName()); + sb.append("public final class ").append(name) + .append(" implements com.codename1.backend.ManagedBean {\n"); + sb.append(" private final ").append(type).append(" target;\n\n"); + sb.append(" public ").append(name).append('(').append(type) + .append(" target) {\n this.target = target;\n }\n\n"); + sb.append(" public String getObjectName() {\n return ") + .append(quote(mg.objectName)).append(";\n }\n\n"); + sb.append(" public String getDescription() {\n return ") + .append(quote(mg.description)).append(";\n }\n\n"); + sb.append(" public String[] attributeNames() {\n return ") + .append(array(mg.attributeNames)).append(";\n }\n\n"); + sb.append(" public String[] attributeDescriptions() {\n return ") + .append(array(mg.attributeDescriptions)).append(";\n }\n\n"); + sb.append(" public Object readAttribute(int index) throws Exception {\n"); + sb.append(" switch (index) {\n"); + for (int i = 0; i < mg.attributes.size(); i++) { + MethodInfo m = mg.attributes.get(i); + sb.append(" case ").append(i).append(": return target.") + .append(callName(m)).append("();\n"); + } + sb.append(" default: throw new IllegalArgumentException(\"No attribute \" " + + "+ index);\n }\n }\n\n"); + List opNames = new ArrayList(); + for (MethodInfo m : mg.operations) { + opNames.add(m.getName()); + } + sb.append(" public String[] operationNames() {\n return ").append(array(opNames)) + .append(";\n }\n\n"); + sb.append(" public String[] operationDescriptions() {\n return ") + .append(array(mg.operationDescriptions)).append(";\n }\n\n"); + sb.append(" public String[][] operationParameters() {\n return new String[][] {"); + for (int i = 0; i < mg.operationParams.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(array(mg.operationParams.get(i))); + } + sb.append("};\n }\n\n"); + sb.append(" public Object invoke(int index, java.util.Map arguments) throws Exception {\n"); + sb.append(" switch (index) {\n"); + for (int i = 0; i < mg.operations.size(); i++) { + MethodInfo m = mg.operations.get(i); + Type[] args = Type.getArgumentTypes(m.getDescriptor()); + StringBuilder call = new StringBuilder("target.").append(callName(m)).append('('); + for (int a = 0; a < args.length; a++) { + if (a > 0) { + call.append(", "); + } + call.append(convert(args[a], "arguments", mg.operationParams.get(i).get(a), true)); + } + call.append(')'); + sb.append(" case ").append(i).append(":\n"); + if (Type.getReturnType(m.getDescriptor()).getSort() == Type.VOID) { + sb.append(" ").append(call).append(";\n"); + sb.append(" return null;\n"); + } else { + sb.append(" return ").append(call).append(";\n"); + } + } + sb.append(" default: throw new IllegalArgumentException(\"No operation \" " + + "+ index);\n }\n }\n\n"); + sb.append(" /** Publishes the numeric attributes as gauges. */\n"); + sb.append(" public void registerGauges(com.codename1.backend.Backend.Environment " + + "environment) {\n"); + sb.append(" final ").append(type).append(" bean = target;\n"); + for (int i = 0; i < mg.attributes.size(); i++) { + MethodInfo m = mg.attributes.get(i); + String read = gaugeRead(Type.getReturnType(m.getDescriptor()), + "bean." + callName(m) + "()"); + if (read == null) { + continue; + } + sb.append(" environment.registerGauge(") + .append(quote(mg.objectName + "." + mg.attributeNames.get(i))).append(", ") + .append(quote(mg.attributeDescriptions.get(i))).append(", ") + .append(quote(mg.attributeUnits.get(i))).append(",\n") + .append(" new com.codename1.backend.metrics.Gauge.Source() {\n") + .append(" public double read() {\n return ").append(read) + .append(";\n }\n });\n"); + } + sb.append(" }\n}\n"); + return sb.toString(); + } + + /// A double from a getter's value, or null when it has none. + private static String gaugeRead(Type t, String call) { + switch (t.getSort()) { + case Type.BOOLEAN: return call + " ? 1 : 0"; + case Type.BYTE: + case Type.SHORT: + case Type.INT: + case Type.LONG: + case Type.FLOAT: + case Type.DOUBLE: + case Type.CHAR: return call; + case Type.OBJECT: + String n = t.getInternalName(); + if ("java/lang/Boolean".equals(n)) { + return "Boolean.TRUE.equals(" + call + ") ? 1 : 0"; + } + if (n.startsWith("java/lang/") && (n.endsWith("Integer") || n.endsWith("Long") + || n.endsWith("Double") || n.endsWith("Float") || n.endsWith("Short") + || n.endsWith("Byte"))) { + return "com.codename1.backend.mcp.McpArgs.toDouble(" + call + ")"; + } + return null; + default: + return null; + } + } + + private static String callName(MethodInfo m) { + return BackendBeans.bridged(m) ? BackendWeaver.bridge(m.getName()) : m.getName(); + } + + // ----------------------------------------------------------------- helpers + + private StringBuilder header(String binary, String what) { + StringBuilder sb = new StringBuilder(); + String pkg = RestClientAnnotationProcessor.packageOf(binary); + if (pkg.length() > 0) { + sb.append("package ").append(pkg).append(";\n\n"); + } + sb.append("// Generated from ").append(what).append(". Do not edit.\n"); + sb.append(GENERATED); + return sb; + } + + /// Appends the lazily created fields a class's methods asked for, before its + /// closing brace. Called once per class, after its methods. + String flushMembers(String source) { + if (pendingMembers.length() == 0) { + return source; + } + int close = source.lastIndexOf('}'); + String out = source.substring(0, close) + pendingMembers + source.substring(close); + pendingMembers.setLength(0); + return out; + } + + private String parameters(String owner, boolean isStatic, Type[] args) { + StringBuilder sb = new StringBuilder(); + if (!isStatic) { + sb.append(owner).append(" self"); + } + for (int i = 0; i < args.length; i++) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(typeName(args[i])).append(" a").append(i); + } + return sb.toString(); + } + + private static String arguments(boolean isStatic, int count) { + StringBuilder sb = new StringBuilder(); + if (!isStatic) { + sb.append("self"); + } + for (int i = 0; i < count; i++) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append('a').append(i); + } + return sb.toString(); + } + + private static String plainArguments(int count) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < count; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append('a').append(i); + } + return sb.toString(); + } + + private String throwsClause(MethodInfo m) { + if (m.getExceptions().isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(" throws "); + for (int i = 0; i < m.getExceptions().size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(sourceOf(m.getExceptions().get(i))); + } + return sb.toString(); + } + + /// The erased type as Java source writes it. + String typeName(Type t) { + switch (t.getSort()) { + case Type.ARRAY: + StringBuilder sb = new StringBuilder(typeName(t.getElementType())); + for (int i = 0; i < t.getDimensions(); i++) { + sb.append("[]"); + } + return sb.toString(); + case Type.OBJECT: + return sourceOf(t.getInternalName()); + default: + return t.getClassName(); + } + } + + /// A class's name as Java source writes it: the scanner's answer for a class + /// it has seen, which knows nesting from `$` in a name, and dots otherwise. + String sourceOf(String internal) { + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(beans.ctx, internal); + if (c != null) { + return c.getSourceName(); + } + return internal.replace('/', '.').replace('$', '.'); + } + + private static String simple(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? binary : binary.substring(dot + 1); + } + + private static String array(List values) { + StringBuilder sb = new StringBuilder("new String[] {"); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(quote(values.get(i))); + } + return sb.append('}').toString(); + } + + /// A Java string literal, ASCII only: anything else is written as an escape. + static String quote(String value) { + if (value == null) { + return "null"; + } + StringBuilder sb = new StringBuilder("\""); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\').append(c); + } else if (c == '\n') { + sb.append("\\n"); + } else if (c == '\r') { + sb.append("\\r"); + } else if (c == '\t') { + sb.append("\\t"); + } else if (c < 0x20 || c > 0x7e) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + return sb.append('"').toString(); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWeaver.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWeaver.java new file mode 100644 index 00000000000..eba9d0baa3d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWeaver.java @@ -0,0 +1,365 @@ +/* + * 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. + */ +package com.codename1.maven.processors; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.FieldVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.TypePath; + +/// Rewrites the compiled classes of a backend module so the generated wiring can +/// reach what it needs without reflection, and so annotated methods carry their +/// aspects. +/// +/// Three rewrites, each decided by the class alone -- so a class that was not +/// recompiled is rewritten the same way every build, and a class that carries the +/// marker field has already been: +/// +/// 1. **Injection setters.** An `@Autowired` or `@Value` field gets a public +/// `cn1$inject$(value)`, which the entry point calls. The field keeps +/// its visibility; nothing but the setter can write it from outside. +/// 2. **Bridges.** A non-public method or constructor the entry point must call +/// -- `@PostConstruct`, a `@Bean` factory, a scheduled job, a package-private +/// constructor -- gets a public `cn1$` twin (a static `cn1$new` for a +/// constructor) that calls it. +/// 3. **Aspects.** A `@Transactional`, `@Async`, `@Timed` or `@Counted` method +/// keeps its name, descriptor, access and annotations, but its body moves to +/// a package-private `$cn1body`; the method itself becomes one static call +/// into `Cn1Aspects`, a class generated as Java source that holds the +/// begin/commit, the task submission or the clock reads. Straight-line +/// bytecode here and javac-compiled code there, so no stack map frame is ever +/// written by hand. +/// +/// Because the method itself carries the aspect rather than a proxy around the +/// object, a self-call, a private method and an instance built with `new` get it +/// too. +final class BackendWeaver { + /// A static field whose presence says a class has been rewritten. + static final String MARKER = "cn1$woven"; + static final String BODY_SUFFIX = "$cn1body"; + + /// The name a woven method's original body moves to: its name, the suffix, + /// and a tag of the class that declares it. The tag is what keeps two woven + /// bodies from overriding each other -- the body is package-private and + /// virtual, so a same-package subclass that overrides a woven method would + /// otherwise override the base class's BODY too, and an explicit + /// super.foo() entered the base stub only to run the subclass body again, + /// recursing until the stack ran out. + static String bodyName(String ownerInternalName, String method) { + // The class's own name, not a hash of it: only a same-package class can + // override a package-private body, and within a package the name is + // unique, where two names can share a hash (p/Aa and p/BB). + return method + BODY_SUFFIX + "_" + + BackendBeans.baseName(ownerInternalName); + } + + /// Whether `name` is a moved body's. + static boolean isBody(String name) { + return name.indexOf(BODY_SUFFIX) >= 0; + } + static final String INJECT_PREFIX = "cn1$inject$"; + static final String BRIDGE_PREFIX = "cn1$"; + static final String NEW_BRIDGE = "cn1$new"; + + /// What to do to one class. + static final class Plan { + final String internalName; + final String helperInternalName; + /// field name -> descriptor + final Map injectFields = new LinkedHashMap(); + /// name + descriptor of non-public methods to bridge + final Set bridges = new LinkedHashSet(); + /// the subset of [#bridges] that are static + final Set staticBridges = new LinkedHashSet(); + /// the subset of [#bridges] that are private, called with INVOKESPECIAL + final Set privateBridges = new LinkedHashSet(); + /// descriptors of non-public constructors to bridge + final Set constructors = new LinkedHashSet(); + /// name + descriptor of methods to give aspects + final Set aspects = new LinkedHashSet(); + + Plan(String internalName, String helperInternalName) { + this.internalName = internalName; + this.helperInternalName = helperInternalName; + } + + boolean isEmpty() { + return injectFields.isEmpty() && bridges.isEmpty() && constructors.isEmpty() + && aspects.isEmpty(); + } + } + + private BackendWeaver() { + } + + /// The name the entry point calls to inject `field`. + static String injectSetter(String field) { + return INJECT_PREFIX + field; + } + + /// The name of a non-public method's public bridge. + static String bridge(String method) { + return BRIDGE_PREFIX + method; + } + + /// Rewrites one class file in place. Answers false when it was already + /// rewritten, or has nothing to rewrite. + static boolean weave(File outputDir, Plan plan) throws IOException { + if (plan.isEmpty()) { + return false; + } + File file = new File(outputDir, plan.internalName + ".class"); + if (!file.isFile()) { + throw new IOException("Cannot weave " + plan.internalName + ": no class file at " + + file); + } + byte[] original = Files.readAllBytes(file.toPath()); + byte[] result = transform(original, plan); + if (result == null) { + return false; + } + Files.write(file.toPath(), result); + return true; + } + + /// The rewritten class, or null when it already carries the marker. + static byte[] transform(byte[] original, final Plan plan) { + ClassReader reader = new ClassReader(original); + final boolean[] marked = {false}; + reader.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public FieldVisitor visitField(int access, String name, String descriptor, + String signature, Object value) { + if (MARKER.equals(name)) { + marked[0] = true; + } + return null; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + if (marked[0]) { + return null; + } + final ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); + final String owner = plan.internalName; + reader.accept(new ClassVisitor(Opcodes.ASM9, writer) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + if (!plan.aspects.contains(name + descriptor)) { + return super.visitMethod(access, name, descriptor, signature, exceptions); + } + // The body, renamed and package-private, without annotations: it is + // an implementation detail, and a second copy of @GetMapping or + // @Scheduled would be read as a second declaration. + // + // `synchronized` moves WITH the body, off the stub. The monitor + // guards the code its author wrote, so it has to be held where + // that code runs: an @Async body runs on an executor thread, and a + // lock the stub took only while enqueueing let two workers run + // the body at once. On the body it also matches Spring for + // @Transactional, whose transaction wraps the synchronized call. + int bodyAccess = access & ~(Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED + | Opcodes.ACC_PRIVATE | Opcodes.ACC_VARARGS | Opcodes.ACC_FINAL); + MethodVisitor body = writer.visitMethod(bodyAccess, bodyName(owner, name), + descriptor, signature, exceptions); + MethodVisitor stub = writer.visitMethod(access & ~Opcodes.ACC_SYNCHRONIZED, + name, descriptor, signature, exceptions); + return new AspectSplitter(body, stub, owner, plan.helperInternalName, access, + name, descriptor); + } + + @Override + public void visitEnd() { + FieldVisitor marker = writer.visitField(Opcodes.ACC_PRIVATE + | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL | Opcodes.ACC_SYNTHETIC, + MARKER, "I", null, Integer.valueOf(1)); + marker.visitEnd(); + for (Map.Entry field : plan.injectFields.entrySet()) { + emitSetter(writer, owner, field.getKey(), field.getValue()); + } + for (String key : plan.bridges) { + int paren = key.indexOf('('); + emitBridge(writer, owner, key.substring(0, paren), key.substring(paren), + plan.staticBridges.contains(key), plan.privateBridges.contains(key)); + } + for (String descriptor : plan.constructors) { + emitConstructorBridge(writer, owner, descriptor); + } + super.visitEnd(); + } + }, 0); + return writer.toByteArray(); + } + + private static void emitSetter(ClassWriter writer, String owner, String field, + String descriptor) { + MethodVisitor m = writer.visitMethod(Opcodes.ACC_PUBLIC, injectSetter(field), + "(" + descriptor + ")V", null, null); + m.visitCode(); + m.visitVarInsn(Opcodes.ALOAD, 0); + m.visitVarInsn(Type.getType(descriptor).getOpcode(Opcodes.ILOAD), 1); + m.visitFieldInsn(Opcodes.PUTFIELD, owner, field, descriptor); + m.visitInsn(Opcodes.RETURN); + m.visitMaxs(0, 0); + m.visitEnd(); + } + + private static void emitBridge(ClassWriter writer, String owner, String name, + String descriptor, boolean isStatic, boolean isPrivate) { + MethodVisitor m = writer.visitMethod(Opcodes.ACC_PUBLIC + | (isStatic ? Opcodes.ACC_STATIC : 0), bridge(name), descriptor, null, null); + m.visitCode(); + int slot = 0; + if (!isStatic) { + m.visitVarInsn(Opcodes.ALOAD, 0); + slot = 1; + } + for (Type arg : Type.getArgumentTypes(descriptor)) { + m.visitVarInsn(arg.getOpcode(Opcodes.ILOAD), slot); + slot += arg.getSize(); + } + // INVOKESPECIAL for a private instance method, as javac emits for class + // files of this age; INVOKEVIRTUAL otherwise, so an override in a + // subclass is the one called, as it would be by a direct call. + m.visitMethodInsn(isStatic ? Opcodes.INVOKESTATIC + : isPrivate ? Opcodes.INVOKESPECIAL : Opcodes.INVOKEVIRTUAL, owner, name, + descriptor, false); + m.visitInsn(Type.getReturnType(descriptor).getOpcode(Opcodes.IRETURN)); + m.visitMaxs(0, 0); + m.visitEnd(); + } + + private static void emitConstructorBridge(ClassWriter writer, String owner, + String descriptor) { + Type[] args = Type.getArgumentTypes(descriptor); + MethodVisitor m = writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, + NEW_BRIDGE, Type.getMethodDescriptor(Type.getObjectType(owner), args), null, + null); + m.visitCode(); + m.visitTypeInsn(Opcodes.NEW, owner); + m.visitInsn(Opcodes.DUP); + int slot = 0; + for (Type arg : args) { + m.visitVarInsn(arg.getOpcode(Opcodes.ILOAD), slot); + slot += arg.getSize(); + } + m.visitMethodInsn(Opcodes.INVOKESPECIAL, owner, "", descriptor, false); + m.visitInsn(Opcodes.ARETURN); + m.visitMaxs(0, 0); + m.visitEnd(); + } + + /// Sends the method's code to the renamed body, its annotations to the stub, + /// and writes the stub's one call when the method ends. + private static final class AspectSplitter extends MethodVisitor { + private final MethodVisitor stub; + private final String owner; + private final String helper; + private final int access; + private final String name; + private final String descriptor; + + AspectSplitter(MethodVisitor body, MethodVisitor stub, String owner, String helper, + int access, String name, String descriptor) { + super(Opcodes.ASM9, body); + this.stub = stub; + this.owner = owner; + this.helper = helper; + this.access = access; + this.name = name; + this.descriptor = descriptor; + } + + @Override + public void visitParameter(String parameterName, int parameterAccess) { + stub.visitParameter(parameterName, parameterAccess); + super.visitParameter(parameterName, parameterAccess); + } + + @Override + public AnnotationVisitor visitAnnotationDefault() { + return stub.visitAnnotationDefault(); + } + + @Override + public AnnotationVisitor visitAnnotation(String desc, boolean visible) { + return stub.visitAnnotation(desc, visible); + } + + @Override + public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, + String desc, boolean visible) { + return stub.visitTypeAnnotation(typeRef, typePath, desc, visible); + } + + @Override + public void visitAnnotableParameterCount(int parameterCount, boolean visible) { + stub.visitAnnotableParameterCount(parameterCount, visible); + } + + @Override + public AnnotationVisitor visitParameterAnnotation(int parameter, String desc, + boolean visible) { + return stub.visitParameterAnnotation(parameter, desc, visible); + } + + @Override + public void visitEnd() { + super.visitEnd(); + boolean isStatic = (access & Opcodes.ACC_STATIC) != 0; + stub.visitCode(); + int slot = 0; + StringBuilder helperDescriptor = new StringBuilder("("); + if (!isStatic) { + stub.visitVarInsn(Opcodes.ALOAD, 0); + helperDescriptor.append('L').append(owner).append(';'); + slot = 1; + } + for (Type arg : Type.getArgumentTypes(descriptor)) { + stub.visitVarInsn(arg.getOpcode(Opcodes.ILOAD), slot); + slot += arg.getSize(); + helperDescriptor.append(arg.getDescriptor()); + } + Type ret = Type.getReturnType(descriptor); + helperDescriptor.append(')').append(ret.getDescriptor()); + stub.visitMethodInsn(Opcodes.INVOKESTATIC, helper, name, + helperDescriptor.toString(), false); + stub.visitInsn(ret.getOpcode(Opcodes.IRETURN)); + stub.visitMaxs(0, 0); + stub.visitEnd(); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java new file mode 100644 index 00000000000..a0b088995dc --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java @@ -0,0 +1,1074 @@ +/* + * 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. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.FieldInfo; +import com.codename1.maven.annotations.MethodInfo; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.objectweb.asm.Type; + +/// Writes `BackendWiring`: the whole dependency injection of a backend, as the +/// code a person would write by hand if they had the patience. +/// +/// ```java +/// b_userRepo = new com.example.UserRepo(Backend.requireDataSource(dataSource, ...)); +/// b_userService = new com.example.UserService(b_userRepo); +/// b_userService.cn1$inject$mailer(b_mailer); +/// b_userService.init(); +/// handlers.add(new com.example.UserControllerRouter(b_userController)); +/// ``` +/// +/// Beans are fields rather than locals only because the server calls back into +/// this object later -- to register websockets, start the scheduled jobs and +/// run the destroy methods -- and those need the same instances. There is no +/// map, no lookup by type or name, and no reflection: every decision was made by +/// [BackendBeans] and is written down here as a constant. +final class BackendWiringWriter { + static final String CLASS_NAME = "BackendWiring"; + + private final BackendBeans model; + private final BackendSources types; + + BackendWiringWriter(BackendBeans model) { + this.model = model; + this.types = new BackendSources(model); + } + + /// One controller's router, for the handler list. + static final class Router { + final String controllerBinary; + final String routerBinary; + + Router(String controllerBinary, String routerBinary) { + this.controllerBinary = controllerBinary; + this.routerBinary = routerBinary; + } + } + + /// The source of `BackendWiring` in `pkg`. + /// + /// @param routers the controllers, with their generated routers, in order + /// @param sockets path -> endpoint binary name + /// @param routes method, path, handler and owning class of every route, for + /// the listing + String write(String pkg, List routers, Map sockets, + List routes) { + StringBuilder sb = new StringBuilder(); + if (pkg.length() > 0) { + sb.append("package ").append(pkg).append(";\n\n"); + } + sb.append("// Generated from the beans of this module. Do not edit.\n"); + sb.append("@com.codename1.backend.annotations.Generated\n"); + sb.append("public final class ").append(CLASS_NAME) + .append(" implements com.codename1.backend.Backend.Application {\n"); + fields(sb); + create(sb, routers); + webSockets(sb, sockets); + started(sb); + sb.append(" public void stopping() {\n"); + sb.append(" if (scheduler != null) {\n scheduler.stop(0);\n }\n"); + sb.append(" }\n\n"); + stopped(sb); + sb.append(" public boolean tracksCurrentRequest() {\n return ") + .append(model.requestSlots + model.sessionSlots > 0).append(";\n }\n\n"); + requestEnded(sb); + sb.append(" public com.codename1.backend.Scheduler getScheduler() {\n") + .append(" return scheduler;\n }\n\n"); + describeBeans(sb); + describeRoutes(sb, routes); + scopes(sb); + prototypes(sb); + sb.append("}\n"); + return sb.toString(); + } + + // ----------------------------------------------------------------- fields + + private void fields(StringBuilder sb) { + sb.append(" private com.codename1.backend.Config config;\n"); + sb.append(" private com.codename1.backend.DataSource dataSource;\n"); + sb.append(" private com.codename1.backend.orm.EntityManager entities;\n"); + sb.append(" private com.codename1.backend.orm.TransactionSession transactionSession;\n"); + sb.append(" private com.codename1.backend.Scheduler scheduler;\n"); + // The controllers and endpoints this start registered, by binary name, + // for describeRoutes(): one whose condition is off serves nothing, and + // listing its routes anyway sent an agent to endpoints that answer 404. + sb.append(" private final java.util.Set active = new java.util.HashSet();\n"); + for (BackendBeans.Bean b : model.beans) { + if (BackendBeans.PROTOTYPE.equals(b.scope)) { + continue; + } + sb.append(" private ").append(typeOf(b)).append(' ').append(b.var).append(";\n"); + if (b.lazy) { + sb.append(" private ").append(typeOf(b)).append(' ').append(b.var) + .append("Real;\n"); + } + } + if (model.requestSlots > 0) { + scopeField(sb, "requestScope", "requestBean"); + } + if (model.sessionSlots > 0) { + scopeField(sb, "sessionScope", "sessionBean"); + } + if (model.lazySlots > 0) { + scopeField(sb, "lazyScope", "lazyBean"); + } + sb.append("\n public ").append(CLASS_NAME).append("() {\n }\n\n"); + } + + private static void scopeField(StringBuilder sb, String field, String method) { + sb.append(" private final com.codename1.backend.Wiring.Scope ").append(field) + .append(" = new com.codename1.backend.Wiring.Scope() {\n") + .append(" public Object get(int slot) {\n") + .append(" return ").append(method).append("(slot);\n") + .append(" }\n };\n"); + } + + // ----------------------------------------------------------------- create + + private void create(StringBuilder sb, List routers) { + sb.append(" public com.codename1.backend.HttpServer.Handler[] create(\n") + .append(" com.codename1.backend.Backend.Environment environment) " + + "throws Exception {\n"); + // Every field back to null first. A builder started again reuses this + // object, and a bean whose condition is off this time -- or a lazy one + // already built -- would otherwise keep the stopped server's destroyed + // instance and hand it to routes, injections, tools and jobs. + sb.append(" synchronized (this) {\n"); + sb.append(" scheduler = null;\n transactionSession = null;\n"); + sb.append(" active.clear();\n"); + for (BackendBeans.Bean b : model.beans) { + if (BackendBeans.PROTOTYPE.equals(b.scope)) { + continue; + } + sb.append(" ").append(b.var).append(" = null;\n"); + if (b.lazy) { + sb.append(" ").append(b.var).append("Real = null;\n"); + } + } + sb.append(" }\n"); + sb.append(" config = environment.getConfig();\n"); + sb.append(" dataSource = environment.getDataSource();\n"); + sb.append(" entities = environment.getEntityManager();\n"); + if (model.needsSession) { + sb.append(" transactionSession = new com.codename1.backend.orm." + + "TransactionSession(\n com.codename1.backend.Backend." + + "requireEntities(entities, \"the injected Session\"));\n"); + } + // Stand-ins first: they hold nothing but their scope, and anything built + // below may be given one. + for (BackendBeans.Bean b : model.beans) { + if (b.isProxied()) { + String scope = b.lazy ? "lazyScope" : BackendBeans.REQUEST.equals(b.scope) + ? "requestScope" : "sessionScope"; + sb.append(" "); + openCondition(sb, b); + sb.append(b.var).append(" = new ").append(b.proxyBinary).append('(').append(scope) + .append(");\n"); + closeCondition(sb, b); + } + } + sb.append(" // Construction, each bean after what its constructor needs.\n"); + for (BackendBeans.Bean b : model.order) { + if (!b.isEager()) { + continue; + } + sb.append(" "); + openCondition(sb, b); + sb.append(b.var).append(" = ").append(construct(b)).append(";\n"); + closeCondition(sb, b); + } + sb.append(" // Members, once everything exists, so fields may form cycles.\n"); + for (BackendBeans.Bean b : model.order) { + if (b.isEager()) { + members(sb, b, b.var, " ", true); + } + } + sb.append(" // Initialization, in dependency order.\n"); + // By EVERY injection -- fields and setters too, not just constructors -- + // so a @PostConstruct never calls a dependency whose own has not run. + // Construction order cannot be used: it only had to follow constructors, + // and a field dependency found later was initialized after its user. + List eager = new ArrayList(); + for (BackendBeans.Bean b : model.order) { + if (b.isEager()) { + eager.add(b); + } + } + for (BackendBeans.Bean b : dependencyOrder(eager)) { + initialize(sb, b, b.var, " ", true); + } + for (BackendBeans.Bean b : model.beans) { + if (b.managed == null && b.tools.isEmpty()) { + continue; + } + // Evaluated once: a prototype's reference is a factory call, and + // naming it in the null test and again below would build -- and + // initialize -- a second instance only to drop the first. + String ref = "exposed" + b.var; + sb.append(" {\n ").append(typeOf(b)).append(' ').append(ref) + .append(" = ").append(reference(b)).append(";\n"); + sb.append(" if (").append(ref).append(" != null) {\n"); + if (b.managed != null) { + sb.append(" ").append(b.managed.adapterBinary).append(" managed") + .append(b.var).append(" = new ").append(b.managed.adapterBinary).append('(') + .append(ref).append(");\n"); + sb.append(" environment.registerManaged(managed") + .append(b.var).append(");\n"); + sb.append(" managed").append(b.var) + .append(".registerGauges(environment);\n"); + } + for (BackendBeans.Tool t : b.tools) { + sb.append(" environment.registerTool(new ") + .append(t.adapterBinary).append('(').append(ref).append("));\n"); + } + sb.append(" }\n }\n"); + } + sb.append(" java.util.List handlers = new java.util.ArrayList();\n"); + for (Router r : routers) { + BackendBeans.Bean b = model.beanOfClass(r.controllerBinary); + String ref = "routed" + b.var; + sb.append(" {\n ").append(typeOf(b)).append(' ').append(ref) + .append(" = ").append(reference(b)).append(";\n"); + sb.append(" if (").append(ref).append(" != null) {\n"); + sb.append(" handlers.add(new ").append(r.routerBinary).append('(').append(ref) + .append("));\n"); + sb.append(" synchronized (this) {\n active.add(") + .append(BackendSources.quote(r.controllerBinary)) + .append(");\n }\n }\n }\n"); + } + sb.append(" com.codename1.backend.HttpServer.Handler[] out =\n") + .append(" new com.codename1.backend.HttpServer.Handler[handlers.size()];\n"); + sb.append(" for (int i = 0; i < out.length; i++) {\n") + .append(" out[i] = (com.codename1.backend.HttpServer.Handler) handlers.get(i);\n") + .append(" }\n"); + sb.append(" return out;\n }\n\n"); + } + + /// `if (condition) {` before a conditional bean's statement, nothing otherwise. + private void openCondition(StringBuilder sb, BackendBeans.Bean b) { + if (!b.isConditional()) { + return; + } + sb.append("if (").append(condition(b)).append(") {\n "); + } + + private void closeCondition(StringBuilder sb, BackendBeans.Bean b) { + if (b.isConditional()) { + sb.append(" }\n"); + } + } + + private String condition(BackendBeans.Bean b) { + List parts = new ArrayList(); + for (String[] group : b.profiles) { + StringBuilder p = new StringBuilder("com.codename1.backend.Wiring.profiles(config, " + + "new String[] {"); + for (int i = 0; i < group.length; i++) { + if (i > 0) { + p.append(", "); + } + p.append(BackendSources.quote(group[i])); + } + parts.add(p.append("})").toString()); + } + for (String[] c : b.propertyConditions) { + parts.add("com.codename1.backend.Wiring.propertyMatches(config, " + + BackendSources.quote(c[0]) + ", " + BackendSources.quote(c[1]) + ", " + + c[2] + ")"); + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.size(); i++) { + if (i > 0) { + sb.append(" && "); + } + sb.append(parts.get(i)); + } + return sb.toString(); + } + + /// The expression that constructs a bean: `new`, the constructor's bridge, or + /// the factory method. + private String construct(BackendBeans.Bean b) { + StringBuilder args = new StringBuilder(); + for (int i = 0; i < b.constructorPoints.size(); i++) { + if (i > 0) { + args.append(", "); + } + args.append(point(b.constructorPoints.get(i))); + } + if (b.factory != null) { + String name = BackendBeans.bridged(b.factory) + ? BackendWeaver.bridge(b.factory.getName()) : b.factory.getName(); + String target = b.owner != null ? reference(b.owner) + : b.factoryOwnerClass.getSourceName(); + // Checked where it is produced: an injection point given the bean + // directly would take the null without a word, and the server would + // report ready and fail on first use, far from the factory at fault. + return "((" + typeOf(b) + ") com.codename1.backend.Wiring.produced(" + target + "." + + name + "(" + args + "), " + + BackendSources.quote("@Bean " + b.factoryOwnerClass.getSourceName() + "." + + b.factory.getName()) + "))"; + } + if (b.constructor.isPublic()) { + return "new " + b.cls.getSourceName() + "(" + args + ")"; + } + return b.cls.getSourceName() + "." + BackendWeaver.NEW_BRIDGE + "(" + args + ")"; + } + + /// Field and setter injection and configuration binding, for one instance. + private void members(StringBuilder sb, BackendBeans.Bean b, String ref, String indent, + boolean guard) { + boolean any = !b.fields.isEmpty() || !b.setters.isEmpty() + || !b.propertySetters.isEmpty(); + if (!any) { + return; + } + String inner = indent; + if (guard) { + sb.append(indent).append("if (").append(ref).append(" != null) {\n"); + inner = indent + " "; + } + for (Map.Entry f : b.fields.entrySet()) { + BackendBeans.Point p = f.getValue(); + if (skippable(p)) { + continue; + } + if (maybeAbsent(p)) { + // Its candidates are all conditional, so whether one is active is + // known only at start-up: assigned only when one is, or the null + // overwrote the field's initializer -- which Spring leaves intact. + sb.append(inner).append("{\n").append(inner).append(" ") + .append(sourceType(p)).append(" v = ").append(point(p)).append(";\n") + .append(inner).append(" if (v != null) {\n").append(inner) + .append(" ").append(ref).append('.') + .append(BackendWeaver.injectSetter(f.getKey().getName())) + .append("(v);\n").append(inner).append(" }\n") + .append(inner).append("}\n"); + continue; + } + sb.append(inner).append(ref).append('.') + .append(BackendWeaver.injectSetter(f.getKey().getName())).append('(') + .append(point(p)).append(");\n"); + } + for (BackendBeans.Call c : b.setters) { + boolean skip = false; + boolean deferred = false; + for (BackendBeans.Point p : c.points) { + skip |= skippable(p); + deferred |= maybeAbsent(p); + } + if (skip) { + continue; + } + if (deferred) { + // As for a field, and as Spring does for an optional method: the + // setter is not called unless every optional argument resolved. + // Each is computed once, in order, so a prototype is built once. + sb.append(inner).append("{\n"); + StringBuilder test = new StringBuilder(); + for (int i = 0; i < c.points.size(); i++) { + BackendBeans.Point p = c.points.get(i); + sb.append(inner).append(" ").append(sourceType(p)).append(" a").append(i) + .append(" = ").append(point(p)).append(";\n"); + if (maybeAbsent(p)) { + test.append(test.length() == 0 ? "" : " && ").append('a').append(i) + .append(" != null"); + } + } + sb.append(inner).append(" if (").append(test).append(") {\n") + .append(inner).append(" ").append(ref).append('.') + .append(callName(c.method)).append('('); + for (int i = 0; i < c.points.size(); i++) { + sb.append(i > 0 ? ", a" : "a").append(i); + } + sb.append(");\n").append(inner).append(" }\n").append(inner).append("}\n"); + continue; + } + sb.append(inner).append(ref).append('.').append(callName(c.method)).append('('); + for (int i = 0; i < c.points.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(point(c.points.get(i))); + } + sb.append(");\n"); + } + for (MethodInfo setter : b.propertySetters) { + String property = setter.getName().substring(3); + String camel = BackendBeans.decapitalize(property); + String key = b.propertiesPrefix.length() == 0 ? camel : b.propertiesPrefix + "." + camel; + String kebab = kebab(camel); + String relaxed = kebab.equals(camel) ? "null" : BackendSources.quote( + b.propertiesPrefix.length() == 0 ? kebab : b.propertiesPrefix + "." + kebab); + Type t = Type.getArgumentTypes(setter.getDescriptor())[0]; + sb.append(inner).append("{\n").append(inner).append(" String value = ") + .append("com.codename1.backend.Wiring.property(config, ") + .append(BackendSources.quote(key)).append(", ").append(relaxed).append(");\n"); + sb.append(inner).append(" if (value != null) {\n").append(inner).append(" ") + .append(ref).append('.').append(setter.getName()).append('(') + .append(convert(t, "value", key)).append(");\n"); + sb.append(inner).append(" }\n").append(inner).append("}\n"); + } + if (guard) { + sb.append(indent).append("}\n"); + } + } + + /// An optional point with nothing to give: the field keeps its initializer and + /// the setter is not called, as Spring does. + private static boolean skippable(BackendBeans.Point p) { + return !p.required && p.candidates.isEmpty() && p.builtin == null && p.value == null + && !p.list; + } + + /// An optional point whose value may be absent at start-up: every candidate + /// it has is conditional (@Profile, @ConditionalOnProperty), so none may be + /// active in the running configuration. + private static boolean maybeAbsent(BackendBeans.Point p) { + if (p.required || p.candidates.isEmpty() || p.builtin != null || p.value != null + || p.list) { + return false; + } + for (BackendBeans.Bean c : p.candidates) { + if (!c.isConditional()) { + return false; + } + } + return true; + } + + /// The Java source name of a point's declared type. + private static String sourceType(BackendBeans.Point p) { + return p.type.getClassName().replace('$', '.'); + } + + /// `@PostConstruct` methods and a factory's init method. + private void initialize(StringBuilder sb, BackendBeans.Bean b, String ref, String indent, + boolean guard) { + if (b.postConstruct.isEmpty() && b.initMethod == null) { + return; + } + String inner = indent; + if (guard) { + sb.append(indent).append("if (").append(ref).append(" != null) {\n"); + inner = indent + " "; + } + for (MethodInfo m : b.postConstruct) { + sb.append(inner).append(ref).append('.').append(callName(m)).append("();\n"); + } + if (b.initMethod != null) { + sb.append(inner).append(ref).append('.').append(b.initMethod).append("();\n"); + } + if (guard) { + sb.append(indent).append("}\n"); + } + } + + /// The expression one injection point receives. + String point(BackendBeans.Point p) { + if (p.value != null) { + return convert(p.type, "com.codename1.backend.Wiring.value(config, " + + BackendSources.quote(p.value) + ", " + BackendSources.quote(p.where) + ")", + p.where); + } + if (p.builtin != null) { + if ("dataSource".equals(p.builtin)) { + return "com.codename1.backend.Backend.requireDataSource(dataSource, " + + BackendSources.quote(p.where) + ")"; + } + if ("entities".equals(p.builtin)) { + return "com.codename1.backend.Backend.requireEntities(entities, " + + BackendSources.quote(p.where) + ")"; + } + if ("session".equals(p.builtin)) { + return "transactionSession"; + } + if ("httpSession".equals(p.builtin)) { + return "request.getSession(true)"; + } + return p.builtin; + } + String type = types.typeName(p.type); + if (p.list) { + StringBuilder sb = new StringBuilder("com.codename1.backend.Wiring.list(new Object[] {"); + for (int i = 0; i < p.candidates.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(reference(p.candidates.get(i))); + } + return sb.append("})").toString(); + } + if (p.candidates.isEmpty()) { + return "null"; + } + if (p.choice || (p.candidates.size() == 1 && p.candidates.get(0).isConditional())) { + StringBuilder sb = new StringBuilder("(").append(type) + .append(") com.codename1.backend.Wiring.") + .append(p.preferFirst ? "preferred" : "single").append("(new Object[] {"); + for (int i = 0; i < p.candidates.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(reference(p.candidates.get(i))); + } + return sb.append("}, ").append(p.required).append(", ") + .append(BackendSources.quote(p.where)).append(')').toString(); + } + return reference(p.candidates.get(0)); + } + + /// How generated code refers to a bean: its field, or a fresh prototype. + private String reference(BackendBeans.Bean b) { + if (BackendBeans.PROTOTYPE.equals(b.scope)) { + return "new" + b.var + "()"; + } + return b.var; + } + + /// Converts a configuration string to `t`. + String convert(Type t, String expression, String where) { + String w = BackendSources.quote(where); + String wiring = "com.codename1.backend.Wiring."; + switch (t.getSort()) { + case Type.BOOLEAN: return wiring + "toBoolean(" + expression + ", " + w + ")"; + case Type.CHAR: return wiring + "toChar(" + expression + ", " + w + ")"; + case Type.BYTE: return wiring + "toByte(" + expression + ", " + w + ")"; + case Type.SHORT: return wiring + "toShort(" + expression + ", " + w + ")"; + case Type.INT: return wiring + "toInt(" + expression + ", " + w + ")"; + case Type.LONG: return wiring + "toLong(" + expression + ", " + w + ")"; + case Type.FLOAT: return wiring + "toFloat(" + expression + ", " + w + ")"; + case Type.DOUBLE: return wiring + "toDouble(" + expression + ", " + w + ")"; + default: + break; + } + String n = t.getInternalName(); + if ("java/lang/String".equals(n)) { + return expression; + } + if ("java/lang/Integer".equals(n)) { + return "Integer.valueOf(" + wiring + "toInt(" + expression + ", " + w + "))"; + } + if ("java/lang/Long".equals(n)) { + return "Long.valueOf(" + wiring + "toLong(" + expression + ", " + w + "))"; + } + if ("java/lang/Boolean".equals(n)) { + return "Boolean.valueOf(" + wiring + "toBoolean(" + expression + ", " + w + "))"; + } + if ("java/lang/Double".equals(n)) { + return "Double.valueOf(" + wiring + "toDouble(" + expression + ", " + w + "))"; + } + if ("java/lang/Float".equals(n)) { + return "Float.valueOf(" + wiring + "toFloat(" + expression + ", " + w + "))"; + } + if ("java/lang/Short".equals(n)) { + return "Short.valueOf(" + wiring + "toShort(" + expression + ", " + w + "))"; + } + if ("java/lang/Byte".equals(n)) { + return "Byte.valueOf(" + wiring + "toByte(" + expression + ", " + w + "))"; + } + if ("java/lang/Character".equals(n)) { + return "Character.valueOf(" + wiring + "toChar(" + expression + ", " + w + "))"; + } + String type = types.sourceOf(n); + return "(" + type + ") " + wiring + "toEnum(" + type + ".values(), " + expression + ", " + + w + ")"; + } + + private static String kebab(String camel) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < camel.length(); i++) { + char c = camel.charAt(i); + if (c >= 'A' && c <= 'Z') { + if (i > 0) { + sb.append('-'); + } + sb.append((char) (c + 32)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static String callName(MethodInfo m) { + return BackendBeans.bridged(m) ? BackendWeaver.bridge(m.getName()) : m.getName(); + } + + private String typeOf(BackendBeans.Bean b) { + return types.sourceOf(b.type); + } + + // ------------------------------------------------------------- websockets + + private void webSockets(StringBuilder sb, Map sockets) { + sb.append(" public void registerWebSockets(\n") + .append(" com.codename1.backend.HttpServer.WebSocketRegistry registry) " + + "throws Exception {\n"); + for (Map.Entry e : sockets.entrySet()) { + BackendBeans.Bean b = model.beanOfClass(e.getValue()); + String ref = reference(b); + sb.append(" if (").append(ref).append(" != null) {\n"); + sb.append(" registry.route(").append(BackendSources.quote(e.getKey())) + .append(", ").append(ref).append(");\n"); + sb.append(" synchronized (this) {\n active.add(") + .append(BackendSources.quote(e.getValue())).append(");\n }\n"); + sb.append(" }\n"); + } + sb.append(" }\n\n"); + } + + // -------------------------------------------------------------- lifecycle + + private void started(StringBuilder sb) { + sb.append(" public void started(com.codename1.backend.Backend backend) " + + "throws Exception {\n"); + if (model.hasJobs()) { + sb.append(" scheduler = new com.codename1.backend.Scheduler(dataSource);\n"); + // Before it starts: whether its runs are measured, and whose tracer + // their spans go to, are this server's. + sb.append(" scheduler.bind(backend);\n"); + int index = 0; + for (BackendBeans.Bean b : model.beans) { + if (b.jobs.isEmpty()) { + continue; + } + String ref = reference(b); + sb.append(" final ").append(typeOf(b)).append(" jobs").append(index) + .append(" = ").append(ref).append(";\n"); + sb.append(" if (jobs").append(index).append(" != null) {\n"); + for (BackendBeans.Job job : b.jobs) { + job(sb, job, "jobs" + index); + } + sb.append(" }\n"); + index++; + } + sb.append(" scheduler.start();\n"); + } + sb.append(" }\n\n"); + } + + private void job(StringBuilder sb, BackendBeans.Job job, String target) { + String where = BackendSources.quote("@Scheduled " + job.name); + String executor = BackendSources.quote(job.executor); + String thread = "com.codename1.backend.Tasks." + job.thread; + String lock = job.lock.length() == 0 ? "null" : BackendSources.quote(job.lock); + String body = "new Runnable() {\n" + + " public void run() {\n" + + " try {\n" + + " " + target + "." + callName(job.method) + "();\n" + + " } catch (RuntimeException err) {\n" + + " throw err;\n" + + " } catch (Exception err) {\n" + + " throw new RuntimeException(err);\n" + + " }\n" + + " }\n" + + " }"; + String name = BackendSources.quote(job.name); + if (job.cron != null) { + String schedule; + if (job.masks != null) { + CronCompiler c = job.masks; + schedule = "new com.codename1.backend.CronSchedule(" + c.seconds + "L, " + c.minutes + + "L, " + c.hours + "L, " + c.daysOfMonth + "L, " + c.months + "L, " + + c.daysOfWeek + "L, " + c.lastDayOfMonth + ", " + + BackendSources.quote(job.zone) + ", " + BackendSources.quote(job.cron) + ")"; + } else { + schedule = "com.codename1.backend.CronSchedule.parse(" + + "com.codename1.backend.Wiring.value(config, " + + BackendSources.quote(job.cron) + ", " + where + "), " + + BackendSources.quote(job.zone) + ")"; + } + sb.append(" scheduler.cron(").append(name).append(", ").append(schedule) + .append(", ").append(executor).append(", ").append(thread).append(", ").append(lock) + .append(", ").append(job.lockAtMostFor).append("L, ").append(body).append(");\n"); + return; + } + boolean rate = job.fixedRate > 0 || job.fixedRateText != null; + String period = rate ? duration(job.fixedRate, job.fixedRateText, where) + : duration(job.fixedDelay, job.fixedDelayText, where); + String initial = duration(job.initialDelay, job.initialDelayText, where); + sb.append(" scheduler.").append(rate ? "fixedRate" : "fixedDelay").append('(') + .append(name).append(", ").append(initial).append(", ").append(period).append(", ") + .append(executor).append(", ").append(thread).append(", ").append(lock).append(", ") + .append(job.lockAtMostFor).append("L, ").append(body).append(");\n"); + } + + private static String duration(long literal, String text, String where) { + if (text != null) { + return "com.codename1.backend.Wiring.toLong(com.codename1.backend.Wiring.value(" + + "config, " + BackendSources.quote(text) + ", " + where + "), " + where + ")"; + } + return literal + "L"; + } + + private void stopped(StringBuilder sb) { + sb.append(" public void stopped() {\n"); + // Eager and lazy singletons in ONE reverse-dependency pass: a lazy bean + // may use an eager one from its @PreDestroy, and an eager one may use a + // lazy one through its stand-in -- destroying all the lazy ones last, or + // first, broke one of the two. + List singletons = new ArrayList(); + for (BackendBeans.Bean b : model.beans) { + if (b.isEager() || b.lazy) { + singletons.add(b); + } + } + List reverse = dependencyOrder(singletons); + java.util.Collections.reverse(reverse); + for (BackendBeans.Bean b : reverse) { + destroy(sb, b, b.lazy ? b.var + "Real" : b.var); + } + sb.append(" }\n\n"); + } + + private void destroy(StringBuilder sb, BackendBeans.Bean b, String ref) { + if (b.preDestroy.isEmpty() && b.destroyMethod == null) { + return; + } + sb.append(" if (").append(ref).append(" != null) {\n"); + for (MethodInfo m : destroyOrder(b)) { + destroyCall(sb, b, ref + "." + callName(m) + "()"); + } + if (b.destroyMethod != null) { + destroyCall(sb, b, ref + "." + b.destroyMethod + "()"); + } + sb.append(" }\n"); + } + + /// The @PreDestroy methods in the order they run: the reverse of the + /// @PostConstruct order, so a subclass releases what it holds before the + /// superclass tears down the state it was built on. + private static List destroyOrder(BackendBeans.Bean b) { + List out = new ArrayList(b.preDestroy); + java.util.Collections.reverse(out); + return out; + } + + private static void destroyCall(StringBuilder sb, BackendBeans.Bean b, String call) { + sb.append(" try {\n ").append(call).append(";\n") + .append(" } catch (Throwable err) {\n") + .append(" com.codename1.backend.Wiring.destroyFailed(") + .append(BackendSources.quote(b.name)).append(", err);\n }\n"); + } + + private void requestEnded(StringBuilder sb) { + scopeEnded(sb, "requestEnded", BackendBeans.REQUEST); + scopeEnded(sb, "sessionEnded", BackendBeans.SESSION); + } + + /// The destroy callbacks of one scope's beans, which the server calls with + /// the array the scope kept them in when the request or session ends. + private void scopeEnded(StringBuilder sb, String method, String scope) { + sb.append(" public void ").append(method).append("(Object[] beans) {\n"); + // Dependents first, as for the singletons: a bean's @PreDestroy may still + // use a same-scope bean it depends on. + List inScope = new ArrayList(); + for (BackendBeans.Bean b : model.beans) { + if (scope.equals(b.scope)) { + inScope.add(b); + } + } + List reverse = dependencyOrder(inScope); + java.util.Collections.reverse(reverse); + for (BackendBeans.Bean b : reverse) { + // A @Bean(destroyMethod = ...) counts as much as @PreDestroy: a + // request-scoped factory bean with only a destroyMethod is exactly + // the resource (a connection, a stream) that must be closed per request. + if (!scope.equals(b.scope) + || (b.preDestroy.isEmpty() && b.destroyMethod == null)) { + continue; + } + String type = typeOf(b); + sb.append(" if (beans.length > ").append(b.slot).append(" && beans[") + .append(b.slot).append("] instanceof ").append(type).append(") {\n"); + sb.append(" ").append(type).append(" bean = (").append(type) + .append(") beans[").append(b.slot).append("];\n"); + for (MethodInfo m : destroyOrder(b)) { + sb.append(" "); + destroyCall(sb, b, "bean." + callName(m) + "()"); + } + if (b.destroyMethod != null) { + sb.append(" "); + destroyCall(sb, b, "bean." + b.destroyMethod + "()"); + } + sb.append(" }\n"); + } + sb.append(" }\n\n"); + } + + // ------------------------------------------------------------- listings + + private void describeBeans(StringBuilder sb) { + sb.append(" public java.util.List describeBeans() {\n"); + sb.append(" java.util.List out = new java.util.ArrayList();\n"); + for (BackendBeans.Bean b : model.beans) { + sb.append(" out.add(bean(").append(BackendSources.quote(b.name)).append(", ") + .append(BackendSources.quote(b.type.replace('/', '.'))).append(", ") + .append(BackendSources.quote(b.lazy ? "singleton (lazy)" : b.scope)).append(", "); + if (b.isConditional()) { + sb.append(BackendSources.quote(conditionText(b))).append(", ") + .append(BackendBeans.PROTOTYPE.equals(b.scope) ? "true" : reference(b) + " != null"); + } else { + sb.append("null, true"); + } + sb.append(", new String[] {"); + List deps = new ArrayList(); + collectDependencies(b, deps); + for (int i = 0; i < deps.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(BackendSources.quote(deps.get(i))); + } + sb.append("}));\n"); + } + sb.append(" return out;\n }\n\n"); + sb.append(" private static java.util.Map bean(String name, String type, String scope,\n") + .append(" String condition, boolean active, String[] dependencies) {\n") + .append(" java.util.Map m = new java.util.LinkedHashMap();\n") + .append(" m.put(\"name\", name);\n m.put(\"type\", type);\n") + .append(" m.put(\"scope\", scope);\n") + .append(" if (condition != null) {\n") + .append(" m.put(\"condition\", condition);\n") + .append(" m.put(\"active\", Boolean.valueOf(active));\n }\n") + .append(" m.put(\"dependencies\", java.util.Arrays.asList(dependencies));\n") + .append(" return m;\n }\n\n"); + } + + private static String conditionText(BackendBeans.Bean b) { + StringBuilder sb = new StringBuilder(); + for (String[] group : b.profiles) { + if (sb.length() > 0) { + sb.append(" and "); + } + sb.append("profile ").append(java.util.Arrays.toString(group)); + } + for (String[] c : b.propertyConditions) { + if (sb.length() > 0) { + sb.append(" and "); + } + sb.append(c[0]).append(c[1].length() > 0 ? "=" + c[1] : " set"); + } + return sb.toString(); + } + + /// `set` ordered so every bean comes after the beans of the set it is + /// injected with -- construction order, which reversed is destruction + /// order. A field or setter cycle, which is legal, is broken where it is met. + private static List dependencyOrder(List set) { + List out = new ArrayList(); + java.util.Set seen = new java.util.HashSet(); + for (BackendBeans.Bean b : set) { + visitDependencies(b, set, seen, out); + } + return out; + } + + private static void visitDependencies(BackendBeans.Bean b, List set, + java.util.Set seen, + List out) { + if (!seen.add(b)) { + return; + } + List points = new ArrayList(b.constructorPoints); + points.addAll(b.fields.values()); + for (BackendBeans.Call c : b.setters) { + points.addAll(c.points); + } + if (b.owner != null && set.contains(b.owner)) { + visitDependencies(b.owner, set, seen, out); + } + for (BackendBeans.Point p : points) { + for (BackendBeans.Bean d : p.candidates) { + if (set.contains(d)) { + visitDependencies(d, set, seen, out); + } + } + } + out.add(b); + } + + private static void collectDependencies(BackendBeans.Bean b, List out) { + List points = new ArrayList(b.constructorPoints); + points.addAll(b.fields.values()); + for (BackendBeans.Call c : b.setters) { + points.addAll(c.points); + } + if (b.owner != null && !out.contains(b.owner.name)) { + out.add(b.owner.name); + } + for (BackendBeans.Point p : points) { + for (BackendBeans.Bean d : p.candidates) { + if (!out.contains(d.name)) { + out.add(d.name); + } + } + if (p.builtin != null && !out.contains("(" + p.builtin + ")")) { + out.add("(" + p.builtin + ")"); + } + if (p.value != null) { + out.add("@Value " + p.value); + } + } + } + + private static void describeRoutes(StringBuilder sb, List routes) { + sb.append(" public synchronized java.util.List describeRoutes() {\n"); + sb.append(" java.util.List out = new java.util.ArrayList();\n"); + for (String[] r : routes) { + // Only what this start registered: r[3] is the controller or + // endpoint class the route belongs to. + sb.append(" if (active.contains(").append(BackendSources.quote(r[3])) + .append(")) {\n out.add(route(").append(BackendSources.quote(r[0])) + .append(", ").append(BackendSources.quote(r[1])).append(", ") + .append(BackendSources.quote(r[2])).append("));\n }\n"); + } + sb.append(" return out;\n }\n\n"); + sb.append(" private static java.util.Map route(String method, String path, " + + "String handler) {\n") + .append(" java.util.Map m = new java.util.LinkedHashMap();\n") + .append(" m.put(\"method\", method);\n m.put(\"path\", path);\n") + .append(" m.put(\"handler\", handler);\n return m;\n }\n\n"); + } + + // ----------------------------------------------------------------- scopes + + private void scopes(StringBuilder sb) { + if (model.requestSlots > 0) { + sb.append(" private Object requestBean(int slot) {\n"); + sb.append(" com.codename1.backend.HttpServer.Request request =\n") + .append(" com.codename1.backend.Backend.currentRequest();\n"); + sb.append(" if (request == null) {\n") + .append(" throw new IllegalStateException(\"A @RequestScope bean was " + + "used outside a request\");\n }\n"); + sb.append(" Object[] beans = request.scopedBeans(").append(model.requestSlots) + .append(");\n"); + sb.append(" if (beans[slot] == null) {\n beans[slot] = " + + "createScoped(slot, request, true);\n }\n"); + sb.append(" return beans[slot];\n }\n\n"); + } + if (model.sessionSlots > 0) { + sb.append(" private Object sessionBean(int slot) {\n"); + sb.append(" com.codename1.backend.HttpServer.Request request =\n") + .append(" com.codename1.backend.Backend.currentRequest();\n"); + sb.append(" if (request == null) {\n") + .append(" throw new IllegalStateException(\"A @SessionScope bean was " + + "used outside a request\");\n }\n"); + sb.append(" com.codename1.backend.HttpSession session = " + + "request.getSession(true);\n"); + // The session's shared lock, not the HttpSession: a database store + // loads a separate copy per request, and locking one copy would let + // two requests each build the bean. + sb.append(" synchronized (session.beanLock()) {\n"); + sb.append(" Object[] beans = session.scopedBeans(").append(model.sessionSlots) + .append(");\n"); + sb.append(" if (beans[slot] == null) {\n beans[slot] = " + + "createScoped(slot, request, false);\n }\n"); + sb.append(" return beans[slot];\n }\n }\n\n"); + } + if (model.lazySlots > 0) { + sb.append(" private synchronized Object lazyBean(int slot) {\n"); + sb.append(" try {\n switch (slot) {\n"); + for (BackendBeans.Bean b : model.beans) { + if (!b.lazy) { + continue; + } + sb.append(" case ").append(b.slot).append(":\n"); + sb.append(" if (").append(b.var).append("Real == null) {\n"); + sb.append(" ").append(typeOf(b)).append(" bean = ") + .append(construct(b)).append(";\n"); + members(sb, b, "bean", " ", false); + initialize(sb, b, "bean", " ", false); + sb.append(" ").append(b.var).append("Real = bean;\n"); + sb.append(" }\n"); + sb.append(" return ").append(b.var).append("Real;\n"); + } + sb.append(" default:\n throw new " + + "IllegalStateException(\"No lazy bean \" + slot);\n }\n"); + sb.append(" } catch (RuntimeException err) {\n throw err;\n"); + sb.append(" } catch (Exception err) {\n") + .append(" throw new IllegalStateException(\"Building a lazy bean " + + "failed: \" + err, err);\n }\n }\n\n"); + } + if (model.requestSlots + model.sessionSlots > 0) { + sb.append(" private Object createScoped(int slot, " + + "com.codename1.backend.HttpServer.Request request, boolean perRequest) {\n"); + sb.append(" try {\n"); + sb.append(" if (perRequest) {\n switch (slot) {\n"); + scopedCases(sb, BackendBeans.REQUEST); + sb.append(" default:\n break;\n"); + sb.append(" }\n } else {\n switch (slot) {\n"); + scopedCases(sb, BackendBeans.SESSION); + sb.append(" default:\n break;\n"); + sb.append(" }\n }\n"); + sb.append(" } catch (RuntimeException err) {\n throw err;\n"); + sb.append(" } catch (Exception err) {\n") + .append(" throw new IllegalStateException(\"Building a scoped bean " + + "failed: \" + err, err);\n }\n"); + sb.append(" throw new IllegalStateException(\"No scoped bean \" + slot);\n"); + sb.append(" }\n\n"); + } + } + + private void scopedCases(StringBuilder sb, String scope) { + for (BackendBeans.Bean b : model.beans) { + if (!scope.equals(b.scope)) { + continue; + } + String indent = " "; + sb.append(" case ").append(b.slot).append(": {\n"); + sb.append(indent).append(typeOf(b)).append(" bean = ").append(construct(b)) + .append(";\n"); + members(sb, b, "bean", indent, false); + initialize(sb, b, "bean", indent, false); + sb.append(indent).append("return bean;\n"); + sb.append(" }\n"); + } + } + + /// A method per prototype bean, which builds a new one at each injection point. + private void prototypes(StringBuilder sb) { + for (BackendBeans.Bean b : model.beans) { + if (!BackendBeans.PROTOTYPE.equals(b.scope)) { + continue; + } + sb.append(" private ").append(typeOf(b)).append(" new").append(b.var) + .append("() throws Exception {\n"); + if (b.isConditional()) { + sb.append(" if (!(").append(condition(b)).append(")) {\n") + .append(" return null;\n }\n"); + } + sb.append(" ").append(typeOf(b)).append(" bean = ").append(construct(b)) + .append(";\n"); + members(sb, b, "bean", " ", false); + initialize(sb, b, "bean", " ", false); + sb.append(" return bean;\n }\n\n"); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/CronCompiler.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/CronCompiler.java new file mode 100644 index 00000000000..b6072fddd2c --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/CronCompiler.java @@ -0,0 +1,231 @@ +/* + * 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. + */ +package com.codename1.maven.processors; + +import java.util.ArrayList; +import java.util.List; + +/// Parses a `@Scheduled(cron = ...)` expression at build time into the bit masks +/// the generated entry point hands to `com.codename1.backend.CronSchedule`. +/// +/// The same grammar as that class's `parse`, which a server uses for an +/// expression it reads from configuration. The two are written twice because the +/// plugin cannot depend on the backend runtime, and the plugin's tests hold them +/// to agreeing on every expression they know -- a disagreement would mean a job +/// fires at one time when written literally and another when configured. +final class CronCompiler { + /// The masks, in the order the CronSchedule constructor takes them. + long seconds; + long minutes; + long hours; + long daysOfMonth; + long months; + long daysOfWeek; + boolean lastDayOfMonth; + + private static final String[] MONTHS = {"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", + "AUG", "SEP", "OCT", "NOV", "DEC"}; + private static final String[] DAYS = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}; + + private CronCompiler() { + } + + /// The masks of `expression`, or an IllegalArgumentException naming what is + /// wrong -- which the processor reports against the method. + static CronCompiler compile(String expression) { + if (expression == null || expression.trim().length() == 0) { + throw new IllegalArgumentException("the cron expression is empty"); + } + String text = expression.trim(); + String macro = macro(text); + if (macro != null) { + text = macro; + } + String[] fields = split(text); + if (fields.length != 6) { + throw new IllegalArgumentException("a cron expression has six fields -- second, " + + "minute, hour, day of month, month, day of week -- and \"" + expression + + "\" has " + fields.length + (fields.length == 5 + ? "; a five-field Unix expression needs a leading 0 for the second" : "")); + } + CronCompiler out = new CronCompiler(); + out.seconds = field(fields[0], 0, 59, null, false, expression, "second"); + out.minutes = field(fields[1], 0, 59, null, false, expression, "minute"); + out.hours = field(fields[2], 0, 23, null, false, expression, "hour"); + out.lastDayOfMonth = "L".equalsIgnoreCase(fields[3]); + out.daysOfMonth = out.lastDayOfMonth ? 0 + : field(fields[3], 1, 31, null, true, expression, "day of month"); + out.months = field(fields[4], 1, 12, MONTHS, false, expression, "month"); + long dow = field(fields[5], 0, 7, DAYS, true, expression, "day of week"); + if ((dow & (1L << 7)) != 0) { + dow = (dow & ~(1L << 7)) | 1L; + } + out.daysOfWeek = dow; + if (!out.lastDayOfMonth && !canEverMatch(out.daysOfMonth, out.months)) { + throw new IllegalArgumentException("\"" + expression + "\" names no day that " + + "exists in the months it allows, so it would never fire"); + } + return out; + } + + /// Whether some allowed day of month exists in some allowed month. + private static boolean canEverMatch(long days, long months) { + int[] lengths = {0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + for (int m = 1; m <= 12; m++) { + if ((months & (1L << m)) == 0) { + continue; + } + for (int d = 1; d <= lengths[m]; d++) { + if ((days & (1L << d)) != 0) { + return true; + } + } + } + return false; + } + + static String macro(String text) { + if ("@yearly".equalsIgnoreCase(text) || "@annually".equalsIgnoreCase(text)) { + return "0 0 0 1 1 *"; + } + if ("@monthly".equalsIgnoreCase(text)) { + return "0 0 0 1 * *"; + } + if ("@weekly".equalsIgnoreCase(text)) { + return "0 0 0 * * 0"; + } + if ("@daily".equalsIgnoreCase(text) || "@midnight".equalsIgnoreCase(text)) { + return "0 0 0 * * *"; + } + if ("@hourly".equalsIgnoreCase(text)) { + return "0 0 * * * *"; + } + return null; + } + + private static String[] split(String text) { + List out = new ArrayList(); + for (String part : text.split("[ \\t]+")) { + if (part.length() > 0) { + out.add(part); + } + } + return out.toArray(new String[out.size()]); + } + + private static long field(String text, int min, int max, String[] names, boolean question, + String expression, String what) { + if ("*".equals(text) || (question && "?".equals(text))) { + return range(min, max, 1); + } + long mask = 0; + for (String part : text.split(",", -1)) { + if (part.length() == 0) { + throw bad(expression, what, text); + } + int step = 1; + int slash = part.indexOf('/'); + String span = part; + if (slash >= 0) { + step = number(part.substring(slash + 1), null, 0, expression, what); + if (step <= 0) { + throw bad(expression, what, text); + } + span = part.substring(0, slash); + } + int from; + int to; + if ("*".equals(span) || (question && "?".equals(span))) { + from = min; + to = max; + } else { + int dash = span.indexOf('-'); + if (dash > 0) { + from = number(span.substring(0, dash), names, min, expression, what); + to = number(span.substring(dash + 1), names, min, expression, what); + } else { + from = number(span, names, min, expression, what); + to = slash >= 0 ? max : from; + } + } + if (from < min || to > max || from > to) { + throw new IllegalArgumentException("the " + what + " field of \"" + expression + + "\" names " + part + ", outside " + min + "-" + max); + } + mask |= range(from, to, step); + } + return mask; + } + + private static int number(String text, String[] names, int base, String expression, + String what) { + if (names != null) { + for (int i = 0; i < names.length; i++) { + if (names[i].equalsIgnoreCase(text)) { + return i + base; + } + } + } + if (text.length() == 0 || text.length() > 4) { + throw bad(expression, what, text); + } + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) < '0' || text.charAt(i) > '9') { + throw bad(expression, what, text); + } + } + return Integer.parseInt(text); + } + + private static IllegalArgumentException bad(String expression, String what, String text) { + return new IllegalArgumentException("the " + what + " field of \"" + expression + + "\" cannot be read: \"" + text + "\""); + } + + private static long range(int from, int to, int step) { + long mask = 0; + for (int i = from; i <= to; i += step) { + mask |= 1L << i; + } + return mask; + } + + /// Whether `zone` is one the runtime can read: UTC, a fixed offset, or an ID + /// the JDK knows -- the build's JDK standing in for the server's time zone + /// database, which has the same IDs. + static boolean knownZone(String zone) { + if (zone == null || zone.length() == 0 || "UTC".equalsIgnoreCase(zone) + || "Z".equalsIgnoreCase(zone) || "GMT".equalsIgnoreCase(zone)) { + return true; + } + if (zone.matches("[+-](0[0-9]|1[0-8]):[0-5][0-9]")) { + return true; + } + for (String id : java.util.TimeZone.getAvailableIDs()) { + if (id.equals(zone)) { + return true; + } + } + return false; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java index 8d1e2d4da56..99b63ee7850 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java @@ -102,6 +102,20 @@ public final class RestControllerAnnotationProcessor extends AbstractAnnotationP } private static final String REQUEST_TYPE = "com.codename1.backend.HttpServer.Request"; + + /** + * The same class as the descriptor spells it, for the same reason as + * RESPONSE_TYPE_BINARY below: a parameter's type comes from the descriptor, + * where a member class is Outer$Inner, so comparing against the dotted + * spelling alone never matched -- and a handler taking the Request itself, + * which the refusal message names as the way out, was refused. + */ + private static final String REQUEST_TYPE_BINARY = "com.codename1.backend.HttpServer$Request"; + + /** Either spelling of HttpServer.Request. */ + private static boolean isRequestType(String javaType) { + return REQUEST_TYPE.equals(javaType) || REQUEST_TYPE_BINARY.equals(javaType); + } private static final String RESPONSE_TYPE = "com.codename1.backend.HttpServer.Response"; /** @@ -166,7 +180,6 @@ private static final class WebSocketEndpoint { String binaryName; String sourceName; String packageName; - String injection; String path; } @@ -181,12 +194,28 @@ private static final class WebSocketEndpoint { /// when the entry point is written; see [#hasGeneratedDaos]. private boolean daos; + /// The beans this build resolved, once [#finish] has asked for them. + BackendBeans beans; + + /// Whether the entry point installs the development MCP tools. True for the + /// JVM build `cn1:backend` runs; the packaging goal turns it off unless asked. + boolean devTools = true; + + /// See [#devTools]. + public void setDevTools(boolean devTools) { + this.devTools = devTools; + } + /// Whether the generated entry point installs a tracer, and the service /// name it passes. Settled in [#finish], before any source is generated, /// because the routers name their routes for it too. boolean telemetry; String telemetryServiceName; + /// The settings annotations, and whether the build asked for the management + /// and MCP endpoints. Settled in [#finish] beside [#telemetry]. + BackendSettings settings; + private final Map routeShapes = new LinkedHashMap(); /** Which controller claimed each shape, so a clash names the other one. */ @@ -204,9 +233,6 @@ private static final class Controller { String packageName; String simpleName; String routerSimpleName; - /// What the generated entry point passes to the constructor: the - /// entity manager, the connection pool, or nothing. See [#injectionOf]. - String injection; List basePaths = new ArrayList(); List routes = new ArrayList(); } @@ -222,6 +248,12 @@ private static final class Route { String prefix; /** For each variable, the literal that must follow it; "" when it runs to the end. */ List after = new ArrayList(); + /** + * The statements that write `result` through the generated codecs, when the + * return type is one of the application's own classes or holds one; null + * when Json writes it as it is. + */ + String codecWrite; } private static final class Param { @@ -236,6 +268,23 @@ private static final class Param { /** Set when the body is decoded into a local before the call. */ String local; int variableIndex = -1; + /** + * The statements that read `parsed` into `target` through the generated + * codecs, when the body is one of the application's own classes or holds + * one; null when the body binds as parsed. + */ + String codecRead; + } + + /// The JSON codecs for the application's own classes, created with the first + /// route that needs one. + private BackendJsonCodecs codecs; + + private BackendJsonCodecs codecs(ProcessorContext ctx) { + if (codecs == null) { + codecs = new BackendJsonCodecs(ctx); + } + return codecs; } @Override @@ -285,16 +334,8 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces if (controller.basePaths.isEmpty()) { controller.basePaths.add(""); } - controller.injection = injectionOf(cls); - if (controller.injection == null) { - ctx.error(cls, "@RestController " + controller.binaryName + " has no constructor " - + "the generated entry point can call. Declare a public constructor " - + "taking nothing, or one taking a " - + "com.codename1.backend.orm.EntityManager, or one taking a " - + "com.codename1.backend.DataSource -- the entry point opens both from " - + "the configuration and hands over whichever the controller asks for."); - return; - } + // What the constructor is given is the bean pass's to decide -- see + // BackendBeans -- like every other bean's. for (MethodInfo m : cls.getMethods()) { if (m.isConstructor() || m.isSynthetic() || m.isStatic() || !m.isPublic()) { @@ -803,7 +844,7 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St } else if (requestBody != null) { p.kind = "BODY"; p.required = requestBody.getBoolOrDefault("required", true); - } else if (REQUEST_TYPE.equals(p.javaType)) { + } else if (isRequestType(p.javaType)) { // The escape hatch: a handler that needs something this binding does not // model takes the Request itself, exactly as it would have before. p.kind = "REQUEST"; @@ -827,6 +868,29 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St return null; } String badKey = "BODY".equals(p.kind) ? unusableMapKey(genericType) : null; + if ("BODY".equals(p.kind) && !"java.lang.String".equals(p.javaType) + && (badKey != null || !bodyElementsAreDecoded(genericType) + || !isBindable(p.javaType, p.kind))) { + // Not a shape the parser hands over as it is -- one of the + // application's classes, or a container of them, or of a type the + // parser does not produce (Integer, Date, an enum). Spring binds + // those through Jackson; here the build writes the codec, and a + // shape it cannot write one for is refused with the reason. + String bodyType = genericType != null ? genericType : p.javaType; + String why = codecs(ctx).checkRead(bodyType); + if (why == null) { + p.genericJavaType = bodyType; + p.codecRead = codecs(ctx).readStatements(bodyType, "parsed", "com.codename1" + + ".backend.JsonCodec.Path.ROOT", "null", "-1", "0", "target", ""); + route.params.add(p); + continue; + } + if (badKey == null) { + ctx.error(cls, "Cannot bind " + bodyType + " from the body on " + + cls.getBinaryName() + "." + m.getName() + ": it holds " + why + "."); + return null; + } + } if (badKey != null) { // Separate from the element rule below, and with its own message, // because Long is a perfectly good body VALUE -- every JSON @@ -891,14 +955,35 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St // success. This processor has no DTO codec generation (the @RestClient // half does), so the honest answer today is to refuse the shape rather // than emit JSON nobody can use. - if (!isEncodableReturn(route.returnJavaType, ctx)) { - ctx.error(cls, cls.getBinaryName() + "." + m.getName() + " returns " - + route.returnJavaType + ", which the generated router cannot encode: " - + "it would be written as the JSON string of its toString(). Return a " - + "Map, a List, a Set, a String, a primitive, an HttpServer.Response, " - + "or make the type implement com.codename1.backend.Json.Writable."); + if (route.returnJavaType != null + && route.returnJavaType.startsWith("java.util.concurrent.Future")) { + // Named on its own rather than left to the generic refusal below: the + // shape comes from @Async, whose woven stub returns the queued task at + // once, and the route would send that task instead of its result -- + // a success with a bogus body, the work's failure lost. Spring MVC + // does not await a plain Future either. + ctx.error(cls, cls.getBinaryName() + "." + m.getName() + " returns a " + + "java.util.concurrent.Future. A route answers with what its method " + + "returns, and an @Async method returns before its work is done, so " + + "the client would get the pending task, not the result. Return the " + + "value itself -- the request already runs on its own thread -- or " + + "start the work and return an id to ask about it by."); return null; } + if (!isEncodableReturn(route.returnJavaType, ctx)) { + // One of the application's classes, or a container of them: written + // through a codec the build generates for it, as Jackson would write + // it for a Spring controller. + String why = codecs(ctx).checkWrite(route.returnJavaType); + if (why != null) { + ctx.error(cls, cls.getBinaryName() + "." + m.getName() + " returns " + + route.returnJavaType + ", which the generated router cannot write " + + "as JSON: it holds " + why + "."); + return null; + } + route.codecWrite = codecs(ctx).writeStatements(route.returnJavaType, "result", + "0", ""); + } AnnotationValues status = m.getAnnotation(RESPONSE_STATUS); // ResponseStatus documents that a value-returning method answers 200 and a // void one answers 204. Defaulting to 200 for both made the annotation's @@ -1041,63 +1126,6 @@ private static boolean isBindable(String javaType, String kind) { || "short".equals(javaType) || "byte".equals(javaType); } - /// The DESCRIPTORS of the constructors the generated entry point knows how - /// to call, most specific first. - private static final String CONSTRUCTOR_ENTITIES = - "(Lcom/codename1/backend/orm/EntityManager;)V"; - private static final String CONSTRUCTOR_DATASOURCE = - "(Lcom/codename1/backend/DataSource;)V"; - - /// What to hand this controller's constructor, or null when it declares - /// none that can be called. - /// - /// This is the whole of the dependency injection, and it is deliberately - /// three cases rather than a container: a server handler needs the database - /// and nothing else, the two ways to want it are the ORM and the pool, and - /// a controller that needs something else builds it itself. There is no - /// scanning, no proxying and nothing resolved at run time -- the generated - /// entry point contains a `new` with the argument written into it. - /// - /// The entity manager wins over the pool, and the pool over nothing, when a - /// class declares several: a test that keeps a no-arg constructor around - /// should not quietly become the shape production runs. - private static String injectionOf(AnnotatedClass cls) { - boolean entities = false; - boolean dataSource = false; - boolean none = false; - for (MethodInfo m : cls.getMethods()) { - if (!m.isConstructor() || !m.isPublic()) { - continue; - } - String descriptor = m.getDescriptor(); - if (CONSTRUCTOR_ENTITIES.equals(descriptor)) { - entities = true; - } else if (CONSTRUCTOR_DATASOURCE.equals(descriptor)) { - dataSource = true; - } else if (Type.getArgumentTypes(descriptor).length == 0) { - none = true; - } - } - if (entities) { - return "ENTITIES"; - } - if (dataSource) { - return "DATASOURCE"; - } - return none ? "NONE" : null; - } - - private static boolean hasNoArgConstructor(AnnotatedClass cls) { - for (MethodInfo m : cls.getMethods()) { - if (m.isConstructor() && m.isPublic() - && Type.getArgumentTypes(m.getDescriptor()).length == 0) { - return true; - } - } - return false; - } - - /** * Records one `@WebSocketMapping`, refusing everything the generated entry * point could not honour. @@ -1145,6 +1173,16 @@ private void processWebSocket(AnnotatedClass cls, ProcessorContext ctx) { + cls.getBinaryName() + " -> \"" + full + "\""); return; } + if (full.indexOf('%') >= 0) { + // The upgrade looks up the CANONICAL path -- unreserved escapes + // decoded, the rest upper-cased -- so a mapping spelled with + // an escape is a key nothing matches, and one that evades the + // duplicate check against its decoded twin. Write the path out. + ctx.error(cls, "@WebSocketMapping path must not contain a percent " + + "escape; write the characters themselves: " + + cls.getBinaryName() + " -> \"" + full + "\""); + return; + } if (full.indexOf('?') >= 0) { // tryUpgrade strips the query before it looks a path up, so a // mapping with one in it goes into the route map under a key @@ -1167,7 +1205,6 @@ private void processWebSocket(AnnotatedClass cls, ProcessorContext ctx) { endpoint.sourceName = cls.getSourceName(); endpoint.packageName = RestClientAnnotationProcessor.packageOf(endpoint.binaryName); - endpoint.injection = injectionOf(cls); endpoint.path = full; webSockets.put(full, endpoint); } @@ -1255,14 +1292,24 @@ private static String join(String base, String path) { @Override public void finish(ProcessorContext ctx) throws ProcessingException { + if (ctx.hasErrors()) { + return; + } + // The beans FIRST: the controllers and endpoints are beans too, and the + // classes are rewritten before anything generated here is compiled + // against them. + beans = BackendBeans.prepare(ctx); if (ctx.hasErrors()) { return; } // A module with only websocket endpoints is a real server and needs an // entry point exactly as much as one with only controllers does. Guarding // on controllers alone left it with no main at all -- and the failure is - // that the build succeeds and produces nothing runnable. - if (controllers.isEmpty() && webSockets.isEmpty()) { + // that the build succeeds and produces nothing runnable. The same goes + // for one whose only work is scheduled jobs, MCP tools or managed + // resources -- the management endpoints serve those. + if (controllers.isEmpty() && webSockets.isEmpty() && !beans.hasJobs() + && !beans.hasTools() && !beans.hasManaged()) { // NOTHING LEFT, so a marker from an earlier build has to go. Maven // keeps target/classes across a build without clean, and returning // early without this left the marker naming a bootstrap that still @@ -1282,6 +1329,7 @@ public void finish(ProcessorContext ctx) throws ProcessingException { return; } resolveTelemetry(ctx); + settings = BackendSettings.resolve(ctx); if (ctx.hasErrors()) { return; } @@ -1304,9 +1352,10 @@ public void finish(ProcessorContext ctx) throws ProcessingException { // WHERE THE ENTRY POINT GOES. The first controller's package, as before -- // but a module may now have websocket endpoints and no controller at all, // and this used to be an unguarded iterator().next() on an empty map. - String entryPackage = controllers.isEmpty() - ? webSockets.values().iterator().next().packageName - : controllers.values().iterator().next().packageName; + String entryPackage = !controllers.isEmpty() + ? controllers.values().iterator().next().packageName + : !webSockets.isEmpty() ? webSockets.values().iterator().next().packageName + : beans.entryPackage; String bootstrap = qualify(entryPackage, "BackendApplication"); // A class of this name already in that package would be OVERWRITTEN in the // output directory by the one compiled below -- silently, because the @@ -1320,7 +1369,25 @@ public void finish(ProcessorContext ctx) throws ProcessingException { + "that class, or move the controllers into another package."); return; } + String wiring = qualify(entryPackage, BackendWiringWriter.CLASS_NAME); + if (isNotOurOwnOutput(ctx, wiring)) { + ctx.error(wiring + " already exists, and the wiring generated for this module " + + "would replace it. Rename that class."); + return; + } + if (codecs != null) { + Map codecSources = codecs.sources(); + for (String codec : codecSources.keySet()) { + if (isNotOurOwnOutput(ctx, codec)) { + ctx.error(codec + " already exists, and the JSON codec generated under " + + "that name would replace it. Rename that class."); + return; + } + } + sources.putAll(codecSources); + } daos = hasGeneratedDaos(ctx); + sources.put(wiring, generateWiring(entryPackage)); sources.put(bootstrap, generateBootstrap(entryPackage)); try { List cp = new ArrayList(); @@ -1517,6 +1584,19 @@ private static void emitRoute(StringBuilder sb, Route route, int index, Controll sb.append(pad).append("return result == null ? request.respond(404, \"text/plain\", EMPTY)\n"); sb.append(pad).append(" : request.respond(").append(route.status) .append(", \"text/plain; charset=utf-8\", utf8(result));\n"); + } else if (route.codecWrite != null) { + // The application's own classes: written straight into the connection's + // buffer by the codec the build generated, with no Map in between. + String type = BackendJsonCodecs.source(route.returnJavaType); + sb.append(pad).append("final ").append(type).append(" result = ").append(call) + .append(";\n"); + sb.append(pad).append("return result == null ? request.respond(404, \"text/plain\", EMPTY)\n"); + sb.append(pad).append(" : request.respondJson(").append(route.status) + .append(", new com.codename1.backend.Json.Writable() {\n"); + sb.append(pad).append(" public void writeTo(com.codename1.backend.ByteSink out) {\n"); + appendIndented(sb, route.codecWrite, pad + " "); + sb.append(pad).append(" }\n"); + sb.append(pad).append(" });\n"); } else { // Everything else is JSON. respondJson writes into the connection's own // Response, so a route that returns a value still allocates only that value. @@ -1927,6 +2007,30 @@ private static void emitBodyLocals(StringBuilder sb, Route route, String pad) { if (!"BODY".equals(p.kind) || "java.lang.String".equals(p.javaType)) { continue; } + if (p.codecRead != null) { + String type = BackendJsonCodecs.source(p.genericJavaType); + p.local = "body" + i; + sb.append(pad).append(type).append(' ').append(p.local).append(" = null;\n"); + sb.append(pad).append("if (request.getBody() != null && request.getBody().length() > 0) {\n"); + sb.append(pad).append(" Object parsed = bodyAsValue(request.getBody());\n"); + sb.append(pad).append(" if (parsed == MALFORMED) {\n"); + sb.append(pad).append(" return request.respond(400, \"text/plain; charset=utf-8\",\n"); + sb.append(pad).append(" utf8(\"The request body is not valid JSON\"));\n"); + sb.append(pad).append(" }\n"); + sb.append(pad).append(" ").append(type).append(" target = null;\n"); + // What the codec refuses is the client's mistake -- a string + // where a number belongs -- and its message says where: a 400, + // as Spring answers a body Jackson cannot read. + sb.append(pad).append(" try {\n"); + appendIndented(sb, p.codecRead, pad + " "); + sb.append(pad).append(" } catch (IllegalArgumentException err) {\n"); + sb.append(pad).append(" return request.respond(400, \"text/plain; charset=utf-8\",\n"); + sb.append(pad).append(" utf8(String.valueOf(err.getMessage())));\n"); + sb.append(pad).append(" }\n"); + sb.append(pad).append(" ").append(p.local).append(" = target;\n"); + sb.append(pad).append("}\n"); + continue; + } boolean map = "java.util.Map".equals(p.javaType); String type = map ? "java.util.Map" : "java.util.List"; String decoder = map ? "bodyAsMap" : "bodyAsList"; @@ -2138,6 +2242,19 @@ private static String capitalize(String javaType) { * runtime class so that a module with no controllers links none of it, and so * the dead-code pass can drop whichever ones this controller never calls. */ + /// Each line of `code` with `pad` in front of it. + private static void appendIndented(StringBuilder sb, String code, String pad) { + int start = 0; + while (start < code.length()) { + int end = code.indexOf('\n', start); + if (end < 0) { + end = code.length(); + } + sb.append(pad).append(code, start, end).append('\n'); + start = end + 1; + } + } + private static void emitRouterHelpers(StringBuilder sb) { sb.append(" private static final byte[] EMPTY = new byte[0];\n\n"); sb.append(" /**\n"); @@ -2464,6 +2581,15 @@ private static void emitRouterHelpers(StringBuilder sb) { sb.append(" || value.equalsIgnoreCase(\"no\") || value.equalsIgnoreCase(\"off\");\n"); sb.append(" }\n\n"); + // A body that does not parse, told apart from one that parsed to null. + sb.append(" private static final Object MALFORMED = new Object();\n\n"); + sb.append(" private static Object bodyAsValue(String body) {\n"); + sb.append(" try {\n"); + sb.append(" return com.codename1.backend.Json.parse(body);\n"); + sb.append(" } catch (java.io.IOException err) {\n"); + sb.append(" return MALFORMED;\n"); + sb.append(" }\n"); + sb.append(" }\n\n"); sb.append(" private static java.util.Map bodyAsMap(String body) {\n"); sb.append(" if (body == null || body.length() == 0) {\n"); sb.append(" return null;\n"); @@ -2524,70 +2650,85 @@ String generateBootstrap(String packageName) { } // Everything a server used to open with -- read the port, start, install // a shutdown handler, drain on SIGTERM, wait -- is inside run(). What is - // left here is the part that differs between one server and the next: - // which controllers there are and what each of them is given. + // left here is the part that differs between one server and the next, + // and even that lives in the generated BackendWiring: every bean, built + // and injected by straight-line code. sb.append(" com.codename1.backend.Backend.builder()\n"); if (telemetry) { - // The ONLY reference to the tracer implementation anywhere in the - // program, which is what keeps it out of a binary that does not ask - // for it: the translator drops what nothing reaches. + // The ONLY references to the OTLP exporters anywhere in the program, + // which is what keeps them out of a binary that does not ask for + // them: the translator drops what nothing reaches. + String name = telemetryServiceName == null || telemetryServiceName.length() == 0 + ? "null" : quote(telemetryServiceName); sb.append(" .tracing(new com.codename1.backend.otel.OtlpTracer(") - .append(telemetryServiceName == null || telemetryServiceName.length() == 0 - ? "null" : quote(telemetryServiceName)) - .append("))\n"); - } - if (needsDatabase() || needsDatabaseForWebSockets()) { - // A controller that declares a DataSource or an EntityManager needs - // a database, and this is where the build says so: the builder opens - // one for a server that has no entities either, which is how a - // development profile's in-memory default reaches a controller that - // asked only for the pool. + .append(name).append("))\n"); + sb.append(" .metrics(new com.codename1.backend.otel.OtlpMetricExporter(") + .append(name).append("))\n"); + } + if (beans != null && beans.needsDatabase) { + // A bean that declares a DataSource, an EntityManager or a Session + // needs a database, and this is where the build says so: the builder + // opens one for a server that has no entities either, which is how a + // development profile's in-memory default reaches a bean that asked + // only for the pool. sb.append(" .requiresDataSource()\n"); } - // A CALLBACK, like .handlers below, not a setter on a started server. The - // runtime invokes this while it is starting and before the listener - // accepts, so there is no window in which a generated route exists here - // and not in the server. - // - // Sorted by path (webSockets is a TreeMap), which keeps the generated - // source byte-identical between builds -- a bootstrap whose text depends - // on scan order recompiles for no reason and diffs noisily. - if (!webSockets.isEmpty()) { - sb.append(" .webSockets(new com.codename1.backend.Backend.WebSocketEndpoints() {\n"); - sb.append(" public void register(\n"); - sb.append(" com.codename1.backend.HttpServer.WebSocketRegistry registry,\n"); - sb.append(" com.codename1.backend.DataSource dataSource,\n"); - sb.append(" com.codename1.backend.orm.EntityManager entities)\n"); - sb.append(" throws Exception {\n"); - for (WebSocketEndpoint endpoint : webSockets.values()) { - sb.append(" registry.route(\"").append(endpoint.path) - .append("\", new ").append(endpoint.sourceName).append("(") - .append(argumentForInjection(endpoint.injection, endpoint.binaryName)) - .append("));\n"); - } - sb.append(" }\n"); - sb.append(" })\n"); - } - sb.append(" .handlers(new com.codename1.backend.Backend.Handlers() {\n"); - sb.append(" public com.codename1.backend.HttpServer.Handler[] create(\n"); - sb.append(" com.codename1.backend.DataSource dataSource,\n"); - sb.append(" com.codename1.backend.orm.EntityManager entities)\n"); - sb.append(" throws Exception {\n"); - sb.append(" return new com.codename1.backend.HttpServer.Handler[] {\n"); - int index = 0; - for (Controller c : controllers.values()) { - sb.append(" new ").append(qualify(c.packageName, c.routerSimpleName)) - .append("(new ").append(c.sourceName).append("(").append(argumentFor(c)).append("))"); - sb.append(++index < controllers.size() ? ",\n" : "\n"); - } - sb.append(" };\n"); - sb.append(" }\n"); - sb.append(" }).run();\n"); + if (settings != null && !settings.values.isEmpty()) { + // The settings annotations, as the bottom layer of the configuration. + sb.append(" .compiledSettings(new String[] {"); + List flat = settings.flat(); + for (int i = 0; i < flat.size(); i++) { + sb.append(i == 0 ? "" : ", ").append(quote(flat.get(i))); + } + sb.append("})\n"); + } + if (devTools || (settings != null && settings.management)) { + // The ONLY call that names the management endpoints, so a packaged + // server that did not ask for them has none of their code: the + // translator drops the builder method nothing calls, and the classes + // only it named go with it. + sb.append(" .management()\n"); + } + boolean tools = beans != null && beans.hasTools(); + if (devTools || tools || (settings != null && settings.mcp)) { + // The development tools are named only in a development build, so a + // packaged server has none of their code. + sb.append(" .mcp(").append(devTools + ? "new com.codename1.backend.mcp.DevTools()" : "null").append(")\n"); + if (telemetryServiceName != null && telemetryServiceName.length() > 0) { + sb.append(" .serviceName(").append(quote(telemetryServiceName)) + .append(")\n"); + } + } + sb.append(" .application(new ") + .append(qualify(packageName, BackendWiringWriter.CLASS_NAME)).append("())\n"); + sb.append(" .run();\n"); sb.append(" }\n"); sb.append("}\n"); return sb.toString(); } + /// The source of the generated BackendWiring, package-visible so a test can + /// read what the build decided. + String generateWiring(String packageName) { + List routers = new ArrayList(); + List routes = new ArrayList(); + for (Controller c : controllers.values()) { + routers.add(new BackendWiringWriter.Router(c.binaryName, + qualify(c.packageName, c.routerSimpleName))); + for (Route r : c.routes) { + routes.add(new String[] {r.httpMethod, r.pattern, + c.binaryName + "." + r.javaMethod, c.binaryName}); + } + } + Map sockets = new LinkedHashMap(); + for (WebSocketEndpoint e : webSockets.values()) { + sockets.put(e.path, e.binaryName); + routes.add(new String[] {"WEBSOCKET", e.path, e.binaryName, e.binaryName}); + } + return new BackendWiringWriter(beans).write(packageName, routers, sockets, routes); + } + /// Whether this module asked for tracing, and under what service name. /// /// `@OpenTelemetry` on any class with a source in this module, or @@ -2667,6 +2808,88 @@ private static boolean propertyEnablesTelemetry(ProcessorContext ctx) { || "on".equalsIgnoreCase(v) || "1".equals(v); } + /// Whether `key` is set to a literal truth value in the module's + /// `application.properties` or any `application-.properties` beside + /// it. A `${...}` reference can't be resolved at build time and doesn't count. + static boolean applicationPropertyTrue(ProcessorContext ctx, String key) { + for (File f : applicationPropertyFiles(ctx)) { + java.util.Properties props = new java.util.Properties(); + InputStream in = null; + try { + in = new java.io.FileInputStream(f); + props.load(in); + } catch (IOException err) { + continue; + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ignored) { + // Only read; nothing to lose. + } + } + } + String v = props.getProperty(key); + if (v != null) { + v = v.trim(); + if ("true".equalsIgnoreCase(v) || "yes".equalsIgnoreCase(v) + || "on".equalsIgnoreCase(v) || "1".equals(v)) { + return true; + } + } + } + return false; + } + + /// The module's `application.properties` and every + /// `application-.properties` beside it. + private static List applicationPropertyFiles(ProcessorContext ctx) { + List files = new ArrayList(); + File base = applicationProperties(ctx); + if (base == null) { + return files; + } + files.add(base); + File[] siblings = base.getParentFile() == null ? null : base.getParentFile().listFiles(); + if (siblings != null) { + for (File f : siblings) { + String n = f.getName(); + if (n.startsWith("application-") && n.endsWith(".properties")) { + files.add(f); + } + } + } + return files; + } + + /// Whether `key` is set in the module's `application.properties` or any + /// `application-.properties` beside it -- what a build can know about + /// a setting that Config will read at run time. + static boolean applicationPropertyKnown(ProcessorContext ctx, String key) { + for (File f : applicationPropertyFiles(ctx)) { + java.util.Properties props = new java.util.Properties(); + InputStream in = null; + try { + in = new java.io.FileInputStream(f); + props.load(in); + } catch (IOException err) { + continue; + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ignored) { + // Only read; nothing to lose. + } + } + } + if (props.getProperty(key) != null) { + return true; + } + } + return false; + } + /// The backend module's `application.properties`, or null. /// /// Looked for beside the module that owns the output directory FIRST @@ -2708,51 +2931,6 @@ private static File applicationProperties(ProcessorContext ctx) { return null; } - /// Whether any controller declared a constructor that needs a database. - private boolean needsDatabase() { - for (Controller c : controllers.values()) { - if ("ENTITIES".equals(c.injection) || "DATASOURCE".equals(c.injection)) { - return true; - } - } - return false; - } - - /// What the generated entry point passes to one controller's constructor. - /// - /// Through a REQUIRE rather than straight: a controller declaring one of - /// these constructors is declaring a dependency, and handing it null because - /// nothing configured a database produces a server that starts, reports - /// healthy and fails on the first request that touches it. The check names - /// the controller, and it runs before the server binds. - private static String argumentFor(Controller c) { - return argumentForInjection(c.injection, c.binaryName); - } - - /// The same rule for a websocket endpoint, which declares a dependency the - /// same way a controller does. - private static String argumentForInjection(String injection, String binaryName) { - if ("ENTITIES".equals(injection)) { - return "com.codename1.backend.Backend.requireEntities(entities, \"" - + binaryName + "\")"; - } - if ("DATASOURCE".equals(injection)) { - return "com.codename1.backend.Backend.requireDataSource(dataSource, \"" - + binaryName + "\")"; - } - return ""; - } - - /// Whether any websocket endpoint declared a constructor that needs one. - private boolean needsDatabaseForWebSockets() { - for (WebSocketEndpoint endpoint : webSockets.values()) { - if ("ENTITIES".equals(endpoint.injection) || "DATASOURCE".equals(endpoint.injection)) { - return true; - } - } - return false; - } - /// Whether this module has server-side daos for the entry point to register. /// /// Asked two ways because neither alone is enough. The generated bootstrap diff --git a/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor b/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor index f0cdae67a7d..b7fa7eb17a2 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor +++ b/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor @@ -9,5 +9,6 @@ com.codename1.maven.processors.GrpcClientAnnotationProcessor com.codename1.maven.processors.GraphQLClientAnnotationProcessor com.codename1.maven.processors.AppIntentAnnotationProcessor com.codename1.maven.processors.BuildHintAnnotationProcessor +com.codename1.maven.processors.BackendBeanAnnotationProcessor com.codename1.maven.processors.RestControllerAnnotationProcessor com.codename1.maven.processors.TelemetryAnnotationProcessor diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendBeansTest.java new file mode 100644 index 00000000000..66d41064730 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -0,0 +1,2356 @@ +/* + * 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. + */ +package com.codename1.maven.processors; + +import com.codename1.backend.Backend; +import com.codename1.backend.Config; +import com.codename1.backend.HttpServer; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.ProcessorContext; +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/// The Spring-style programming model, end to end: sources that use it are +/// compiled, the processors resolve, weave and generate, and the generated +/// wiring then RUNS -- a real server on a real port, a real SQLite database -- +/// because a wiring that compiles and injects the wrong bean, or a transaction +/// that begins and never rolls back, is exactly what a source-text assertion +/// would miss. +public class BackendBeansTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String PKG = "package com.example;\n" + + "import com.codename1.backend.*;\n" + + "import com.codename1.backend.annotations.*;\n" + + "import java.util.*;\n" + + "import java.util.concurrent.*;\n" + + "import java.util.concurrent.atomic.*;\n"; + + private static Map sample() { + Map s = new LinkedHashMap(); + s.put("com.example.Greeter", PKG + + "public interface Greeter { String greet(String name); }\n"); + s.put("com.example.PoliteGreeter", PKG + + "@Service\n" + + "public class PoliteGreeter implements Greeter {\n" + + " @Value(\"${greeting.prefix:Hello}\") private String prefix;\n" + + " private int initialized;\n" + + " @PostConstruct void init() { initialized++; }\n" + + " public String greet(String name) {\n" + + " return prefix + \", \" + name + \" (\" + initialized + \")\";\n" + + " }\n" + + "}\n"); + s.put("com.example.Notes", PKG + + "@Repository\n" + + "public class Notes {\n" + + " private final DataSource db;\n" + + " public Notes(DataSource db) { this.db = db; }\n" + + " @PostConstruct public void schema() throws java.io.IOException {\n" + + " db.execute(\"CREATE TABLE IF NOT EXISTS notes (text TEXT)\", null);\n" + + " }\n" + + " @Transactional\n" + + " public void addTwo(String a, String b) throws java.io.IOException {\n" + + " db.execute(\"INSERT INTO notes (text) VALUES (?)\", new Object[] {a});\n" + + " if (b == null) { throw new IllegalStateException(\"no second note\"); }\n" + + " db.execute(\"INSERT INTO notes (text) VALUES (?)\", new Object[] {b});\n" + + " }\n" + + " @Transactional(readOnly = true)\n" + + " public int count() throws java.io.IOException {\n" + + " Map row = db.queryOne(\"SELECT COUNT(*) AS n FROM notes\", null);\n" + + " return ((Number) row.get(\"n\")).intValue();\n" + + " }\n" + + "}\n"); + s.put("com.example.Jobs", PKG + + "@Service\n" + + "public class Jobs {\n" + + " public final AtomicInteger runs = new AtomicInteger();\n" + + " @Scheduled(fixedRate = 20)\n" + + " public void tick() { runs.incrementAndGet(); }\n" + + " @Async\n" + + " public Future later(String v) {\n" + + " return AsyncResult.of(v + \" on \" + (Thread.currentThread().getName()" + + ".startsWith(\"cn1-task\") ? \"a task thread\" : \"the caller\"));\n" + + " }\n" + + "}\n"); + s.put("com.example.RequestInfo", PKG + + "@Component @RequestScope\n" + + "public class RequestInfo {\n" + + " private final String id = String.valueOf(System.nanoTime());\n" + + " public String id() { return id; }\n" + + "}\n"); + s.put("com.example.Cache", PKG + + "@Component @ManagedResource(objectName = \"cache\")\n" + + "public class Cache {\n" + + " private int cleared;\n" + + " @ManagedAttribute public int getSize() { return 3 - cleared; }\n" + + " @ManagedOperation public void clear() { cleared = 3; }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController\n" + + "public class Api {\n" + + " @Autowired private Greeter greeter;\n" + + " @Autowired private RequestInfo request;\n" + + " private final Notes notes;\n" + + " private final Jobs jobs;\n" + + " public Api(Notes notes, Jobs jobs) { this.notes = notes; this.jobs = jobs; }\n" + + " @GetMapping(\"/hello/{name}\")\n" + + " public String hello(@PathVariable(\"name\") String name) {\n" + + " return greeter.greet(name);\n" + + " }\n" + + " @PostMapping(\"/notes\")\n" + + " public String add(@RequestParam(\"a\") String a,\n" + + " @RequestParam(value = \"b\", required = false) String b)\n" + + " throws Exception {\n" + + " try { notes.addTwo(a, b); } catch (IllegalStateException e) {\n" + + " return \"rolled back\";\n" + + " }\n" + + " return \"ok\";\n" + + " }\n" + + " @GetMapping(\"/notes/count\")\n" + + " public String count() throws Exception { return String.valueOf(notes.count()); }\n" + + " @GetMapping(\"/async\")\n" + + " public String async() throws Exception { return (String) jobs.later(\"x\").get(); }\n" + + " @GetMapping(\"/ticks\")\n" + + " public String ticks() { return String.valueOf(jobs.runs.get()); }\n" + + " @GetMapping(\"/rid\")\n" + + " public String rid() { return request.id() + \"|\" + request.id(); }\n" + + " @McpTool(description = \"Greets someone\")\n" + + " public String greet(@McpParam(\"name\") String name) { return greeter.greet(name); }\n" + + "}\n"); + return s; + } + + @Test + public void theGeneratedWiringRunsTheSample() throws Exception { + File classes = compile(sample()); + ProcessorContext ctx = process(classes); + assertNoErrors(ctx); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Backend.Application app = (Backend.Application) loader + .loadClass("com.example.BackendWiring").newInstance(); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + settings.setProperty("greeting.prefix", "Hi"); + // What the generated main does, with the development tools a JVM build has. + Backend backend = Backend.builder(Config.of(settings, "dev")) + .quiet() + .requiresDataSource() + .mcp(new com.codename1.backend.mcp.DevTools()).management() + .application(app) + .start(); + try { + // Field injection into a private field, @Value, and a package-private + // @PostConstruct that ran exactly once. + assertEquals("Hi, Ada (1)", http("GET", port, "/hello/Ada")); + // A transaction that commits, and one whose unchecked exception + // rolls back the insert that came before it. + assertEquals("ok", http("POST", port, "/notes?a=one&b=two")); + assertEquals("2", http("GET", port, "/notes/count")); + assertEquals("rolled back", http("POST", port, "/notes?a=three")); + assertEquals("the first insert of a rolled-back transaction was kept", "2", + http("GET", port, "/notes/count")); + // @Async runs on a task thread and the caller's Future completes. + assertEquals("x on a task thread", http("GET", port, "/async")); + // Request scope: one instance within a request, another in the next. + String first = http("GET", port, "/rid"); + String[] halves = first.split("\\|"); + assertEquals("a request-scoped bean changed within one request", halves[0], halves[1]); + assertNotEquals("two requests shared a request-scoped bean", first, + http("GET", port, "/rid")); + // The scheduled job fires on its own. + long deadline = System.currentTimeMillis() + 5000; + while (Integer.parseInt(http("GET", port, "/ticks")) < 3 + && System.currentTimeMillis() < deadline) { + Thread.sleep(20); + } + assertTrue("the fixed-rate job did not run", + Integer.parseInt(http("GET", port, "/ticks")) >= 3); + // The MCP endpoint lists and calls the tool, and the dev tools see + // the routes and the managed bean. + String tools = post(port, "/mcp", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + assertTrue(tools, tools.contains("\"greet\"")); + String call = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":" + + "\"tools/call\",\"params\":{\"name\":\"greet\",\"arguments\":" + + "{\"name\":\"Bob\"}}}"); + assertTrue(call, call.contains("Hi, Bob (1)")); + String managed = http("GET", port, "/manage/managed"); + assertTrue(managed, managed.contains("\"size\":3")); + String routes = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":" + + "\"tools/call\",\"params\":{\"name\":\"backend_routes\"}}"); + // backend_call sends a request through the running server. Its method + // was once named like McpTool.call, and the tool recursed into itself. + String sent = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":31,\"method\":" + + "\"tools/call\",\"params\":{\"name\":\"backend_call\",\"arguments\":" + + "{\"method\":\"GET\",\"path\":\"/hello/Cy\"}}}"); + assertTrue(sent, sent.contains("Hi, Cy") && sent.contains("\"isError\":false")); + assertTrue(routes, routes.contains("/hello/{name}")); + String beans = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":" + + "\"tools/call\",\"params\":{\"name\":\"backend_beans\"}}"); + assertTrue(beans, beans.contains("politeGreeter")); + String sql = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":" + + "\"tools/call\",\"params\":{\"name\":\"backend_sql\",\"arguments\":" + + "{\"sql\":\"SELECT text FROM notes ORDER BY text\"}}}"); + assertTrue(sql, sql.contains("one") && sql.contains("two") && !sql.contains("three")); + String refused = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":" + + "\"tools/call\",\"params\":{\"name\":\"backend_sql\",\"arguments\":" + + "{\"sql\":\"DELETE FROM notes\"}}}"); + assertTrue("a write ran without write=true: " + refused, + refused.contains("\"isError\":true")); + // Begins like a read, deletes like a write: the engine must refuse it. + String disguised = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":" + + "\"tools/call\",\"params\":{\"name\":\"backend_sql\",\"arguments\":" + + "{\"sql\":\"WITH x AS (SELECT 1) DELETE FROM notes\"}}}"); + assertTrue("a disguised write ran without write=true: " + disguised, + disguised.contains("\"isError\":true")); + String still = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":" + + "\"tools/call\",\"params\":{\"name\":\"backend_sql\",\"arguments\":" + + "{\"sql\":\"SELECT text FROM notes ORDER BY text\"}}}"); + assertTrue(still, still.contains("one") && still.contains("two")); + String metrics = http("GET", port, "/manage/prometheus"); + assertTrue(metrics, metrics.contains("http_server_request_duration_bucket")); + // The scheduler is built by the application after the server knows it + // measures; its runs must still be recorded. + assertTrue("the scheduled job's runs were not measured: " + metrics, + metrics.contains("Jobs.tick")); + } finally { + backend.stop(); + } + } + + @Test + public void theEntryPointNamesTheWiring() throws Exception { + File classes = compile(sample()); + RestControllerAnnotationProcessor proc = new RestControllerAnnotationProcessor(); + ProcessorContext ctx = process(classes, proc); + assertNoErrors(ctx); + String bootstrap = proc.generateBootstrap("com.example"); + assertTrue(bootstrap, bootstrap.contains(".application(new com.example.BackendWiring())")); + assertTrue("a bean takes a DataSource, so the entry point must ask for a database:\n" + + bootstrap, bootstrap.contains(".requiresDataSource()")); + String wiring = proc.generateWiring("com.example"); + assertTrue(wiring, wiring.contains("new com.example.Api(")); + assertTrue("the private field is injected through the woven setter:\n" + wiring, + wiring.contains(".cn1$inject$greeter(")); + } + + private static Map dtoSample() { + Map s = new LinkedHashMap(); + s.put("com.example.Priority", PKG + "public enum Priority { LOW, HIGH }\n"); + s.put("com.example.Tag", PKG + "public class Tag {\n" + + " public String name;\n" + + " public Tag() { }\n" + + " public Tag(String name) { this.name = name; }\n" + + "}\n"); + s.put("com.example.Note", PKG + "public class Note {\n" + + " public long id;\n" + + " public String title;\n" + + " public Date due;\n" + + " public Priority priority;\n" + + " public List tags;\n" + + " @com.codename1.annotations.JsonProperty(\"is_done\") public boolean done;\n" + + " @com.codename1.annotations.JsonIgnore public String secret = \"s3cret\";\n" + + " public transient String cache = \"cached\";\n" + + " private int rank;\n" + + " public int getRank() { return rank; }\n" + + " public void setRank(int rank) { this.rank = rank; }\n" + + " public byte[] blob;\n" + + " public Map counts;\n" + + " public Note() { }\n" + + "}\n"); + s.put("com.example.Special", PKG + "public class Special extends Note {\n" + + " public String extra = \"more\";\n" + + "}\n"); + s.put("com.example.Node", PKG + "public class Node {\n" + + " public Node next;\n" + + "}\n"); + s.put("com.example.Box", PKG + "public class Box {\n" + + " public Object content;\n" + + " public Map extras = new LinkedHashMap();\n" + + "}\n"); + s.put("com.example.Loner", PKG + "public class Loner {\n" + + " public String x = \"y\";\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " static Note note() {\n" + + " Note n = new Note();\n" + + " n.id = 7; n.title = \"hi\"; n.due = new Date(86400000L);\n" + + " n.priority = Priority.HIGH; n.done = true; n.setRank(3);\n" + + " n.tags = new ArrayList(); n.tags.add(new Tag(\"a\"));\n" + + " n.blob = new byte[] {1, 2, 3};\n" + + " n.counts = new LinkedHashMap(); n.counts.put(\"x\", 1);\n" + + " return n;\n" + + " }\n" + + " @GetMapping(\"/note\") public Note one() { return note(); }\n" + + " @GetMapping(\"/notes\") public List all() {\n" + + " List out = new ArrayList();\n" + + " out.add(note()); out.add(new Special());\n" + + " return out;\n" + + " }\n" + + " @PostMapping(\"/echo\") public Note echo(@RequestBody Note n) { return n; }\n" + + " @PostMapping(\"/count\") public String count(@RequestBody List ns) {\n" + + " return ns.size() + \":\" + ns.get(1).tags.get(0).name;\n" + + " }\n" + + " @GetMapping(\"/loop\") public Node loop() {\n" + + " Node a = new Node(); a.next = a; return a;\n" + + " }\n" + + " @GetMapping(\"/box\") public Box box() {\n" + + " Box b = new Box(); b.content = new Tag(\"inside\");\n" + + " b.extras.put(\"when\", new Date(5L));\n" + + " b.extras.put(\"level\", Priority.LOW);\n" + + " return b;\n" + + " }\n" + + " @GetMapping(\"/opaque\") public Box opaque() {\n" + + " Box b = new Box(); b.content = new Loner(); return b;\n" + + " }\n" + + "}\n"); + return s; + } + + @Test + public void aControllerReturnsAndAcceptsItsOwnClassesAsJson() throws Exception { + File classes = compile(dtoSample()); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + String one = http("GET", port, "/note"); + for (String part : new String[] {"\"id\":7", "\"title\":\"hi\"", "\"due\":86400000", + "\"priority\":\"HIGH\"", "\"tags\":[{\"name\":\"a\"}]", "\"is_done\":true", + "\"rank\":3", "\"blob\":\"AQID\"", "\"counts\":{\"x\":1}"}) { + assertTrue(part + " missing from " + one, one.contains(part)); + } + assertFalse("@JsonIgnore was written: " + one, one.contains("s3cret")); + assertFalse("a transient field was written: " + one, one.contains("cached")); + + String all = http("GET", port, "/notes"); + assertTrue("a subclass was written as its declared type: " + all, + all.contains("\"extra\":\"more\"")); + + String echoed = post(port, "/echo", "{\"id\":9,\"title\":\"t\",\"unknown\":[1]," + + "\"due\":\"1970-01-02T00:00:00Z\",\"priority\":\"LOW\",\"is_done\":true," + + "\"rank\":5,\"tags\":[{\"name\":\"b\"}],\"blob\":\"AQID\"," + + "\"counts\":{\"y\":2}}"); + for (String part : new String[] {"\"id\":9", "\"due\":86400000", "\"priority\":\"LOW\"", + "\"is_done\":true", "\"rank\":5", "\"tags\":[{\"name\":\"b\"}]", + "\"blob\":\"AQID\"", "\"counts\":{\"y\":2}"}) { + assertTrue(part + " missing from " + echoed, echoed.contains(part)); + } + assertTrue(post(port, "/count", "[{},{\"tags\":[{\"name\":\"z\"}]}]").equals("2:z")); + + assertEquals("HTTP 400: $.id: expected a whole number from -9223372036854775808 to " + + "9223372036854775807, got a string", post(port, "/echo", "{\"id\":\"x\"}")); + assertEquals("HTTP 400: $[1].tags[0].name: expected a string, got the number 5", + post(port, "/count", "[{},{\"tags\":[{\"name\":5}]}]")); + assertEquals("HTTP 400: $.priority: expected one of LOW, HIGH, got a string", + post(port, "/echo", "{\"priority\":\"MEDIUM\"}")); + assertEquals("HTTP 400: The request body is not valid JSON", + post(port, "/echo", "{nope")); + assertTrue(http("GET", port, "/loop").startsWith("HTTP 500")); + // An Object field is written by what it holds: a class this build + // writes goes through its codec, a Date as millis, an enum by name. + assertEquals("{\"content\":{\"name\":\"inside\"},\"extras\":{\"when\":5," + + "\"level\":\"LOW\"}}", http("GET", port, "/box")); + // A class with no codec is refused, never written as its toString(). + assertTrue(http("GET", port, "/opaque").startsWith("HTTP 500")); + } finally { + backend.stop(); + } + } + + @Test + public void theGuidesOrderExampleWorksAsDocumented() throws Exception { + // The developer guide's own files, not a copy: what the chapter shows is + // what builds and answers here. + File dir = new File("../../docs/demos/backend/src/main/java/com/codenameone/" + + "developerguide/backend/orders"); + assertTrue(dir.getAbsolutePath(), dir.isDirectory()); + Map s = new LinkedHashMap(); + for (String name : new String[] {"Order", "OrderLine", "OrdersApi"}) { + s.put("com.codenameone.developerguide.backend.orders." + name, new String( + java.nio.file.Files.readAllBytes(new File(dir, name + ".java").toPath()), + "UTF-8")); + } + File classes = compile(s); + assertNoErrors(process(classes)); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Backend.Application app = (Backend.Application) loader.loadClass( + "com.codenameone.developerguide.backend.orders.BackendWiring").newInstance(); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().application(app) + .start(); + try { + String placed = post(port, "/orders", + "{\"customer\":\"Ada\",\"lines\":[{\"sku\":\"A-1\",\"quantity\":2}]}"); + assertTrue(placed, placed.matches("\\{\"id\":1,\"customer\":\"Ada\"," + + "\"placed_at\":[0-9]+,\"lines\":\\[\\{\"sku\":\"A-1\",\"quantity\":2\\}\\]\\}")); + assertEquals(placed, http("GET", port, "/orders/1")); + assertTrue(http("GET", port, "/orders/2").startsWith("HTTP 404")); + } finally { + backend.stop(); + } + } + + @Test + public void aClassNoCodecCanBeWrittenForIsABuildError() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Page", PKG + "public class Page { public List items; }\n"); + s.put("com.example.Fixed", PKG + "public class Fixed {\n" + + " public final String id;\n" + + " public Fixed(String id) { this.id = id; }\n" + + "}\n"); + // One controller each: a class stops at its first refused route. + s.put("com.example.PageApi", PKG + + "@RestController public class PageApi {\n" + + " @GetMapping(\"/p\") public Page page() { return null; }\n" + + "}\n"); + s.put("com.example.FixedApi", PKG + + "@RestController public class FixedApi {\n" + + " @PostMapping(\"/f\") public String f(@RequestBody Fixed f) { return f.id; }\n" + + "}\n"); + s.put("com.example.ArrayApi", PKG + + "@RestController public class ArrayApi {\n" + + " @GetMapping(\"/a\") public int[] a() { return null; }\n" + + "}\n"); + s.put("com.example.Unordered", PKG + "public class Unordered { public String n; }\n"); + s.put("com.example.SetApi", PKG + + "@RestController public class SetApi {\n" + + " @PostMapping(\"/s\") public String s(@RequestBody TreeSet s) {\n" + + " return \"x\";\n" + + " }\n" + + " @PostMapping(\"/t\") public String t(@RequestBody TreeSet s) {\n" + + " return \"x\";\n" + + " }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("has a type variable for its type")); + assertTrue(errors, errors.contains("has no constructor without arguments")); + assertTrue(errors, errors.contains("an array, which has no JSON form here other than " + + "byte[]")); + assertTrue(errors, errors.contains("a TreeSet of com.example.Unordered, which is not " + + "Comparable")); + assertFalse("a TreeSet of String is ordered by String: " + errors, + errors.contains("TreeSet of java.lang.String")); + } + + @Test + public void aPackagedServerLinksManagementAndMcpOnlyWhenAsked() throws Exception { + Map plain = new LinkedHashMap(); + plain.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @GetMapping(\"/x\") public String x() { return \"x\"; }\n" + + "}\n"); + RestControllerAnnotationProcessor proc = new RestControllerAnnotationProcessor(); + proc.setDevTools(false); + File classes = compile(plain); + assertNoErrors(process(classes, proc)); + String bootstrap = proc.generateBootstrap("com.example"); + assertFalse("nothing asked for management, yet the entry point names it:\n" + bootstrap, + bootstrap.contains(".management()")); + assertFalse("nothing asked for MCP, yet the entry point names it:\n" + bootstrap, + bootstrap.contains(".mcp(")); + assertFalse(bootstrap, bootstrap.contains(".compiledSettings(")); + + Map s = new LinkedHashMap(plain); + s.put("com.example.Settings", PKG + + "@EnableManagement(path = \"/ops\") @EnableMcpServer(allowedOrigins = " + + "{\"https://a.example\", \"https://b.example\"})\n" + + "public class Settings { }\n"); + proc = new RestControllerAnnotationProcessor(); + proc.setDevTools(false); + assertNoErrors(process(compile(s), proc)); + bootstrap = proc.generateBootstrap("com.example"); + assertTrue(bootstrap, bootstrap.contains(".management()")); + assertTrue(bootstrap, bootstrap.contains(".mcp(null)")); + assertTrue(bootstrap, bootstrap.contains("\"cn1.management.enabled\", \"true\"")); + assertTrue(bootstrap, bootstrap.contains("\"cn1.management.path\", \"/ops\"")); + assertTrue(bootstrap, bootstrap.contains( + "\"cn1.mcp.allowedOrigins\", \"https://a.example,https://b.example\"")); + assertFalse("@EnableMcpServer links the endpoint; whether it serves stays the " + + "endpoint's own default:\n" + bootstrap, bootstrap.contains("cn1.mcp.enabled")); + + // The property, in a profile's file, asks as surely as the annotation. + classes = compile(plain); + java.io.FileWriter w = new java.io.FileWriter(new File(classes, "application.properties")); + w.write("cn1.profile=prod\n"); + w.close(); + w = new java.io.FileWriter(new File(classes, "application-prod.properties")); + w.write("cn1.management.enabled=true\n"); + w.close(); + proc = new RestControllerAnnotationProcessor(); + proc.setDevTools(false); + assertNoErrors(process(classes, proc)); + assertTrue(proc.generateBootstrap("com.example").contains(".management()")); + } + + @Test + public void settingsAnnotationsBecomeCompiledSettings() throws Exception { + Map s = sample(); + s.put("com.example.Settings", PKG + + "@ServerConfig(port = 8081, workers = 4)\n" + + "@SessionConfig(store = \"DB\", timeoutSeconds = 0, sameSite = \"strict\")\n" + + "@DataSourceConfig(url = \"${DATABASE_URL}\", poolSize = 3)\n" + + "@StaticFilesConfig(root = \"www\", prefix = \"/assets\")\n" + + "public class Settings { }\n"); + RestControllerAnnotationProcessor proc = new RestControllerAnnotationProcessor(); + assertNoErrors(process(compile(s), proc)); + String bootstrap = proc.generateBootstrap("com.example"); + for (String pair : new String[] {"\"cn1.server.port\", \"8081\"", + "\"cn1.server.workers\", \"4\"", "\"cn1.session.store\", \"db\"", + "\"cn1.session.timeout\", \"0\"", "\"cn1.session.same-site\", \"Strict\"", + "\"cn1.datasource.url\", \"${DATABASE_URL}\"", + "\"cn1.datasource.pool.size\", \"3\"", "\"cn1.static.root\", \"www\"", + "\"cn1.static.prefix\", \"/assets\""}) { + assertTrue(pair + " missing from:\n" + bootstrap, bootstrap.contains(pair)); + } + assertFalse("an attribute left at its default set a key:\n" + bootstrap, + bootstrap.contains("cn1.server.backlog")); + } + + @Test + public void aSettingTheRuntimeWouldRefuseIsABuildError() throws Exception { + Map s = sample(); + s.put("com.example.Settings", PKG + + "@SessionConfig(store = \"jdbc\") public class Settings { }\n"); + ProcessorContext ctx = process(compile(s)); + assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) + .contains("@SessionConfig store is \"jdbc\"; it must be memory or db.")); + s.put("com.example.Settings", PKG + + "@ServerConfig(port = 70000) public class Settings { }\n"); + ctx = process(compile(s)); + assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) + .contains("@ServerConfig port is 70000; a port is 0 to 65535.")); + s.put("com.example.Settings", PKG + + "@EnableManagement(path = \"ops\") public class Settings { }\n"); + ctx = process(compile(s)); + assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) + .contains("@EnableManagement path is \"ops\"; a path starts with /.")); + } + + @Test + public void twoClassesSettingOneKeyDifferentlyIsRefused() throws Exception { + Map s = sample(); + s.put("com.example.A", PKG + "@SessionConfig(store = \"db\") public class A { }\n"); + s.put("com.example.B", PKG + "@SessionConfig(store = \"memory\") public class B { }\n"); + ProcessorContext ctx = process(compile(s)); + String errors = String.valueOf(ctx.getErrors()); + assertTrue(errors, errors.contains("cn1.session.store is set to")); + assertTrue(errors, errors.contains("Keep one of them.")); + s.put("com.example.B", PKG + "@SessionConfig(store = \"db\") public class B { }\n"); + assertNoErrors(process(compile(s))); + } + + @Test + public void aMissingBeanIsABuildErrorNamingTheInjectionPoint() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Mailer", PKG + "public interface Mailer { }\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " public Api(Mailer mailer) { }\n" + + " @GetMapping(\"/x\") public String x() { return \"x\"; }\n" + + "}\n"); + ProcessorContext ctx = process(compile(s)); + assertTrue("a constructor needing a bean nobody provides built", ctx.hasErrors()); + assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) + .contains("constructor parameter 1 of com.example.Api needs a com.example.Mailer")); + } + + @Test + public void twoCandidatesWithoutPrimaryAreAmbiguous() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Mailer", PKG + "public interface Mailer { }\n"); + s.put("com.example.SmtpMailer", PKG + "@Component public class SmtpMailer implements Mailer { }\n"); + s.put("com.example.LogMailer", PKG + "@Component public class LogMailer implements Mailer { }\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " public Api(Mailer mailer) { }\n" + + " @GetMapping(\"/x\") public String x() { return \"x\"; }\n" + + "}\n"); + ProcessorContext ctx = process(compile(s)); + assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) + .contains("Mark one @Primary, or name one with @Qualifier")); + s.put("com.example.LogMailer", PKG + + "@Component @Primary public class LogMailer implements Mailer { }\n"); + assertNoErrors(process(compile(s))); + } + + @Test + public void aConstructorCycleIsRefused() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.A", PKG + "@Component public class A { public A(B b) { } }\n"); + s.put("com.example.B", PKG + "@Component public class B { public B(A a) { } }\n"); + ProcessorContext ctx = process(compile(s)); + assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) + .contains("The constructors form a cycle")); + } + + @Test + public void aFieldCycleIsFine() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.A", PKG + "@Component public class A { @Autowired B b; public B b() { return b; } }\n"); + s.put("com.example.B", PKG + "@Component public class B { @Autowired A a; public A a() { return a; } }\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " private final A a;\n" + + " public Api(A a) { this.a = a; }\n" + + " @GetMapping(\"/x\") public String x() {\n" + + " return String.valueOf(a.b().a() == a);\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + assertEquals("true", http("GET", port, "/x")); + } finally { + backend.stop(); + } + } + + @Test + public void anInvalidCronExpressionIsABuildError() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Jobs", PKG + + "@Service public class Jobs {\n" + + " @Scheduled(cron = \"0 0 25 * * *\") public void never() { }\n" + + "}\n"); + ProcessorContext ctx = process(compile(s)); + assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) + .contains("the hour field of \"0 0 25 * * *\" names 25")); + } + + @Test + public void anAsyncMethodMustReturnVoidOrFuture() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Jobs", PKG + + "@Service public class Jobs {\n" + + " @Async public String now() { return \"x\"; }\n" + + "}\n"); + ProcessorContext ctx = process(compile(s)); + assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) + .contains("can only receive nothing or a java.util.concurrent.Future")); + } + + @Test + public void profilesPickTheBeanAtStartUp() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Mailer", PKG + "public interface Mailer { String name(); }\n"); + s.put("com.example.DevMailer", PKG + "@Component @Profile(\"dev\") public class DevMailer " + + "implements Mailer { public String name() { return \"dev\"; } }\n"); + s.put("com.example.ProdMailer", PKG + "@Component @Profile(\"!dev\") public class ProdMailer " + + "implements Mailer { public String name() { return \"prod\"; } }\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " private final Mailer mailer;\n" + + " public Api(Mailer mailer) { this.mailer = mailer; }\n" + + " @GetMapping(\"/x\") public String x() { return mailer.name(); }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend dev = start(classes, port, new Properties()); + try { + assertEquals("dev", http("GET", port, "/x")); + } finally { + dev.stop(); + } + } + + @Test + public void aFactoryBeanIsConditionalOnItsConfigurationClass() throws Exception { + // A @Profile("prod") configuration's @Bean methods, static or not, exist + // only on prod -- as in Spring -- rather than being built on every + // profile, the instance one through a configuration object that is null. + Map s = new LinkedHashMap(); + s.put("com.example.Marker", PKG + "public class Marker { }\n"); + s.put("com.example.ProdConfig", PKG + "@Configuration @Profile(\"prod\")\n" + + "@ConditionalOnProperty(\"prod.enabled\") public class ProdConfig {\n" + + " @Bean public StringBuilder prodThing() { return new StringBuilder(\"p\"); }\n" + + " @Bean public static Marker prodMarker() { return new Marker(); }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired(required = false) private StringBuilder thing;\n" + + " @Autowired(required = false) private Marker marker;\n" + + " @GetMapping(\"/x\") public String x() {\n" + + " return (thing != null) + \",\" + (marker != null);\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend dev = start(classes, port, new Properties()); + try { + assertEquals("false,false", http("GET", port, "/x")); + } finally { + dev.stop(); + } + } + + @Test + public void aRequestScopedFactoryBeanIsDestroyedByItsDestroyMethod() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Handle", PKG + "public class Handle {\n" + + " public static int closed;\n" + + " public void close() { closed++; }\n" + + " public String use() { return \"used\"; }\n" + + "}\n"); + s.put("com.example.Handles", PKG + "@Configuration public class Handles {\n" + + " @Bean(destroyMethod = \"close\") @RequestScope\n" + + " public Handle handle() { return new Handle(); }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired private Handle handle;\n" + + " @GetMapping(\"/x\") public String x() {\n" + + " handle.use();\n" + + " return String.valueOf(Handle.closed);\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + assertEquals("0", http("GET", port, "/x")); + assertEquals("the first request's bean was never closed", "1", + http("GET", port, "/x")); + } finally { + backend.stop(); + } + } + + @Test + public void membersInheritedFromABaseClassAreInjected() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Clock", PKG + "@Component public class Clock " + + "{ public String now() { return \"t\"; } }\n"); + s.put("com.example.BaseService", PKG + "public abstract class BaseService {\n" + + " @Autowired private Clock clock;\n" + + " @Value(\"${label:base}\") protected String label;\n" + + " protected int inits;\n" + + " @PostConstruct void baseInit() { inits++; }\n" + + " protected String stamp() { return clock.now() + label; }\n" + + "}\n"); + s.put("com.example.Orders", PKG + "@Service public class Orders extends BaseService {\n" + + " @PostConstruct void ownInit() { inits += 10; }\n" + + " public String describe() { return stamp() + inits; }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " private final Orders orders;\n" + + " public Api(Orders orders) { this.orders = orders; }\n" + + " @GetMapping(\"/x\") public String x() { return orders.describe(); }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + // Base field and value injected, base @PostConstruct run, then the bean's. + assertEquals("tbase11", http("GET", port, "/x")); + } finally { + backend.stop(); + } + } + + @Test + public void aMissingBeanDefaultStepsAsideForAnotherImplementation() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Mailer", PKG + "public interface Mailer { String name(); }\n"); + s.put("com.example.DefaultMailer", PKG + "@Component @ConditionalOnMissingBean\n" + + "public class DefaultMailer implements Mailer " + + "{ public String name() { return \"default\"; } }\n"); + s.put("com.example.SmtpMailer", PKG + "@Component public class SmtpMailer " + + "implements Mailer { public String name() { return \"smtp\"; } }\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " private final Mailer mailer;\n" + + " public Api(Mailer mailer) { this.mailer = mailer; }\n" + + " @GetMapping(\"/x\") public String x() { return mailer.name(); }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + assertEquals("smtp", http("GET", port, "/x")); + } finally { + backend.stop(); + } + } + + @Test + public void aSynchronizedAsyncMethodHoldsItsMonitorWhereTheBodyRuns() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Worker", PKG + "@Service public class Worker {\n" + + " @Async public synchronized void work() { }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " private final Worker w;\n" + + " public Api(Worker w) { this.w = w; }\n" + + " @GetMapping(\"/x\") public String x() { w.work(); return \"ok\"; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Class worker = loader.loadClass("com.example.Worker"); + java.lang.reflect.Method body = worker.getDeclaredMethod( + BackendWeaver.bodyName("com/example/Worker", "work")); + assertTrue("the woven body lost its synchronized, so executor threads could run it " + + "at once", java.lang.reflect.Modifier.isSynchronized(body.getModifiers())); + } + + @Test + public void emptyNegatedProfilesAndFutureLifecyclesAreCaught() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Everywhere", PKG + "@Component @Profile(\"!\") public class Everywhere {\n" + + "}\n"); + s.put("com.example.Starter", PKG + "@Component public class Starter {\n" + + " @PostConstruct public Future warm() { return AsyncResult.of(\"x\"); }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("has \"!\", which names no profile")); + assertTrue(String.valueOf(warnings), String.valueOf(warnings) + .contains("@PostConstruct method com.example.Starter.warm returns a Future")); + } + + @Test + public void anAsyncLifecycleMethodRunsSynchronously() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Warm", PKG + "@Component public class Warm {\n" + + " public static volatile String state = \"cold\";\n" + + " @PostConstruct @Async public void init() throws Exception {\n" + + " Thread.sleep(200);\n" + + " state = \"warm\";\n" + + " }\n" + + "}\n"); + s.put("com.example.Api", PKG + "@RestController public class Api {\n" + + " @Autowired private Warm warm;\n" + + " @GetMapping(\"/x\") public String x() { return Warm.state; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + assertTrue(String.valueOf(warnings), String.valueOf(warnings) + .contains("com.example.Warm.init is a lifecycle method")); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + assertEquals("the server was ready before @PostConstruct had run", "warm", + http("GET", port, "/x")); + } finally { + backend.stop(); + } + } + + @Test + public void twoScopedFactoryBeansOfOneClassKeepTheirOwnStandIns() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Label", PKG + "public class Label {\n" + + " private final String text;\n" + + " public Label() { this(\"none\"); }\n" + + " public Label(String text) { this.text = text; }\n" + + " public String text() { return text; }\n" + + "}\n"); + s.put("com.example.Labels", PKG + "@Configuration public class Labels {\n" + + " @Bean @RequestScope public Label east() { return new Label(\"east\"); }\n" + + " @Bean @RequestScope public Label west() { return new Label(\"west\"); }\n" + + "}\n"); + s.put("com.example.Api", PKG + "@RestController public class Api {\n" + + " @Autowired @Qualifier(\"east\") private Label east;\n" + + " @Autowired @Qualifier(\"west\") private Label west;\n" + + " @GetMapping(\"/x\") public String x() { return east.text() + \",\" + west.text(); }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + assertEquals("east,west", http("GET", port, "/x")); + } finally { + backend.stop(); + } + } + + @Test + public void aScopedBeanWhoseConstructorCallsAnOverridableMethodStarts() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Greeter", PKG + "@Component @RequestScope public class Greeter {\n" + + " private final String greeting;\n" + + " public Greeter() { greeting = prefix() + \" there\"; }\n" + + " public String prefix() { return \"hi\"; }\n" + + " public String hello() { return greeting; }\n" + + "}\n"); + s.put("com.example.Api", PKG + "@RestController public class Api {\n" + + " @Autowired private Greeter greeter;\n" + + " @GetMapping(\"/x\") public String x() { return greeter.hello(); }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + assertEquals("hi there", http("GET", port, "/x")); + } finally { + backend.stop(); + } + } + + @Test + public void supportNamesStayDistinct() throws Exception { + // Aa and BB share a Java hash; Outer_Inner and Outer$Inner folded alike. + assertEquals("Aa".hashCode(), "BB".hashCode()); + assertNotEquals(BackendWeaver.bodyName("p/Aa", "work"), + BackendWeaver.bodyName("p/BB", "work")); + assertNotEquals(BackendBeans.baseName("p/Outer_Inner"), + BackendBeans.baseName("p/Outer$Inner")); + assertNotEquals(BackendBeans.baseName("p/A$_B"), BackendBeans.baseName("p/A_$B")); + Map s = new LinkedHashMap(); + s.put("com.example.Aa", PKG + "public class Aa {\n" + + " @Timed(\"aa.work\") public String work() { return \"base\"; }\n" + + "}\n"); + s.put("com.example.BB", PKG + "public class BB extends Aa {\n" + + " @Timed(\"bb.work\") public String work() { return \"sub+\" + super.work(); }\n" + + "}\n"); + s.put("com.example.Outer_Inner", PKG + "public class Outer_Inner {\n" + + " @Timed(\"flat.work\") public String work() { return \"flat\"; }\n" + + "}\n"); + s.put("com.example.Outer", PKG + "public class Outer {\n" + + " public static class Inner {\n" + + " @Timed(\"nested.work\") public String work() { return \"nested\"; }\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Object sub = loader.loadClass("com.example.BB").newInstance(); + assertEquals("sub+base", sub.getClass().getMethod("work").invoke(sub)); + Object flat = loader.loadClass("com.example.Outer_Inner").newInstance(); + assertEquals("flat", flat.getClass().getMethod("work").invoke(flat)); + Object nested = loader.loadClass("com.example.Outer$Inner").newInstance(); + assertEquals("nested", nested.getClass().getMethod("work").invoke(nested)); + } + + @Test + public void aWovenOverrideCallingSuperRunsTheBaseBody() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Base", PKG + "public class Base {\n" + + " @Timed(\"base.work\") public String work() { return \"base\"; }\n" + + "}\n"); + s.put("com.example.Sub", PKG + "public class Sub extends Base {\n" + + " @Timed(\"sub.work\") public String work() { return \"sub+\" + super.work(); }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Object sub = loader.loadClass("com.example.Sub").newInstance(); + try { + assertEquals("sub+base", sub.getClass().getMethod("work").invoke(sub)); + } catch (java.lang.reflect.InvocationTargetException err) { + throw new AssertionError("the base body was overridden by the subclass's: " + + err.getCause(), err.getCause()); + } + } + + @Test + public void sessionScopedBeansAreDestroyedWhenTheSessionIsInvalidated() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Cart", PKG + "@Component @SessionScope public class Cart {\n" + + " public static int destroyed;\n" + + " private int items;\n" + + " public int add() { return ++items; }\n" + + " @PreDestroy void close() { destroyed++; }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired private Cart cart;\n" + + " @GetMapping(\"/add\") public String add() { return String.valueOf(cart.add()); }\n" + + " @GetMapping(\"/logout\") public String logout(HttpServer.Request r) {\n" + + " r.getSession(true).invalidate();\n" + + " return \"out\";\n" + + " }\n" + + " @GetMapping(\"/destroyed\") public String destroyed() {\n" + + " return String.valueOf(Cart.destroyed);\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + HttpURLConnection first = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/add").openConnection(); + assertEquals("1", read(first)); + String cookie = first.getHeaderField("Set-Cookie"); + String pair = cookie.substring(0, cookie.indexOf(';')); + HttpURLConnection out = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/logout").openConnection(); + out.setRequestProperty("Cookie", pair); + assertEquals("out", read(out)); + assertEquals("the invalidated session's bean was never destroyed", "1", + http("GET", port, "/destroyed")); + // A session still open when the server stops is destroyed with it. + assertEquals("1", http("GET", port, "/add")); + } finally { + backend.stop(); + } + URLClassLoader loader = (URLClassLoader) backend.getApplication().getClass() + .getClassLoader(); + assertEquals(2, loader.loadClass("com.example.Cart").getField("destroyed").getInt(null)); + } + + @Test + public void aScopedOrOverloadedManagedResourceIsABuildError() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Visits", PKG + "@Component @SessionScope @ManagedResource\n" + + "public class Visits {\n" + + " @ManagedAttribute public int getCount() { return 1; }\n" + + "}\n"); + s.put("com.example.Cache", PKG + "@Component @ManagedResource public class Cache {\n" + + " @ManagedOperation public void evict() { }\n" + + " @ManagedOperation public void evict(String key) { }\n" + + "}\n"); + ProcessorContext ctx = process(compile(s)); + String errors = String.valueOf(ctx.getErrors()); + assertTrue(errors, errors.contains("must be a singleton")); + assertTrue(errors, errors.contains("is overloaded")); + } + + @Test + public void aFactoryBeanRunsInheritedCallbacksAndDestroysSubclassFirst() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Log", PKG + "public class Log { public static String text = \"\"; }\n"); + s.put("com.example.BaseClient", PKG + "public class BaseClient {\n" + + " @PostConstruct public void open() { Log.text += \"open,\"; }\n" + + " @PreDestroy public void closeBase() { Log.text += \"base,\"; }\n" + + "}\n"); + s.put("com.example.Client", PKG + "public class Client extends BaseClient {\n" + + " @PreDestroy public void flush() { Log.text += \"sub,\"; }\n" + + "}\n"); + s.put("com.example.Clients", PKG + "@Configuration public class Clients {\n" + + " @Bean public Client client() { return new Client(); }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired private Client client;\n" + + " @GetMapping(\"/x\") public String x() { return Log.text; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + assertEquals("open,", http("GET", port, "/x")); + } finally { + backend.stop(); + } + Class log = backend.getApplication().getClass().getClassLoader() + .loadClass("com.example.Log"); + assertEquals("the subclass must release its state before the base tears down", + "open,sub,base,", log.getField("text").get(null)); + } + + @Test + public void aRequestBeanCanUseAnotherWhileItIsDestroyed() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Clock", PKG + "@Component @RequestScope public class Clock {\n" + + " public String now() { return \"t\"; }\n" + + "}\n"); + s.put("com.example.Audit", PKG + "@Component @RequestScope public class Audit {\n" + + " public static String last = \"none\";\n" + + " @Autowired private Clock clock;\n" + + " public void touch() { }\n" + + " @PreDestroy public void done() { last = clock.now(); }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired private Audit audit;\n" + + " @GetMapping(\"/x\") public String x() { audit.touch(); return Audit.last; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + assertEquals("none", http("GET", port, "/x")); + assertEquals("the @PreDestroy could not reach a request-scoped dependency", "t", + http("GET", port, "/x")); + } finally { + backend.stop(); + } + } + + @Test + public void aSessionBeanBuiltByAFailingRequestIsStillDestroyed() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Cart", PKG + "@Component @SessionScope public class Cart {\n" + + " public static int destroyed;\n" + + " public void add() { }\n" + + " @PreDestroy void close() { destroyed++; }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired private Cart cart;\n" + + " @GetMapping(\"/fail\") public String fail() {\n" + + " cart.add();\n" + + " throw new IllegalStateException(\"boom\");\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/fail").openConnection(); + assertEquals(500, c.getResponseCode()); + } finally { + backend.stop(); + } + Class cart = backend.getApplication().getClass().getClassLoader() + .loadClass("com.example.Cart"); + assertEquals("a session bean built by a failing request was dropped undestroyed", 1, + cart.getField("destroyed").getInt(null)); + } + + @Test + public void aConfigurationThatStepsAsideTakesItsFactoriesWithIt() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Store", PKG + "public interface Store { String name(); }\n"); + s.put("com.example.DefaultStores", PKG + + "@Configuration @ConditionalOnMissingBean(DbStore.class)\n" + + "public class DefaultStores {\n" + + " @Bean public static Store memoryStore() {\n" + + " return new Store() { public String name() { return \"memory\"; } };\n" + + " }\n" + + "}\n"); + s.put("com.example.DbStore", PKG + "@Component public class DbStore implements Store " + + "{ public String name() { return \"db\"; } }\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " private final Store store;\n" + + " public Api(Store store) { this.store = store; }\n" + + " @GetMapping(\"/x\") public String x() { return store.name(); }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + assertEquals("db", http("GET", port, "/x")); + } finally { + backend.stop(); + } + } + + @Test + public void futuresFromToolsAndOperationsAreBuildErrors() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Tools", PKG + "@Component public class Tools {\n" + + " @McpTool(description = \"a\")\n" + + " public Future pending() { return AsyncResult.of(\"x\"); }\n" + + "}\n"); + s.put("com.example.Ops", PKG + "@Component @ManagedResource(objectName = \"ops\")\n" + + "public class Ops {\n" + + " @ManagedOperation @Async public Future rebuild() {\n" + + " return AsyncResult.of(\"done\");\n" + + " }\n" + + " @ManagedOperation @Async public void refresh() { }\n" + + " @ManagedAttribute public Future getLevel() { return AsyncResult.of(1); }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("@McpTool method com.example.Tools.pending returns " + + "a Future")); + assertTrue(errors, errors.contains("@ManagedOperation com.example.Ops.rebuild returns " + + "a Future")); + assertFalse(errors, errors.contains("com.example.Ops.refresh")); + assertTrue(errors, errors.contains("@ManagedAttribute com.example.Ops.getLevel returns " + + "a Future")); + } + + @Test + public void oneMetricNameForTwoKindsIsABuildError() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Work", PKG + "@Component public class Work {\n" + + " @Timed(\"work\") @Counted(\"work\") public void both() { }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("Metric work is a counter for @Counted on " + + "com.example.Work.both and a histogram for @Timed on com.example.Work.both")); + // Across two methods too: the instruments are the process's. + s.put("com.example.Work", PKG + "@Component public class Work {\n" + + " @Timed(\"jobs\") public void a() { }\n" + + " @Counted(\"jobs\") public void b() { }\n" + + "}\n"); + errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("Metric jobs is a")); + // Two names that fold to one Prometheus name clash as surely. + s.put("com.example.Work", PKG + "@Component public class Work {\n" + + " @Timed(\"latency.ms\") public void a() { }\n" + + " @Timed(\"latency_ms\") public void b() { }\n" + + "}\n"); + errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("would be exported to Prometheus as latency_ms")); + s.put("com.example.Work", PKG + "@Component public class Work {\n" + + " @Timed(\"jobs.time\") @Counted(\"jobs\") public void a() { }\n" + + " @Counted(\"jobs\") public void b() { }\n" + + "}\n"); + assertNoErrors(process(compile(s))); + } + + @Test + public void anOverloadedPropertySetterBindsOnce() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Timeouts", PKG + "@Component @ConfigurationProperties(\"t\")\n" + + "public class Timeouts {\n" + + " public String calls = \"\";\n" + + " private int timeout;\n" + + " public void setTimeout(String v) { calls += \"string:\" + v + \",\"; }\n" + + " public void setTimeout(int v) { timeout = v; calls += \"int:\" + v + \",\"; }\n" + + " public int getTimeout() { return timeout; }\n" + + "}\n"); + s.put("com.example.Api", PKG + "@RestController public class Api {\n" + + " @Autowired private Timeouts timeouts;\n" + + " @GetMapping(\"/x\") public String x() { return timeouts.calls; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty("t.timeout", "5"); + Backend backend = start(classes, port, settings); + try { + assertEquals("each overload bound the same key", "int:5,", http("GET", port, "/x")); + } finally { + backend.stop(); + } + } + + @Test + public void anInactiveControllerIsNotListed() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Api", PKG + "@RestController public class Api {\n" + + " @GetMapping(\"/x\") public String x() { return \"x\"; }\n" + + "}\n"); + s.put("com.example.Admin", PKG + "@RestController @ConditionalOnProperty(\"admin.on\")\n" + + "public class Admin {\n" + + " @GetMapping(\"/admin\") public String admin() { return \"admin\"; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Backend.Application app = (Backend.Application) loader + .loadClass("com.example.BackendWiring").newInstance(); + int port = freePort(); + Properties off = new Properties(); + off.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(off, "dev")).quiet().application(app).start(); + try { + String listed = String.valueOf(app.describeRoutes()); + assertTrue(listed, listed.contains("/x")); + assertFalse("an inactive controller's route was listed: " + listed, + listed.contains("/admin")); + } finally { + backend.stop(); + } + Properties on = new Properties(); + on.setProperty(Config.SERVER_PORT, String.valueOf(port)); + on.setProperty("admin.on", "true"); + backend = Backend.builder(Config.of(on, "dev")).quiet().application(app).start(); + try { + assertTrue(String.valueOf(app.describeRoutes()).contains("/admin")); + } finally { + backend.stop(); + } + } + + @Test + public void anInactiveOptionalDependencyKeepsTheInitializer() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Feature", PKG + "public class Feature {\n" + + " public String name() { return \"fallback\"; }\n" + + "}\n"); + s.put("com.example.RealFeature", PKG + "@Component @ConditionalOnProperty(\"feature.x\")\n" + + "public class RealFeature extends Feature {\n" + + " public String name() { return \"real\"; }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired(required = false) private Feature feature = new Feature();\n" + + " private Feature viaSetter = new Feature();\n" + + " @Autowired(required = false) public void use(Feature f) { viaSetter = f; }\n" + + " @GetMapping(\"/x\") public String x() {\n" + + " return feature.name() + \" \" + viaSetter.name();\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend off = start(classes, port, new Properties()); + try { + assertEquals("an inactive conditional bean overwrote the initializer with null", + "fallback fallback", http("GET", port, "/x")); + } finally { + off.stop(); + } + Properties settings = new Properties(); + settings.setProperty("feature.x", "true"); + Backend on = start(classes, port, settings); + try { + assertEquals("real real", http("GET", port, "/x")); + } finally { + on.stop(); + } + } + + @Test + public void aRestartedApplicationForgetsTheBeansOfTheLastStart() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Feature", PKG + "@Component @ConditionalOnProperty(\"feature.x\")\n" + + "public class Feature { }\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired(required = false) private Feature feature;\n" + + " @GetMapping(\"/x\") public String x() { return String.valueOf(feature != null); }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Backend.Application app = (Backend.Application) loader + .loadClass("com.example.BackendWiring").newInstance(); + int port = freePort(); + Properties on = new Properties(); + on.setProperty(Config.SERVER_PORT, String.valueOf(port)); + on.setProperty("feature.x", "true"); + Backend first = Backend.builder(Config.of(on, "dev")).quiet().application(app).start(); + try { + assertEquals("true", http("GET", port, "/x")); + } finally { + first.stop(); + } + Properties off = new Properties(); + off.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend second = Backend.builder(Config.of(off, "dev")).quiet().application(app).start(); + try { + assertEquals("the second start injected the first start's destroyed bean", + "false", http("GET", port, "/x")); + } finally { + second.stop(); + } + } + + @Test + public void aModuleWithOnlyAManagedResourceGetsAnApplication() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Stats", PKG + "@Component @ManagedResource public class Stats {\n" + + " @ManagedAttribute public int getCount() { return 1; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + assertTrue("no application was generated for a managed-resource-only module", + new File(classes, "com/example/BackendWiring.class").isFile()); + } + + @Test + public void aScopedBeanForwardsMethodsItInheritsFromALibrary() throws Exception { + Map lib = new LinkedHashMap(); + lib.put("org.lib.BaseCounter", "package org.lib;\n" + + "public class BaseCounter {\n" + + " private int n;\n" + + " public int next() { return ++n; }\n" + + "}\n"); + File libClasses = tmp.newFolder(); + JavaSourceCompiler.compile(lib, libClasses, backendClasspath()); + List cp = new ArrayList(backendClasspath()); + cp.add(libClasses); + Map s = new LinkedHashMap(); + s.put("com.example.Visits", PKG + "@Component @RequestScope\n" + + "public class Visits extends org.lib.BaseCounter { }\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired private Visits visits;\n" + + " @GetMapping(\"/x\") public String x() {\n" + + " visits.next();\n" + + " return String.valueOf(visits.next());\n" + + " }\n" + + "}\n"); + File classes = tmp.newFolder(); + JavaSourceCompiler.compile(s, classes, cp); + assertNoErrors(process(classes, new RestControllerAnnotationProcessor(), libClasses)); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL(), + libClasses.toURI().toURL()}, getClass().getClassLoader()); + Class proxy = loader.loadClass("com.example.VisitsCn1Scoped"); + assertNotNull("the stand-in does not forward next(), which its bean inherits", + proxy.getDeclaredMethod("next")); + } + + @Test + public void aRequestBeanDestroyedAfterTheHandlerCanStillUseTheSession() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit {\n" + + " @Autowired private HttpServer.Request request;\n" + + " public void touch() { }\n" + + " @PreDestroy public void done() {\n" + + " request.getSession(true).setAttribute(\"seen\", \"yes\");\n" + + " }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired private Visit visit;\n" + + " @GetMapping(\"/x\") public String x(HttpServer.Request r) {\n" + + " visit.touch();\n" + + " HttpSession session = r.getSession(false);\n" + + " return session == null ? \"none\" : String.valueOf(session.getAttribute(\"seen\"));\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + HttpURLConnection first = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/x").openConnection(); + assertEquals("none", read(first)); + String cookie = first.getHeaderField("Set-Cookie"); + assertNotNull("the session a @PreDestroy started was never sent to the client", + cookie); + HttpURLConnection second = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/x").openConnection(); + second.setRequestProperty("Cookie", cookie.substring(0, cookie.indexOf(';'))); + assertEquals("yes", read(second)); + } finally { + backend.stop(); + } + } + + @Test + public void aFailedStatementRollsBackAndAnotherCheckedExceptionCommits() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Rows", PKG + "@Service public class Rows {\n" + + " private final DataSource db;\n" + + " public Rows(DataSource db) { this.db = db; }\n" + + " @PostConstruct public void schema() throws java.io.IOException {\n" + + " db.execute(\"CREATE TABLE r (v TEXT)\", null);\n" + + " }\n" + + " @Transactional public void badStatement() throws java.io.IOException {\n" + + " db.execute(\"INSERT INTO r (v) VALUES ('a')\", null);\n" + + " db.execute(\"INSERT INTO no_such_table (v) VALUES ('b')\", null);\n" + + " }\n" + + " @Transactional public void fileFailure() throws java.io.IOException {\n" + + " db.execute(\"INSERT INTO r (v) VALUES ('c')\", null);\n" + + " throw new java.io.IOException(\"the file was not there\");\n" + + " }\n" + + " public int count() throws java.io.IOException {\n" + + " return ((Number) db.queryOne(\"SELECT COUNT(*) AS n FROM r\", null)" + + ".get(\"n\")).intValue();\n" + + " }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " private final Rows rows;\n" + + " public Api(Rows rows) { this.rows = rows; }\n" + + " @GetMapping(\"/bad\") public String bad() throws java.io.IOException {\n" + + " try { rows.badStatement(); } catch (DataAccessException e) { }\n" + + " return String.valueOf(rows.count());\n" + + " }\n" + + " @GetMapping(\"/file\") public String file() throws java.io.IOException {\n" + + " try { rows.fileFailure(); } catch (java.io.IOException e) { }\n" + + " return String.valueOf(rows.count());\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet() + .requiresDataSource() + .application((Backend.Application) loader + .loadClass("com.example.BackendWiring").newInstance()) + .start(); + try { + // As in Spring, where a failed statement is an unchecked + // DataAccessException: the insert before it is undone. + assertEquals("a failed statement committed the work before it", "0", + http("GET", port, "/bad")); + // And any other checked exception commits, as Spring's rule says. + assertEquals("1", http("GET", port, "/file")); + } finally { + backend.stop(); + } + } + + @Test + public void duplicateToolAndManagedNamesAreBuildErrors() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.a.Stats", "package com.example.a;\n" + + "import com.codename1.backend.annotations.*;\n" + + "@Component @ManagedResource public class Stats {\n" + + " @ManagedAttribute public int getN() { return 1; }\n" + + " @McpTool(description = \"x\") public String find() { return \"a\"; }\n" + + "}\n"); + s.put("com.example.b.Stats", "package com.example.b;\n" + + "import com.codename1.backend.annotations.*;\n" + + "@Component(\"otherStats\") @ManagedResource public class Stats {\n" + + " @ManagedAttribute public int getN() { return 2; }\n" + + " @McpTool(description = \"y\") public String find() { return \"b\"; }\n" + + "}\n"); + ProcessorContext ctx = process(compile(s)); + String errors = String.valueOf(ctx.getErrors()); + assertTrue(errors, errors.contains("Two @McpTool methods are named \"find\"")); + assertTrue(errors, errors.contains("Two @ManagedResource beans are named \"Stats\"")); + } + + @Test + public void jobsOfSameNamedClassesGetDistinctNames() throws Exception { + Map s = new LinkedHashMap(); + for (String pkg : new String[] {"a", "b"}) { + s.put("com.example." + pkg + ".Cleanup", "package com.example." + pkg + ";\n" + + "import com.codename1.backend.annotations.*;\n" + + "@Component" + ("b".equals(pkg) ? "(\"cleanupB\")" : "") + + " public class Cleanup {\n" + + " @Scheduled(fixedDelay = 1000000, initialDelay = 1000000)\n" + + " public void run() { }\n" + + "}\n"); + } + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + // The entry point goes in the first bean's package. + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet() + .application((Backend.Application) loader + .loadClass("com.example.a.BackendWiring").newInstance()) + .start(); + try { + String jobs = String.valueOf(backend.getApplication().getScheduler().describe()); + assertTrue(jobs, jobs.contains("com.example.a.Cleanup.run") + && jobs.contains("com.example.b.Cleanup.run")); + assertTrue(backend.getApplication().getScheduler() + .trigger("com.example.b.Cleanup.run")); + } finally { + backend.stop(); + } + } + + @Test + public void beansAreDestroyedAfterWhatDependsOnThem() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Log", PKG + "public class Log { public static String text = \"\"; }\n"); + // Aaa is found first and Zzz depends on it: Zzz must go first. + s.put("com.example.Aaa", PKG + "@Component @RequestScope public class Aaa {\n" + + " public void use() { }\n" + + " @PreDestroy public void done() { Log.text += \"aaa,\"; }\n" + + "}\n"); + s.put("com.example.Zzz", PKG + "@Component @RequestScope public class Zzz {\n" + + " @Autowired private Aaa aaa;\n" + + " public void use() { aaa.use(); }\n" + + " @PreDestroy public void done() { Log.text += \"zzz,\"; }\n" + + "}\n"); + s.put("com.example.Store", PKG + "@Component public class Store {\n" + + " @PreDestroy public void close() { Log.text += \"store,\"; }\n" + + "}\n"); + s.put("com.example.Report", PKG + "@Component @Lazy public class Report {\n" + + " @Autowired private Store store;\n" + + " public String make() { return \"r\"; }\n" + + " @PreDestroy public void flush() { Log.text += \"report,\"; }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired private Zzz zzz;\n" + + " @Autowired private Report report;\n" + + " @GetMapping(\"/x\") public String x() { zzz.use(); report.make(); " + + "return Log.text; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + http("GET", port, "/x"); + assertEquals("same-scope beans were destroyed before their dependents", + "zzz,aaa,", http("GET", port, "/x")); + } finally { + backend.stop(); + } + Class log = backend.getApplication().getClass().getClassLoader() + .loadClass("com.example.Log"); + String text = String.valueOf(log.getField("text").get(null)); + assertTrue("a lazy bean was destroyed after the eager bean it uses: " + text, + text.endsWith("report,store,")); + } + + @Test + public void managedGaugesLeaveWithTheirServer() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Stats", PKG + "@Component @ManagedResource(objectName = \"gaugestats\")\n" + + "public class Stats {\n" + + " @ManagedAttribute public int getCount() { return 7; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + assertNotNull(com.codename1.backend.metrics.Metrics.get("gaugestats.count")); + } finally { + backend.stop(); + } + assertTrue("a stopped server's managed gauge still reads its destroyed bean", + com.codename1.backend.metrics.Metrics.get("gaugestats.count") == null); + } + + @Test + public void asyncOnARequestBeanOrAScheduledMethodIsABuildError() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit {\n" + + " @Async public void later() { }\n" + + "}\n"); + s.put("com.example.Jobs", PKG + "@Component public class Jobs {\n" + + " @Scheduled(fixedRate = 1000) @Async public void tick() { }\n" + + "}\n"); + s.put("com.example.Cart", PKG + "@Component @SessionScope public class Cart {\n" + + " @Async public void recount() { }\n" + + "}\n"); + s.put("com.example.Stats", PKG + "@Component @ManagedResource(objectName = \"cache/main\")\n" + + "public class Stats {\n" + + " @ManagedAttribute public int getN() { return 1; }\n" + + "}\n"); + ProcessorContext ctx = process(compile(s)); + String errors = String.valueOf(ctx.getErrors()); + // Warnings, as Spring runs them: the task calls the instance itself. + assertTrue(String.valueOf(warnings), String.valueOf(warnings) + .contains("is on a @RequestScope bean")); + assertTrue(String.valueOf(warnings), String.valueOf(warnings) + .contains("is on a @SessionScope bean")); + assertFalse(errors, errors.contains("is on a @")); + assertTrue(errors, errors.contains("is a segment of the management URL")); + assertTrue(errors, errors.contains("is also @Async")); + } + + @Test + public void aFieldDependencyIsInitializedBeforeItsUser() throws Exception { + Map s = new LinkedHashMap(); + // Aaa is found first and uses Zzz, injected into a field, in its own + // @PostConstruct: Zzz's must have run by then. + s.put("com.example.Aaa", PKG + "@Component public class Aaa {\n" + + " @Autowired private Zzz zzz;\n" + + " public String seen = \"unset\";\n" + + " @PostConstruct public void init() { seen = zzz.state(); }\n" + + "}\n"); + s.put("com.example.Zzz", PKG + "@Component public class Zzz {\n" + + " private String state = \"cold\";\n" + + " @PostConstruct public void warm() { state = \"warm\"; }\n" + + " public String state() { return state; }\n" + + "}\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired private Aaa aaa;\n" + + " @GetMapping(\"/x\") public String x() { return aaa.seen; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + try { + assertEquals("a @PostConstruct ran before its field dependency's", "warm", + http("GET", port, "/x")); + } finally { + backend.stop(); + } + } + + @Test + public void aspectsOnInterfacesAreBuildErrors() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Ledger", PKG + "public interface Ledger {\n" + + " @Transactional default void post() { }\n" + + " @Timed void total();\n" + + "}\n"); + s.put("com.example.Audit", PKG + "@Async public interface Audit {\n" + + " void record();\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("@Transactional on com.example.Ledger.post is not " + + "applied")); + assertTrue(errors, errors.contains("neither this default method")); + assertTrue(errors, errors.contains("@Timed on com.example.Ledger.total is not applied")); + assertTrue(errors, errors.contains("@Async on interface com.example.Audit is not " + + "applied")); + } + + @Test + public void aScheduledFutureIsWarnedAbout() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Sync", PKG + "@Component public class Sync {\n" + + " @Scheduled(fixedRate = 60000)\n" + + " public Future run() { return AsyncResult.of(\"x\"); }\n" + + "}\n"); + assertNoErrors(process(compile(s))); + assertTrue(String.valueOf(warnings), String.valueOf(warnings) + .contains("@Scheduled method com.example.Sync.run returns a Future")); + } + + @Test + public void aPrototypeWithJobsIsWarnedAbout() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Ticker", PKG + "@Component @Scope(\"prototype\") public class Ticker {\n" + + " @Scheduled(fixedRate = 60000) public void tick() { }\n" + + "}\n"); + // Built, as Spring runs it; but said, since it behaves as a singleton. + assertNoErrors(process(compile(s))); + String warned = String.valueOf(warnings); + assertTrue(warned, warned.contains("ticker (com.example.Ticker) has @Scheduled methods " + + "but is prototype-scoped")); + } + + @Test + public void aClassLevelAsyncReachesOnlyItsOwnMethods() throws Exception { + // The subclass's @Async weaves only what the subclass declares, so the + // inherited scheduled method stays synchronous and is valid. + Map s = new LinkedHashMap(); + s.put("com.example.Base", PKG + "public class Base {\n" + + " @Scheduled(fixedRate = 1000) public void tick() { }\n" + + "}\n"); + s.put("com.example.Worker", PKG + "@Component @Async public class Worker extends Base {\n" + + " public void send() { }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertFalse(errors, errors.contains("is also @Async")); + + // The base class's own @Async does reach it, inherited or not. + s = new LinkedHashMap(); + s.put("com.example.Base", PKG + "@Async public class Base {\n" + + " @Scheduled(fixedRate = 1000) public void tick() { }\n" + + "}\n"); + s.put("com.example.Worker", PKG + "@Component public class Worker extends Base {\n" + + "}\n"); + errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("is also @Async")); + } + + @Test + public void anAsyncRouteReturningAFutureIsABuildError() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Api", PKG + "@RestController public class Api {\n" + + " @GetMapping(\"/x\") @Async\n" + + " public Future x() { return AsyncResult.of(\"x\"); }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("com.example.Api.x returns a " + + "java.util.concurrent.Future")); + assertTrue(errors, errors.contains("the client would get the pending task")); + } + + @Test + public void asyncToolsAndDuplicateParametersAreBuildErrors() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Tools", PKG + "@Component public class Tools {\n" + + " @McpTool(description = \"a\") @Async\n" + + " public Future slow() { return AsyncResult.of(\"x\"); }\n" + + " @McpTool(description = \"b\")\n" + + " public String pair(@McpParam(\"id\") String a, @McpParam(\"id\") String b) {\n" + + " return a + b;\n" + + " }\n" + + "}\n"); + ProcessorContext ctx = process(compile(s)); + String errors = String.valueOf(ctx.getErrors()); + assertTrue(errors, errors.contains("is also @Async")); + assertTrue(errors, errors.contains("named \"id\" like another")); + } + + @Test + public void anEscapedWebSocketPathIsABuildError() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Chat", PKG + "@WebSocketMapping(\"/ch%61t\")\n" + + "public class Chat implements WebSocket {\n" + + " public void onOpen(WebSocketSession s) { }\n" + + " public void onText(WebSocketSession s, String m) { }\n" + + " public void onBinary(WebSocketSession s, byte[] m, int o, int l) { }\n" + + "}\n"); + ProcessorContext ctx = process(compile(s)); + assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) + .contains("must not contain a percent escape")); + } + + @Test + public void anInheritedScheduledMethodRuns() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.BaseJob", PKG + "public abstract class BaseJob {\n" + + " public static int runs;\n" + + " @Scheduled(fixedRate = 20) public void tick() { runs++; }\n" + + "}\n"); + s.put("com.example.Cleanup", PKG + "@Component public class Cleanup extends BaseJob { }\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); + Class base = backend.getApplication().getClass().getClassLoader() + .loadClass("com.example.BaseJob"); + try { + long deadline = System.currentTimeMillis() + 5000; + while (base.getField("runs").getInt(null) < 2 + && System.currentTimeMillis() < deadline) { + Thread.sleep(20); + } + assertTrue("an inherited @Scheduled method never ran", + base.getField("runs").getInt(null) >= 2); + } finally { + backend.stop(); + } + } + + @Test + public void aFactoryBuiltBeanKeepsItsScheduledJobs() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Ticker", PKG + "public class Ticker {\n" + + " public static volatile int runs;\n" + + " @Scheduled(fixedRate = 20) public void tick() { runs++; }\n" + + "}\n"); + s.put("com.example.Setup", PKG + "@Configuration public class Setup {\n" + + " @Bean public Ticker ticker() { return new Ticker(); }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + Backend backend = start(classes, freePort(), new Properties()); + Class ticker = backend.getApplication().getClass().getClassLoader() + .loadClass("com.example.Ticker"); + try { + long deadline = System.currentTimeMillis() + 5000; + while (ticker.getField("runs").getInt(null) < 2 + && System.currentTimeMillis() < deadline) { + Thread.sleep(20); + } + assertTrue("a @Bean-built class's @Scheduled method never ran", + ticker.getField("runs").getInt(null) >= 2); + } finally { + backend.stop(); + } + } + + @Test + public void twoBeansOfOneClassScheduleTheirJobsSeparately() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Worker", PKG + "public class Worker {\n" + + " public static volatile int runs;\n" + + " @Scheduled(fixedRate = 20) public void tick() { runs++; }\n" + + "}\n"); + s.put("com.example.Setup", PKG + "@Configuration public class Setup {\n" + + " @Bean public Worker east() { return new Worker(); }\n" + + " @Bean public Worker west() { return new Worker(); }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + // Both started: the same job name twice refused the start. + Backend backend = start(classes, freePort(), new Properties()); + try { + String jobs = String.valueOf(backend.getApplication().getScheduler().describe()); + assertTrue(jobs, jobs.contains("east.tick") && jobs.contains("west.tick")); + } finally { + backend.stop(); + } + } + + @Test + public void scopedBeansReachedThroughASingletonAreRefusedOffRequest() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit { }\n"); + s.put("com.example.Helper", PKG + "@Component public class Helper {\n" + + " @Autowired private Visit visit;\n" + + "}\n"); + s.put("com.example.Chat", PKG + "@WebSocketMapping(\"/chat\")\n" + + "public class Chat implements WebSocket {\n" + + " @Autowired private Helper helper;\n" + + " public void onOpen(WebSocketSession s) { }\n" + + " public void onText(WebSocketSession s, String m) { }\n" + + " public void onBinary(WebSocketSession s, byte[] m, int o, int l) { }\n" + + "}\n"); + s.put("com.example.Nightly", PKG + "@Component public class Nightly {\n" + + " public Nightly(Helper helper) { }\n" + + " @Scheduled(fixedRate = 60000) public void run() { }\n" + + "}\n"); + // Warned, not refused: Spring starts this, and the stand-in throws only + // when it is really used with no request current. + assertNoErrors(process(compile(s))); + String warned = String.valueOf(warnings); + assertTrue(warned, warned.contains("Websocket endpoint chat (com.example.Chat) reaches " + + "visit (com.example.Visit) through helper (com.example.Helper)")); + assertTrue(warned, warned.contains("A websocket callback runs outside any HTTP request")); + assertTrue(warned, warned.contains("nightly (com.example.Nightly) reaches visit")); + assertTrue(warned, warned.contains("A scheduled job runs outside any HTTP request")); + // The singleton itself may inject it: it is used from requests. + s.remove("com.example.Chat"); + s.remove("com.example.Nightly"); + assertNoErrors(process(compile(s))); + assertFalse(String.valueOf(warnings), String.valueOf(warnings).contains("reaches")); + } + + @Test + public void anAsyncBeanReachingAScopedBeanIsWarned() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit { }\n"); + s.put("com.example.Helper", PKG + "@Component public class Helper {\n" + + " @Autowired private Visit visit;\n" + + "}\n"); + s.put("com.example.Mailer", PKG + "@Component public class Mailer {\n" + + " @Autowired private Helper helper;\n" + + " @Async public void send() { }\n" + + "}\n"); + // Warned, as Spring starts it and fails only on a use off the request. + assertNoErrors(process(compile(s))); + String warned = String.valueOf(warnings); + assertTrue(warned, warned.contains("Bean with @Async methods mailer (com.example.Mailer) " + + "reaches visit (com.example.Visit) through helper (com.example.Helper)")); + } + + @Test + public void oneExecutorNameWithTwoThreadKindsIsRefused() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Reports", PKG + "@Component public class Reports {\n" + + " @Async(value = \"reports\", thread = ThreadKind.PLATFORM) public void db() { }\n" + + " @Async(value = \"reports\", thread = ThreadKind.VIRTUAL) public void cpu() { }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("Executor \"reports\" is asked for")); + s.put("com.example.Reports", PKG + "@Component public class Reports {\n" + + " @Async(value = \"reports\", thread = ThreadKind.PLATFORM) public void db() { }\n" + + " @Async(value = \"reports\", thread = ThreadKind.AUTO) public void any() { }\n" + + "}\n"); + assertNoErrors(process(compile(s))); + } + + @Test + public void aScopedBeanInheritingAnAsyncMethodIsWarned() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Worker", PKG + "public abstract class Worker {\n" + + " @Async public void later() { }\n" + + "}\n"); + s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit " + + "extends Worker { }\n"); + assertNoErrors(process(compile(s))); + String warned = String.valueOf(warnings); + assertTrue(warned, warned.contains("@Async method com.example.Visit.later is on a " + + "@RequestScope bean")); + } + + @Test + public void aScopedConfigurationCannotBuildASingleton() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.PerRequest", PKG + "@Configuration @RequestScope public class PerRequest {\n" + + " @Bean public StringBuilder buffer() { return new StringBuilder(); }\n" + + "}\n"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("@Bean method com.example.PerRequest.buffer builds a " + + "singleton bean")); + s.put("com.example.PerRequest", PKG + "@Configuration @RequestScope public class PerRequest {\n" + + " @Bean public static StringBuilder buffer() { return new StringBuilder(); }\n" + + "}\n"); + assertNoErrors(process(compile(s))); + } + + @Test + public void aConditionalPrimaryFallsBackToTheActiveAlternative() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Greeter", PKG + "public interface Greeter { String greet(); }\n"); + s.put("com.example.ProdGreeter", PKG + "@Component @Primary @Profile(\"prod\") " + + "public class ProdGreeter implements Greeter {\n" + + " public String greet() { return \"prod\"; }\n" + + "}\n"); + s.put("com.example.DevGreeter", PKG + "@Component @Profile(\"dev\") " + + "public class DevGreeter implements Greeter {\n" + + " public String greet() { return \"dev\"; }\n" + + "}\n"); + s.put("com.example.Api", PKG + "@RestController public class Api {\n" + + " private final Greeter greeter;\n" + + " public Api(Greeter greeter) { this.greeter = greeter; }\n" + + " @GetMapping(\"/who\") public String who() { return greeter.greet(); }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Backend backend = start(classes, port, new Properties()); // the dev profile + try { + String answer = http("GET", port, "/who"); + assertTrue(answer, answer.contains("dev")); + } finally { + backend.stop(); + } + } + + @Test + public void aPrototypeControllerIsBuiltOnce() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Api", PKG + "@RestController @Scope(\"prototype\") public class Api {\n" + + " public static final AtomicInteger BUILT = new AtomicInteger();\n" + + " public Api() { BUILT.incrementAndGet(); }\n" + + " @GetMapping(\"/x\") public String x() { return \"x\"; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + Backend backend = start(classes, freePort(), new Properties()); + try { + Class api = backend.getApplication().getClass().getClassLoader() + .loadClass("com.example.Api"); + assertEquals("the router's controller was built twice, one discarded", 1, + ((java.util.concurrent.atomic.AtomicInteger) api.getField("BUILT").get(null)) + .get()); + } finally { + backend.stop(); + } + } + + @Test + public void aFactoryReturningNullStopsTheStart() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Setup", PKG + "@Configuration public class Setup {\n" + + " @Bean public StringBuilder buffer() { return null; }\n" + + "}\n"); + s.put("com.example.Api", PKG + "@RestController public class Api {\n" + + " public Api(StringBuilder buffer) { }\n" + + " @GetMapping(\"/x\") public String x() { return \"x\"; }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + try { + start(classes, freePort(), new Properties()).stop(); + fail("a @Bean method that returned null let the server start"); + } catch (Exception expected) { + String all = String.valueOf(expected); + for (Throwable t = expected.getCause(); t != null; t = t.getCause()) { + all += " / " + t; + } + assertTrue(all, all.contains("@Bean com.example.Setup.buffer returned null")); + } + } + + @Test + public void sessionBeansAndSocketsCannotHoldRequestState() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Cart", PKG + "@Component @SessionScope public class Cart {\n" + + " @Autowired private HttpSession session;\n" + + "}\n"); + s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit { }\n"); + s.put("com.example.Chat", PKG + "@WebSocketMapping(\"/chat\")\n" + + "public class Chat implements WebSocket {\n" + + " @Autowired private Visit visit;\n" + + " public void onOpen(WebSocketSession s) { }\n" + + " public void onText(WebSocketSession s, String m) { }\n" + + " public void onBinary(WebSocketSession s, byte[] m, int o, int l) { }\n" + + "}\n"); + ProcessorContext ctx = process(compile(s)); + String errors = String.valueOf(ctx.getErrors()); + assertTrue(errors, errors.contains("a session bean outlives the request")); + assertTrue(String.valueOf(warnings), String.valueOf(warnings) + .contains("A websocket callback runs outside any HTTP request")); + } + + @Test + public void everyScopeAndBindingWorksAtRunTime() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Handler", PKG + "public interface Handler { String name(); }\n"); + s.put("com.example.AHandler", PKG + "@Component public class AHandler implements Handler " + + "{ public String name() { return \"a\"; } }\n"); + s.put("com.example.BHandler", PKG + "@Component(\"special\") public class BHandler " + + "implements Handler { public String name() { return \"b\"; } }\n"); + s.put("com.example.Ticket", PKG + "@Component @Scope(\"prototype\") public class Ticket {\n" + + " private static int made;\n" + + " private final int number = ++made;\n" + + " public int number() { return number; }\n" + + "}\n"); + // The expensive part is @PostConstruct, which is the contract: the + // generated stand-in extends the class and so runs its constructor once + // at start-up, but only the real instance -- built on first use -- is + // initialized. + s.put("com.example.Expensive", PKG + "@Component @Lazy public class Expensive {\n" + + " public static int built;\n" + + " @PostConstruct void warm() { built++; }\n" + + " public String hello() { return \"lazy\"; }\n" + + "}\n"); + s.put("com.example.Cart", PKG + "@Component @SessionScope public class Cart {\n" + + " private int items;\n" + + " public int add() { return ++items; }\n" + + "}\n"); + s.put("com.example.Limits", PKG + "@Component @ConfigurationProperties(\"limits\")\n" + + "public class Limits {\n" + + " private int maxSize = 5;\n" + + " private String label = \"none\";\n" + + " public void setMaxSize(int v) { maxSize = v; }\n" + + " public void setLabel(String v) { label = v; }\n" + + " public String describe() { return label + \"/\" + maxSize; }\n" + + "}\n"); + s.put("com.example.Clients", PKG + "@Configuration public class Clients {\n" + + " @Bean public StringBuilder greeting(@Value(\"${greeting:hi}\") String g) {\n" + + " return new StringBuilder(g);\n" + + " }\n" + + "}\n"); + s.put("com.example.Beta", PKG + "@Component @ConditionalOnProperty(\"feature.beta\")\n" + + "public class Beta { }\n"); + s.put("com.example.Api", PKG + + "@RestController public class Api {\n" + + " @Autowired private List handlers;\n" + + " @Autowired @Qualifier(\"special\") private Handler special;\n" + + " @Autowired private Ticket first;\n" + + " @Autowired private Ticket second;\n" + + " @Autowired private Expensive expensive;\n" + + " @Autowired private Cart cart;\n" + + " @Autowired private Limits limits;\n" + + " @Autowired private StringBuilder greeting;\n" + + " @Autowired(required = false) private Beta beta;\n" + + " @GetMapping(\"/handlers\") public String handlers() {\n" + + " String out = \"\";\n" + + " for (Handler h : handlers) { out += h.name(); }\n" + + " return out + \"|\" + special.name();\n" + + " }\n" + + " @GetMapping(\"/tickets\") public String tickets() {\n" + + " return first.number() + \",\" + second.number();\n" + + " }\n" + + " @GetMapping(\"/lazy\") public String lazy() {\n" + + " int before = Expensive.built;\n" + + " return before + \":\" + expensive.hello() + \":\" + Expensive.built;\n" + + " }\n" + + " @GetMapping(\"/cart\") public String cart() { return String.valueOf(cart.add()); }\n" + + " @GetMapping(\"/config\") public String config() {\n" + + " return limits.describe() + \"|\" + greeting + \"|\" + (beta != null);\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty("limits.max-size", "9"); + settings.setProperty("limits.label", "gold"); + settings.setProperty("greeting", "hey"); + Backend backend = start(classes, port, settings); + try { + assertEquals("ab|b", http("GET", port, "/handlers")); + String[] tickets = http("GET", port, "/tickets").split(","); + assertNotEquals("a prototype was shared between two injection points", + tickets[0], tickets[1]); + assertEquals("a lazy bean was built before its first use", "0:lazy:1", + http("GET", port, "/lazy")); + assertEquals("gold/9|hey|false", http("GET", port, "/config")); + // One cart per session: the same cookie keeps adding to one cart. + HttpURLConnection first = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/cart").openConnection(); + assertEquals("1", read(first)); + String cookie = first.getHeaderField("Set-Cookie"); + assertTrue("a session-scoped bean did not start a session", cookie != null); + String pair = cookie.substring(0, cookie.indexOf(';')); + HttpURLConnection again = (HttpURLConnection) new URL("http://127.0.0.1:" + port + + "/cart").openConnection(); + again.setRequestProperty("Cookie", pair); + assertEquals("2", read(again)); + assertEquals("a new client shared another's session bean", "1", + http("GET", port, "/cart")); + } finally { + backend.stop(); + } + } + + @Test + public void theCronCompilerAgreesWithTheRuntimeParser() throws Exception { + String[] expressions = {"0 0 * * * *", "*/15 * * * * *", "0 30 9-17 * * MON-FRI", + "0 0 0 1 JAN,JUL ?", "5,10,15 1/5 0-23/2 L * *", "@daily", "@hourly", + "0 0 12 ? * SUN", "0 0 0 * * 7"}; + for (String e : expressions) { + CronCompiler built = CronCompiler.compile(e); + com.codename1.backend.CronSchedule runtime = + com.codename1.backend.CronSchedule.parse(e, "UTC"); + com.codename1.backend.CronSchedule fromMasks = new com.codename1.backend.CronSchedule( + built.seconds, built.minutes, built.hours, built.daysOfMonth, built.months, + built.daysOfWeek, built.lastDayOfMonth, "UTC", e); + long t = 1767225600000L; // 2026-01-01T00:00:00Z + for (int i = 0; i < 20; i++) { + long a = runtime.next(t); + long b = fromMasks.next(t); + assertEquals("the build and the runtime disagree about " + e, a, b); + t = a; + } + } + } + + // ------------------------------------------------------------------ helpers + + private Backend start(File classes, int port, Properties settings) throws Exception { + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + Backend.Application app = (Backend.Application) loader + .loadClass("com.example.BackendWiring").newInstance(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + return Backend.builder(Config.of(settings, "dev")).quiet().application(app).start(); + } + + private File compile(Map sources) throws Exception { + File classes = tmp.newFolder(); + JavaSourceCompiler.compile(sources, classes, backendClasspath()); + return classes; + } + + /// What the last process() logged as warnings. + private final List warnings = new ArrayList(); + + private ProcessorContext process(File classes) throws Exception { + return process(classes, new RestControllerAnnotationProcessor()); + } + + private ProcessorContext process(File classes, RestControllerAnnotationProcessor proc, + File... extraClasspath) throws Exception { + Map index = ClassScanner.scan(classes); + List cp = new ArrayList(); + for (File f : backendClasspath()) { + cp.add(f.getAbsolutePath()); + } + for (File f : extraClasspath) { + cp.add(f.getAbsolutePath()); + } + warnings.clear(); + ProcessorContext ctx = new ProcessorContext(classes, tmp.newFolder(), index, + new SystemStreamLog() { + @Override + public void warn(CharSequence content) { + warnings.add(String.valueOf(content)); + super.warn(content); + } + }, tmp.newFolder(), new Properties(), null, + Collections.emptyList(), "UTF-8", cp); + BackendBeanAnnotationProcessor beans = new BackendBeanAnnotationProcessor(); + beans.start(ctx); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) { + proc.processClass(cls, ctx); + } + } + beans.finish(ctx); + proc.finish(ctx); + return ctx; + } + + private static void assertNoErrors(ProcessorContext ctx) { + if (ctx.hasErrors()) { + fail("the processors reported errors: " + ctx.getErrors()); + } + } + + private static List backendClasspath() throws Exception { + URL url = HttpServer.class.getProtectionDomain().getCodeSource().getLocation(); + return Arrays.asList(new File(url.toURI())); + } + + private static int freePort() throws Exception { + ServerSocket s = new ServerSocket(0); + try { + return s.getLocalPort(); + } finally { + s.close(); + } + } + + private static String http(String method, int port, String path) throws Exception { + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + path) + .openConnection(); + c.setRequestMethod(method); + if ("POST".equals(method)) { + c.setDoOutput(true); + c.getOutputStream().close(); + } + return read(c); + } + + private static String post(int port, String path, String json) throws Exception { + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + path) + .openConnection(); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json"); + OutputStream out = c.getOutputStream(); + out.write(json.getBytes("UTF-8")); + out.close(); + return read(c); + } + + private static String read(HttpURLConnection c) throws Exception { + int status = c.getResponseCode(); + InputStream in = status >= 400 ? c.getErrorStream() : c.getInputStream(); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + if (in != null) { + byte[] chunk = new byte[4096]; + int n; + while ((n = in.read(chunk)) > 0) { + buffer.write(chunk, 0, n); + } + } + String body = new String(buffer.toByteArray(), "UTF-8"); + if (status >= 400) { + return "HTTP " + status + ": " + body; + } + return body; + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java index 20770b322a3..1705867f932 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java @@ -200,6 +200,26 @@ public void aHandlerMayThrow() throws Exception { } } + @Test + public void aHandlerCanTakeTheRequestItself() throws Exception { + // The escape hatch the "no binding annotation" refusal points at. The + // parameter's type is read off the descriptor, where the member class is + // HttpServer$Request, and the check compared it with the dotted spelling + // only -- so a handler taking the Request was refused by the very message + // telling it to take one. + Router router = generate("package com.example;\n" + + "import com.codename1.backend.HttpServer;\n" + + "import com.codename1.backend.annotations.*;\n" + + "@RestController\n" + + "public class Notes {\n" + + " @GetMapping(\"/raw\")\n" + + " public String raw(HttpServer.Request request) {\n" + + " return request.getMethod() + \" \" + request.getTarget();\n" + + " }\n" + + "}\n"); + assertEquals("GET /raw?x=1", router.text("GET", "/raw?x=1")); + } + @Test public void theEntryPointDelegatesTheWholeLifecycleToTheBuilder() throws Exception { // The twenty lines every server used to open with -- read PORT, start, @@ -1034,11 +1054,11 @@ public void aRelativeClassPrefixStillRoutes() throws Exception { } @Test - public void aBodyOfDtosIsRefused() throws Exception { - // The descriptor erases this to java.util.List, which binds. What the - // parser actually supplies is a list of Map, so the first use of an - // element as a Note throws and the endpoint answers 500 -- having - // packaged perfectly. + public void aBodyOfDtosIsDecodedByACodec() throws Exception { + // The descriptor erases this to java.util.List, which binds as parsed -- + // a list of Map, whose first use as a Note would throw. The generic type + // is what is checked, and a class of the application's own gets a + // codec, as Jackson decodes it for a Spring controller. ProcessorContext ctx = run(compile( "package com.example;\n" + "import com.codename1.backend.annotations.*;\n" @@ -1049,9 +1069,7 @@ public void aBodyOfDtosIsRefused() throws Exception { + " @PostMapping(\"/notes\")\n" + " public String add(@RequestBody List body) { return \"ok\"; }\n" + "}\n")); - assertTrue("a body of DTOs should not compile", ctx.hasErrors()); - assertTrue(ctx.getErrors().toString(), - ctx.getErrors().toString().indexOf("Cannot bind") >= 0); + assertFalse(ctx.getErrors().toString(), ctx.hasErrors()); } @Test @@ -1089,7 +1107,7 @@ public void aResponseInsideACollectionIsRefused() throws Exception { + "}\n")); assertTrue("a collection of Response should not compile", ctx.hasErrors()); assertTrue(ctx.getErrors().toString(), - ctx.getErrors().toString().indexOf("cannot encode") >= 0); + ctx.getErrors().toString().indexOf("cannot write as JSON") >= 0); } @Test @@ -1119,21 +1137,21 @@ public void aDirectResponseReturnIsSentAsItStands() throws Exception { @Test public void aJdkReturnJsonCannotWriteIsRefused() throws Exception { - // java.util.Date has no branch in Json.writeValue, so it reaches the - // final one and is answered as a quoted, implementation-formatted - // toString() -- a date the client cannot parse back, from a build and a - // request that both reported success. + // StringBuilder has no branch in Json.writeValue and no codec, so it + // would reach the final branch and be answered as a quoted toString() + // from a build and a request that both reported success. (A Date has a + // codec now: milliseconds, as the app's mapper reads it.) ProcessorContext ctx = run(compile( "package com.example;\n" + "import com.codename1.backend.annotations.*;\n" + "@RestController\n" + "public class Notes {\n" + " @GetMapping(\"/when\")\n" - + " public java.util.Date when() { return null; }\n" + + " public StringBuilder when() { return null; }\n" + "}\n")); assertTrue("a JDK type Json cannot write should not compile", ctx.hasErrors()); assertTrue(ctx.getErrors().toString(), - ctx.getErrors().toString().indexOf("cannot encode") >= 0); + ctx.getErrors().toString().indexOf("cannot write as JSON") >= 0); } @Test @@ -1189,7 +1207,7 @@ public void anArrayReturnOtherThanBytesIsRefused() throws Exception { + "}\n")); assertTrue("an array the router cannot encode should not compile", ctx.hasErrors()); String all = ctx.getErrors().toString(); - assertTrue(all, all.indexOf("cannot encode") >= 0); + assertTrue(all, all.indexOf("cannot write as JSON") >= 0); } @Test @@ -1232,11 +1250,11 @@ public void aLiteralAndAVariableInOneControllerBothWork() throws Exception { } @Test - public void aListOfDtosIsRefused() throws Exception { + public void aListOfDtosIsWrittenByACodec() throws Exception { // The DESCRIPTOR erases this to java.util.List, which the encodable - // check waves through on its own name. Every Note in the list would - // then be written as the quoted result of its toString(), while the - // build and the request both reported success. + // check waves through on its own name, and Json would write each Note + // as its toString(). The generic type is what is checked, and each Note + // is written by the codec the build generates for it. ProcessorContext ctx = run(compile( "package com.example;\n" + "import com.codename1.backend.annotations.*;\n" @@ -1247,10 +1265,7 @@ public void aListOfDtosIsRefused() throws Exception { + " @GetMapping(\"/notes\")\n" + " public List all() { return null; }\n" + "}\n")); - assertTrue("a list of types the router cannot encode should not compile", - ctx.hasErrors()); - String all = ctx.getErrors().toString(); - assertTrue(all, all.indexOf("cannot encode") >= 0); + assertFalse(ctx.getErrors().toString(), ctx.hasErrors()); } @Test @@ -1474,9 +1489,9 @@ public void anInheritedWritableIsRecognised() throws Exception { } @Test - public void aTypeThatIsNotWritableAtAllIsStillRefused() throws Exception { - // The traversal must not turn the check off: a class with no Writable - // anywhere in its hierarchy is still the malformed contract this refuses. + public void aPlainClassIsWrittenByItsCodec() throws Exception { + // No Writable anywhere in its hierarchy, and none needed: its public + // field is written by the codec the build generates, as Jackson would. Map sources = new LinkedHashMap(); sources.put("com.example.Plain", "package com.example;\n" @@ -1494,7 +1509,7 @@ public void aTypeThatIsNotWritableAtAllIsStillRefused() throws Exception { File classes = tmp.newFolder(); JavaSourceCompiler.compile(sources, classes, backendClasspath()); ProcessorContext ctx = run(classes); - assertTrue("a type Json cannot write must still be refused", ctx.hasErrors()); + assertFalse(ctx.getErrors().toString(), ctx.hasErrors()); } @Test diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/WebSocketMappingProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/WebSocketMappingProcessorTest.java index 97dbd5e947d..7796eeebd18 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/WebSocketMappingProcessorTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/WebSocketMappingProcessorTest.java @@ -76,8 +76,13 @@ public class WebSocketMappingProcessorTest { public void registersTheEndpointOnTheBuilder() throws Exception { RestControllerAnnotationProcessor processor = run(compile("Chat", ENDPOINT)); String bootstrap = processor.generateBootstrap("com.example"); - assertTrue("the endpoint is not registered through the callback:\n" + bootstrap, - bootstrap.indexOf("registry.route(\"/chat\", new com.example.Chat())") >= 0); + // The endpoint is a bean like any other, so the wiring registers the + // instance it built rather than a `new` of its own. + String wiring = processor.generateWiring("com.example"); + assertTrue("the endpoint is not registered through the callback:\n" + wiring, + wiring.indexOf("registry.route(\"/chat\", b_chat)") >= 0); + assertTrue("the endpoint is not built by the wiring:\n" + wiring, + wiring.indexOf("b_chat = new com.example.Chat()") >= 0); assertTrue("the entry point no longer goes through the builder:\n" + bootstrap, bootstrap.indexOf("com.codename1.backend.Backend.builder()") >= 0); assertTrue("the entry point does not run the server:\n" + bootstrap, @@ -112,7 +117,7 @@ public void thePathIsRelativeToAClassLevelRequestMapping() throws Exception { + " public void onText(WebSocketSession s, String m) { }\n" + " public void onBinary(WebSocketSession s, byte[] m, int o, int l) { }\n" + "}\n"; - String bootstrap = run(compile("Scoped", source)).generateBootstrap("com.example"); + String bootstrap = run(compile("Scoped", source)).generateWiring("com.example"); assertTrue("the base path was not applied:\n" + bootstrap, bootstrap.indexOf("registry.route(\"/api/chat\"") >= 0); } @@ -155,7 +160,7 @@ public void aBarePathIsNormalisedRatherThanRefused() throws Exception { // to learn where the two differ. String source = ENDPOINT.replace("@WebSocketMapping(\"/chat\")", "@WebSocketMapping(\"chat\")"); - String bootstrap = run(compile("Chat", source)).generateBootstrap("com.example"); + String bootstrap = run(compile("Chat", source)).generateWiring("com.example"); assertTrue("a bare path should be normalised:\n" + bootstrap, bootstrap.indexOf("registry.route(\"/chat\"") >= 0); } @@ -207,7 +212,7 @@ public void theGeneratedEntryPointIsStableAcrossBuilds() throws Exception { sources.put("com.example.Chat", ENDPOINT); sources.put("com.example.Alerts", second); JavaSourceCompiler.compile(sources, classes, backendClasspath()); - String bootstrap = run(classes).generateBootstrap("com.example"); + String bootstrap = run(classes).generateWiring("com.example"); int alerts = bootstrap.indexOf("registry.route(\"/alerts\""); int chat = bootstrap.indexOf("registry.route(\"/chat\""); assertTrue("both endpoints should be registered:\n" + bootstrap, alerts >= 0 && chat >= 0); diff --git a/maven/core-unittests/src/test/java/com/codename1/telemetry/TelemetryTest.java b/maven/core-unittests/src/test/java/com/codename1/telemetry/TelemetryTest.java index 65fa959eb02..12dd38c07ff 100644 --- a/maven/core-unittests/src/test/java/com/codename1/telemetry/TelemetryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/telemetry/TelemetryTest.java @@ -68,7 +68,18 @@ class TelemetryTest extends UITestBase { private static final long WAIT_MILLIS = 30000; @BeforeEach - void mocks() { + void mocks() throws Exception { + // The previous test's last export first. Telemetry.uninstall() queues it + // and returns, so it could reach the collector mock AFTER the connections + // below were cleared -- and a test asking for "at least one span" then + // got the previous test's instead of its own, which failed CI on every + // PR it happened to land in. + long idleBy = System.currentTimeMillis() + WAIT_MILLIS; + while (!NetworkManager.getInstance().isQueueIdle() + && System.currentTimeMillis() < idleBy) { + flushSerialCalls(); + Thread.sleep(10); + } TestCodenameOneImplementation impl = TestCodenameOneImplementation.getInstance(); impl.clearNetworkMocks(); impl.clearConnections(); @@ -306,15 +317,20 @@ void anAppsOwnTraceparentIsNeverReplacedAndOursDoesNotOutliveTheAttempt() throws // The app's request joins the app's trace downstream, so no span of ours // may describe it in another one; ours is recorded as usual. Telemetry.flush(); + // Waits for THIS test's span, not for any span: a count is satisfied by + // whatever else reached the collector first. List spans = exported(1); - boolean sawOurs = false; + long deadline = System.currentTimeMillis() + WAIT_MILLIS; + while (!hasUrl(spans, API + "/ours") && System.currentTimeMillis() < deadline) { + Thread.sleep(20); + spans = exported(1); + } for (Span span : spans) { - String url = attribute(span.getAttributesList(), "url.full"); - assertFalse((API + "/own").equals(url), + assertFalse((API + "/own").equals(attribute(span.getAttributesList(), "url.full")), "a span was recorded for a request that carries the app's own trace"); - sawOurs |= (API + "/ours").equals(url); } - assertTrue(sawOurs, "the ordinary request's span is missing: " + spans); + assertTrue(hasUrl(spans, API + "/ours"), "the ordinary request's span is missing: " + + spans); } @Test @@ -1263,6 +1279,15 @@ private List exported(int wanted) throws Exception { throw new AssertionError("expected " + wanted + " spans, got " + spans); } + private static boolean hasUrl(List spans, String url) { + for (Span span : spans) { + if (url.equals(attribute(span.getAttributesList(), "url.full"))) { + return true; + } + } + return false; + } + private static Span find(List spans, String name) { for (Span span : spans) { if (name.equals(span.getName())) { diff --git a/maven/integration-tests/cn1app-archetype-test.sh b/maven/integration-tests/cn1app-archetype-test.sh index eda33f03898..f269d41b126 100644 --- a/maven/integration-tests/cn1app-archetype-test.sh +++ b/maven/integration-tests/cn1app-archetype-test.sh @@ -65,6 +65,13 @@ if [ ! -f "backend/target/classes/$ROUTER_DIR/ApiRouter.class" ]; then echo "no router was generated for the @RestController" >&2 exit 1 fi +# The beans are wired by a class the build generates beside the entry point; the +# sample's controller takes its Greeter service through the constructor, so a +# missing BackendWiring means dependency injection did not happen at all. +if [ ! -f "backend/target/classes/$ROUTER_DIR/BackendWiring.class" ]; then + echo "no BackendWiring was generated for the backend's beans" >&2 + exit 1 +fi # `cn1:backend-package` -- the goal that turns the module above into a native # binary -- ON A JDK THAT IS NOT 8, with JDK_8_HOME deliberately unset. @@ -129,8 +136,15 @@ else fi sleep 1 done + # The route that goes through an injected @Service, while the binary still runs. + GREETING="$(curl -s --max-time 5 "http://127.0.0.1:$BACKEND_PORT/greet/archetype" || true)" kill -9 $BACKEND_PID 2>/dev/null || true trap 'set_auto_bundle_pref false' EXIT + if [ "$HEALTH" = "ok" ] && [ "$GREETING" != "Hello, archetype" ]; then + echo "the packaged backend did not answer /greet through its injected service (got '$GREETING'):" >&2 + cat backend/target/backend-run.log >&2 + exit 1 + fi if [ "$HEALTH" != "ok" ]; then echo "the packaged backend did not answer /healthz with ok (got '$HEALTH'):" >&2 cat backend/target/backend-run.log >&2 diff --git a/scripts/check-backend-jdk-surface.py b/scripts/check-backend-jdk-surface.py new file mode 100755 index 00000000000..cb4acc89b25 --- /dev/null +++ b/scripts/check-backend-jdk-surface.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Every java.* type the backend's public API exposes must be one the backend API +reference documents. + + scripts/check-backend-jdk-surface.py + +The backend compiles against vm/JavaAPI, which is larger than the CLDC java.* set the +client reference documents, so a backend signature can use a JDK type -- Future, +TimeUnit -- that no reference page describes and nothing promised to keep. That is +how @Async's Future shipped: usable, compiled, and undocumented. A type the backend +exposes has to be either a CLDC class or one of PROMOTED, which build_javadocs.sh +adds to the backend reference from vm/JavaAPI. +""" +import os +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +# The vm/JavaAPI classes the backend reference documents beyond the CLDC set. Keep +# in step with the PROMOTED list in .github/scripts/build_javadocs.sh. +PROMOTED = [ + 'java.util.Properties', + 'java.util.concurrent.CancellationException', + 'java.util.concurrent.ExecutionException', + 'java.util.concurrent.Future', + 'java.util.concurrent.TimeUnit', + 'java.util.concurrent.TimeoutException', +] + + +def exists(root, fq): + return (REPO / root / (fq.replace('.', '/') + '.java')).is_file() + + +def main(): + missing = {} + for path in sorted((REPO / 'vm/backend/src').rglob('*.java')): + text = path.read_text(encoding='utf-8') + imports = {m.group(1).split('.')[-1]: m.group(1) + for m in re.finditer(r'^import (java\.[\w.]+);', text, re.M)} + for sig in re.finditer(r'^\s*(?:public|protected)\s[^;{=]*[({]', text, re.M): + s = sig.group(0) + found = set(re.findall(r'java\.[a-z]+(?:\.[a-z]+)*\.[A-Z]\w*', s)) + found |= {fq for simple, fq in imports.items() if re.search(r'\b' + simple + r'\b', s)} + for fq in found: + if not exists('Ports/CLDC11/src', fq) and fq not in PROMOTED: + missing.setdefault(fq, set()).add(str(path.relative_to(REPO))) + # And the guide's backend examples: they compile against the JDK in their own + # module, so a class the translated backend does not have -- the first example + # used ConcurrentHashMap -- builds there and fails for everyone who copies it. + for path in sorted((REPO / 'docs/demos/backend/src').rglob('*.java')): + text = path.read_text(encoding='utf-8') + used = set(re.findall(r'^import (java\.[\w.]+);', text, re.M)) + used |= set(re.findall(r'\b(java\.[a-z]+(?:\.[a-z]+)*\.[A-Z]\w*)', text)) + for fq in used: + if fq.endswith('.*'): + continue + if not exists('Ports/CLDC11/src', fq) and fq not in PROMOTED: + missing.setdefault(fq, set()).add(str(path.relative_to(REPO))) + for fq in PROMOTED: + if not exists('vm/JavaAPI/src', fq): + print('check-backend-jdk-surface: %s is promoted but vm/JavaAPI has no source for it' + % fq) + return 1 + if missing: + print('check-backend-jdk-surface: the backend API exposes JDK types its reference ' + 'does not document:') + for fq in sorted(missing): + print(' %s (%s)' % (fq, ', '.join(sorted(missing[fq])))) + print('Document it by adding it to PROMOTED here and in build_javadocs.sh -- with ' + 'test coverage on ParparVM -- or keep it out of the public API.') + return 1 + print('check-backend-jdk-surface: every JDK type the backend exposes is documented') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md b/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md index b57a6eb581c..7d5b732c3e4 100644 --- a/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md +++ b/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md @@ -24,6 +24,12 @@ their own conventions; the canonical source of truth is `.agent-skills/`. attach a Java debugger to the device build and drive it over MCP the same way; see `.agent-skills/codename-one/references/on-device-debugging.md`. - Native cloud builds use `mvn -pl package -Dcodename1.platform=... -Dcodename1.buildTarget=...`. +- The server side lives in `backend/` (Spring-style `@RestController` / `@Service`, + resolved at build time). Run it with + `CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend`; it then + serves MCP tools at `http://127.0.0.1:8080/mcp` for inspecting and exercising it. + See `.agent-skills/codename-one/references/backend.md`, and + `references/full-stack-loop.md` for changes that span the app and the server. When in doubt, open `.agent-skills/codename-one/SKILL.md` and follow the reference table at the bottom. diff --git a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md index 294692ef1a0..0e383520c0b 100644 --- a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md +++ b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md @@ -1,6 +1,6 @@ --- name: codename-one -description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, or when the user asks to build a UI, write screen tests, generate screenshots, or compare to Swing/HTML/Android. +description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` module (`cn1:backend`, `com.codename1.backend.*`, `@RestController`, `@Service`, `@Transactional`), or when the user asks to build a UI, write screen tests, generate screenshots, build a server or full-stack feature, or compare to Swing/HTML/Android. metadata: type: skill --- @@ -15,4 +15,5 @@ The actual skill content is **vendor-neutral** and lives in this repository at: - `.agent-skills/codename-one/tools/` — runnable Java 17 utilities (`isApiSupported`, `isCssValid`, ...) **Read `.agent-skills/codename-one/SKILL.md` next.** All the guidance you need to -build, style, test, debug, and port to Codename One is in that directory. +build, style, test, debug, and port to Codename One -- and to write and +exercise the `backend/` server -- is in that directory. diff --git a/scripts/initializr/common/src/main/resources/common.zip b/scripts/initializr/common/src/main/resources/common.zip index 0e49e00c3ec..eec64cf9c49 100644 Binary files a/scripts/initializr/common/src/main/resources/common.zip and b/scripts/initializr/common/src/main/resources/common.zip differ diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 250541d892c..9bcc8b358e4 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: codename-one -description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, or when the user asks to build a UI, write screen tests, generate screenshots, or compare to Swing/HTML. +description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` module (`cn1:backend`, `com.codename1.backend.*`, `@RestController`, `@Service`, `@Transactional`, `@Scheduled`), or when the user asks to build a UI, write screen tests, generate screenshots, build a server or full-stack feature, or compare to Swing/HTML. metadata: type: skill --- @@ -43,6 +43,8 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform - `references/3d-graphics.md` — Portable GPU 3D (`com.codename1.gpu`): the `RenderView` + `Renderer` loop, declarative `Material` / `VertexFormat` (engine-generated shaders — no GLSL), `Primitives`, `GltfLoader` for glTF models, `Camera` / `Light` / `Matrix4`, and platform backends. Read this for product viewers, 3D scenes, or custom GPU rendering. - `references/snapshot-builds.md` — Edge case: compiling against a Codename One SNAPSHOT from git. - `references/debugging.md` — `jdb`-attach workflow for an agent: start the simulator paused, set breakpoints, dump locals, drive the session non-interactively from a script. +- `references/backend.md` — The **server side** (`backend/` module): Spring-style `@RestController`, `@Service`, `@Autowired`, `@Value`, scopes, `@Transactional`, `@Async`/`@Scheduled` and virtual vs platform threads, sessions, metrics and management endpoints, `@McpTool`, the development MCP tools (`backend_routes`, `backend_call`, `backend_sql`, ...), testing, and what each build error means. Everything is resolved at **build time** — read this before writing backend code, because a few Spring habits (runtime scanning, reflection, `org.springframework` imports) do not apply. +- `references/full-stack-loop.md` — A change that spans the app **and** the backend: run the backend, connect its MCP and the simulator's, point the app at it, then drive the UI while checking the requests and rows the server saw. - `references/mcp-agent-control.md` — Driving the **running** simulator yourself over MCP: turn the server on from the simulator's `MCP` menu, register it with Claude Desktop / Claude Code / Codex in one click, then read the screen with `ui_snapshot`, find a field with `ui_find`, type with `ui_set_text` and tap with `ui_activate`. Read this when you need to know whether a flow *behaves* correctly, not just how it looks. - `tools/` — runnable Java 17 single-file utilities. `tools/IsApiSupported.java` answers "is this `java.*` class in the CN1 subset?"; `tools/IsCssValid.java` answers "does this `theme.css` compile?"; `tools/CompareToMockup.java` scores a rendered screenshot against a designer mockup (similarity %, with region masking); `tools/DesignImport.java` turns a Figma/Sketch/Adobe XD design — or an HTML/React design's `tokens.css`/`styles.css` (Claude-generated mockups) — into starter CN1 CSS + tokens + a layout map. **`tools/DumpForm.java`** boots the app in **desktop mode** and dumps a model of the current screen, which **`tools/DescribeForm.java`** (vision-free outline), **`tools/AlignmentCheck.java`** (designer alignment guides) and **`tools/GuiLint.java`** (nested scroll, opaque text/containers, image borders) analyse. **`tools/UpdateSkills.java`** self-updates this whole skill from GitHub. Run with `java tools/.java `. @@ -66,10 +68,13 @@ my-app/ ├── javase/ # Desktop simulator port ├── android/ # Android wrapper (built via build server or local Gradle) ├── ios/ # iOS wrapper (ParparVM) -└── javascript/ # TeaVM-based web port +├── javascript/ # TeaVM-based web port +└── backend/ # Optional server side (see references/backend.md) + ├── application.properties # Server settings: in the MODULE ROOT, not src/main/resources + └── src/main/java// # @RestController / @Service classes (Java 8 level, no UI classes) ``` -**Only edit `common/`**. The platform modules are thin wrappers — touching them is almost always wrong unless you are intentionally writing a native interface. +**Only edit `common/`** for the app, and `backend/` for the server. The platform modules are thin wrappers — touching them is almost always wrong unless you are intentionally writing a native interface. ## Java version and language features @@ -265,6 +270,11 @@ mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios # Use -Dcodename1.buildTarget=javascript instead for the cloud builder; set # javascript.port=teavm only when the legacy compatibility fallback is needed. mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript + +# The backend: run it on this JVM (dev profile = in-memory database + MCP dev tools at /mcp), +# or package it as a native server binary. -Dcodename1.platform=backend is required. +CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend +mvn -pl backend -Dcodename1.platform=backend cn1:backend-package ``` See `references/build-and-run.md` for the local-vs-cloud matrix, automated-build mode (Enterprise), iOS local-build prerequisites, and the complete goal list. The full `codename1.arg.*` index lives in `references/build-hints.md`. @@ -320,6 +330,9 @@ If you cannot run the simulator (e.g. headless environment), **say so explicitly | "Debug a faulty screen — attach `jdb` to the simulator" | `references/debugging.md` | | "It only breaks on the phone" / "attach a debugger to the Android/iOS build" / `android.onDeviceDebug`, `ios.onDeviceDebug` / drive the app on a device over MCP | `references/on-device-debugging.md` | | "Try the flow" / "fill in this form and press submit" / "drive the running app" / MCP | `references/mcp-agent-control.md` | +| "Add an endpoint / a service / a scheduled job" / `@RestController`, `@Service`, `@Transactional`, `@Scheduled`, `@Async` / "the server side" | `references/backend.md` | +| "Inspect or exercise the running backend" / `backend_*` MCP tools / "why did this request fail" | `references/backend.md` | +| "Build this feature end to end" / "the screen should save to the server" / a bug that could be client or server | `references/full-stack-loop.md` | | Quick yes/no check: "is this `java.*` class supported", "does my `theme.css` compile" | `tools/` directory — `java tools/IsApiSupported.java ` / `java tools/IsCssValid.java ` | | "Score this screen against a mockup" / "Import a Figma/Sketch/XD design" | `tools/` directory — `java tools/CompareToMockup.java ` / `java tools/DesignImport.java ` (see `references/mockup-comparison.md`) | | "Describe this screen" / "are these elements aligned" / "lint this UI for bugs" | `tools/` — `java -cp tools/DumpForm.java ` then `tools/DescribeForm.java` / `tools/AlignmentCheck.java` / `tools/GuiLint.java` on the model (see `references/mockup-comparison.md`) | diff --git a/scripts/initializr/common/src/main/resources/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md new file mode 100644 index 00000000000..0af0fd2a365 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -0,0 +1,312 @@ +# The Backend Module — Spring-Style Server Code + +The `backend/` module is the server side of the app, written in Java with Spring's +annotations under Codename One package names. `@RestController`, `@Service`, +`@Autowired`, `@Transactional`, `@Scheduled` and the rest mean what they mean in +Spring. The difference is **when** they are resolved: at build time, into plain +code. There is no container, no classpath scan, no proxy and no reflection when the +server runs, and a dependency that cannot be satisfied fails the **build** with a +message naming the injection point. + +The same source runs two ways: + +```bash +# Development: this JVM, starts in seconds, dev profile = in-memory SQLite + dev tools +CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend + +# Deployment: one native binary, no JVM underneath +mvn -pl backend -Dcodename1.platform=backend cn1:backend-package +``` + +`-Dcodename1.platform=backend` is required: the module sits in a Maven profile. + +## Rules that differ from a normal Java server + +- **Java 8 language level, backend class library only.** The native build compiles + `backend/` against the server runtime's Java API subset, not the JDK. No + `java.time`, no reflection, no `Class.forName`. `backend/` does **not** depend on + `codenameone-core` (no UI classes on a server). +- **Everything injectable is known at build time.** Annotate the class, or declare a + `@Bean` method. There is no scanning of jars. +- **Configuration files live in the module ROOT**: `backend/application.properties` + and `backend/application-.properties`, **not** `src/main/resources`. + Environment variables override them (`cn1.datasource.url` is also read from + `CN1_DATASOURCE_URL` and `DATABASE_URL`, `cn1.server.port` from `PORT`). +- **Common settings can be annotations too** (a single binary often ships with + no properties file): `@ServerConfig(port = 8080)`, `@SessionConfig(store = "db")`, + `@DataSourceConfig(url = "${DATABASE_URL}")`, `@StaticFilesConfig(root = "www")` + on any class. Each attribute is one `cn1.*` key compiled in UNDER the properties + files and environment, which still override it. A bad value or two classes + disagreeing is a build error. Never put a token or password in one. +- **Imports come from `com.codename1.backend.annotations`**, never + `org.springframework.*`. + +## Beans and injection + +```java +package com.example.myapp; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.*; + +@Repository +public class NoteStore { + private final DataSource db; // built-in: the connection pool + public NoteStore(DataSource db) { this.db = db; } + // ... +} + +@Service +public class Notes { + private final NoteStore store; + @Autowired private Mailer mailer; // private fields are fine + @Value("${notes.max:100}") private int max; // config, with a fallback + + public Notes(NoteStore store) { this.store = store; } // one constructor: no @Autowired needed + + @PostConstruct void ready() { /* runs once, after injection */ } + @PreDestroy void shutdown() { /* runs when the server stops */ } +} + +@RestController +@RequestMapping("/api") +public class NotesApi { + private final Notes notes; + public NotesApi(Notes notes) { this.notes = notes; } + + @GetMapping("/notes/{id}") + public Note get(@PathVariable("id") long id) { ... } +} +``` + +| Annotation | Meaning | +| --- | --- | +| `@Component`, `@Service`, `@Repository` | A bean. Name defaults to the simple class name, decapitalized. | +| `@Configuration` + `@Bean` | Factory methods; parameters are injected. Calling one `@Bean` method from another does NOT share the instance — take the other bean as a parameter. | +| `@Autowired` | Constructor (needed only when there are several), field, or setter. `required = false` allowed. | +| `@Qualifier("name")`, `@Primary` | Choose between several beans of one type. Two candidates and neither is enough → build error. | +| `@Value("${key:fallback}")` | A configuration value, converted to String, a primitive or box, or an enum. | +| `@ConfigurationProperties("mail")` | Calls the bean's setters from `mail.*` keys (`setMaxSize` reads `mail.maxSize` or `mail.max-size`). | +| `@Scope("prototype")` | A new instance at each injection point. | +| `@RequestScope`, `@SessionScope` | One per HTTP request / session. Injected into a singleton through a generated subclass, so the class must not be final and needs a no-argument constructor. | +| `@Lazy` | Built on first use (same subclass rule). The stand-in runs the no-argument constructor once at start-up, so put expensive set-up in `@PostConstruct`, which runs only on the real instance. | +| `@Profile("dev")`, `@Profile("!prod")` | Only on that profile (`CN1_PROFILE`). | +| `@ConditionalOnProperty("feature.x")`, `@ConditionalOnMissingBean` | Conditional beans. | + +Built-in injectables: `Config`, `DataSource`, `orm.EntityManager`, +`com.codename1.orm.session.Session` (the current transaction's session), and — in a +request bean only — `HttpServer.Request` and `HttpSession` (a session bean reads the +current session with `Backend.currentRequest().getSession(true)` instead). + +**Parameter names do not survive compilation.** `@PathVariable`, `@RequestParam`, +`@RequestHeader` and `@McpParam` always need their name spelled out. + +## JSON: return and accept your own classes + +A controller may return an entity or DTO (or `List`, `Map`) +and take one as `@RequestBody`. The build writes a codec per class -- no +reflection -- in the same JSON form as the app's `@Mapped` mapper, so a class +shared by app and backend round-trips: + +- Fields: non-static, non-`transient`; public directly, otherwise via + `getX`/`isX` + `setX`. `@JsonProperty("name")` / `@JsonIgnore` from + `com.codename1.annotations`. +- `Date` = epoch millis (reads millis or ISO-8601), `byte[]` = base64, enum = name. +- Unknown body members ignored, absent ones keep the default. A bad value is a 400 + naming its path (`$.lines[0].quantity: expected a whole number ...`). +- Objects that point back at each other: `@JsonIgnore` the back reference (a + response nesting over 64 deep is a 500). +- Build errors: generic `Page` fields (use a concrete subclass), a body class + without a no-arg constructor, interfaces, arrays other than `byte[]`. + +## Transactions + +```java +@Service +public class Transfers { + private final DataSource db; + public Transfers(DataSource db) { this.db = db; } + + @Transactional + public void move(long from, long to, long cents) throws IOException { + db.execute("UPDATE account SET cents = cents - ? WHERE id = ?", new Object[] {cents, from}); + db.execute("UPDATE account SET cents = cents + ? WHERE id = ?", new Object[] {cents, to}); + } +} +``` + +- Everything done through the `DataSource` on the calling thread — directly, via an + entity manager's DAOs, or via the injected `Session` — joins the transaction. +- Unchecked exceptions and `Error`s roll back; checked exceptions commit -- except + `DataAccessException`, what every failed database operation throws (a subclass + of `IOException`), which rolls back as Spring's unchecked one does. Another + `IOException` (file, mail, HTTP) commits unless listed in `rollbackFor`. + `rollbackFor` / `noRollbackFor` change that. +- Propagation: `REQUIRED` (default), `REQUIRES_NEW`, `NESTED` (savepoint), + `SUPPORTS`, `MANDATORY`, `NOT_SUPPORTED`, `NEVER`. `readOnly = true` for readers. +- **The method itself is rewritten at build time**, so unlike Spring the + transaction also applies to a call through `this`, to a private method, and to an + object you built with `new`. +- `Transactions.setRollbackOnly()` in the method that began the transaction rolls it back without throwing; in a joined method it makes the outer commit throw `UnexpectedRollback`. +- The injected `Session` only works inside a `@Transactional` method. +- Use `?` placeholders; the same SQL runs on SQLite, PostgreSQL and MySQL. + +## Background work: `@Async`, `@Scheduled`, threads + +```java +@Service +public class Reports { + @Async // caller returns at once + public Future build(String month) { + return AsyncResult.of(compute(month)); // void is fine too + } + + @Scheduled(cron = "0 0 3 * * *", zone = "Europe/Berlin") // sec min hour dom mon dow + public void nightly() { ... } + + @Scheduled(fixedRate = 60000, lock = "cleanup") // one replica at a time, via the DB + public void cleanup() { ... } +} +``` + +- Cron is Spring's **six** fields (seconds first) or `@daily`, `@hourly`, ... A + literal expression is parsed at build time — a typo is a build error. +- A run never overlaps the previous run of the same job; a late fire is skipped. +- `@Async(thread = ThreadKind.VIRTUAL)` runs on a virtual thread; `PLATFORM` (the + default) on a pool. A virtual thread parks on sockets, so PostgreSQL/MySQL + queries, `Web` calls and TLS are fine on `VIRTUAL`. **Rule of thumb: SQLite and + file work belong on `PLATFORM`** — those are local calls that block the host + thread under a virtual thread. `Future.get()` on a virtual thread yields its host + instead of blocking it. +- Size executors in config: `cn1.task.executor..threads`, + `cn1.task.executor..kind=virtual|platform`; name one with + `@Async("reports")`. One-off work: `Tasks.platform(runnable)`, + `Tasks.virtual(runnable)`. + +## Sessions + +```java +@PostMapping("/login") +public String login(HttpServer.Request request, @RequestBody Map body) { + HttpSession session = request.getSession(true); + session.changeSessionId(); // always, on login + session.setAttribute("user", userId); + return "ok"; +} +``` + +A session and its cookie exist only once something calls `getSession(true)`. +`cn1.session.store=db` keeps sessions in the database so any replica can serve +any client (attributes must then be JSON-able). Cookie is `HttpOnly`, +`SameSite=Lax`, and `Secure` under TLS. + +## Metrics and management (JMX style, over OpenTelemetry) + +```java +@Component +@ManagedResource(objectName = "cache") +public class Cache { + @ManagedAttribute(unit = "{entry}") public int getSize() { ... } // a gauge + @ManagedOperation public void clear() { ... } // invocable + @Timed @Counted public Value load(String key) { ... } // histogram + counters +} +``` + +Custom instruments: `Metrics.counter(...)`, `Metrics.histogram(...)`, +`Metrics.gauge(...)` — create once, keep in a static field. + +- `@OpenTelemetry(serviceName = "notes")` on any class (or + `cn1.otel.enabled=true`) exports traces **and** metrics over OTLP/HTTP; point + `OTEL_EXPORTER_OTLP_ENDPOINT` at a collector. +- Management endpoints: `/manage/health`, `/manage/metrics`, `/manage/prometheus`, + `/manage/jobs`, `/manage/managed`, and `POST /manage/managed/{bean}/{operation}`. + Always present in the `cn1:backend` dev run. A **packaged** binary contains them + only if the build asked -- `@EnableManagement` on a class or + `cn1.management.enabled=true` in a properties file -- otherwise the code is not + in the binary at all. Outside dev they also need `cn1.management.token` (env). + `cn1.management.enabled=false` turns built-in endpoints off at start-up. + +## MCP: the running backend as a tool server + +On the dev profile, `cn1:backend` serves MCP at `http://127.0.0.1:8080/mcp` and +prints the URL at start-up. Register it once: + +```bash +claude mcp add --transport http cn1-backend http://127.0.0.1:8080/mcp +``` + +(A stdio-only host can use the bridge in the backend jar: +`java -cp com.codename1.backend.mcp.StdioBridge http://127.0.0.1:8080/mcp`.) + +| Tool | Use it to | +| --- | --- | +| `backend_routes` | See every route and the method behind it | +| `backend_beans` | Check what was injected where, and which conditional beans are active | +| `backend_call` | Send a request (`method`, `path`, `body`, `headers`) and read the response | +| `backend_requests` | See recent requests; `failuresOnly: true` shows 5xx answers with the exception | +| `backend_logs` | Read the server's console | +| `backend_sql` | Query the database (`write: true` to modify it) | +| `backend_schema` | See the entities, tables and columns | +| `backend_jobs`, `backend_run_job` | Inspect scheduled jobs; run one now | +| `backend_metrics`, `backend_managed`, `backend_invoke` | Read metrics; read/invoke managed beans | +| `backend_config` | The active profile and settings (secrets masked) | + +The application can publish its own tools, which is how an agent in production +talks to the app's domain: + +```java +@McpTool(description = "Finds orders by customer email. Use before refunding.") +public List findOrders(@McpParam(value = "email", description = "Customer email") String email) { ... } +``` + +Outside a dev profile the endpoint requires `cn1.mcp.token` (the server refuses to +start without it) and a browser `Origin` other than localhost is refused. On a dev +profile the token is optional, but a server without one listens on `127.0.0.1` +only: its tools reach the database. To test from a phone or another machine, set +`cn1.mcp.token` (the MCP client then sends it as a bearer token) or turn the +endpoint off with `cn1.mcp.enabled=false`. A packaged binary contains the MCP +endpoint only when it has an `@McpTool` method, `@EnableMcpServer`, or +`cn1.mcp.enabled=true` in a properties file; the dev tools are only in the dev run. + +## Testing + +- **Unit test** a service by constructing it: injection is constructor calls, so + `new Notes(new FakeStore())` is the whole setup. +- **Integration test** the wired server — what `@SpringBootTest` does — by + starting the generated wiring on a free port: + +```java +Properties p = new Properties(); +p.setProperty("cn1.server.port", "0"); // or a free port you picked +Backend server = Backend.builder(Config.of(p, "test")) + .quiet() + .application(new BackendWiring()) // generated into target/classes + .start(); +try { + // HTTP calls against server.getServer().getPort() +} finally { + server.stop(); +} +``` + +The `test` profile is a development profile: in-memory SQLite, tables created. + +## Build errors you will see, and what they mean + +| Message fragment | Fix | +| --- | --- | +| `needs a X, and no bean has that type` | Annotate the implementation `@Service`/`@Component`, or add a `@Bean` method. | +| `could receive any of ...` | Mark one `@Primary` or inject with `@Qualifier("name")`. | +| `The constructors form a cycle` | Inject one side through an `@Autowired` field or setter. | +| `is final, so it can only be set by the constructor` | Take it as a constructor parameter. | +| `@Async method ... returns X` | Return `void` or `java.util.concurrent.Future` (`AsyncResult.of(...)`). | +| `the hour field of "..." names 25` | Fix the cron expression (six fields, seconds first). | +| `Parameter N of @McpTool ... has no @McpParam` | Name every tool parameter. | + +## Loop before reporting "done" + +1. `CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend` +2. `backend_routes` / `backend_beans` — the wiring is what you meant. +3. `backend_call` each changed endpoint; `backend_requests` with `failuresOnly`. +4. `backend_sql` to confirm what was written. +5. For a full-stack change, drive the app too — see `references/full-stack-loop.md`. diff --git a/scripts/initializr/common/src/main/resources/skill/references/full-stack-loop.md b/scripts/initializr/common/src/main/resources/skill/references/full-stack-loop.md new file mode 100644 index 00000000000..48ca244fce1 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/skill/references/full-stack-loop.md @@ -0,0 +1,118 @@ +# The Full-Stack Loop — App and Backend Together + +Use this when a change spans the app (`common/`) and the server (`backend/`): a new +screen that needs a new endpoint, a form whose data must land in the database, a +bug that could be on either side. The loop runs both halves locally and gives you +**two MCP servers**: + +- the **backend's**, which shows its routes, beans, requests, logs and database; +- the **simulator's**, which reads the screen and taps and types for you. + +Together they let you check a feature end to end — the button, the request, the +row — without asking a human to click anything. + +## 1. Start the backend (it keeps running) + +```bash +CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend +``` + +Run it in the background: it blocks, and it prints + +``` +cn1: MCP endpoint at http://127.0.0.1:8080/mcp (with development tools) +``` + +when it is ready. The `dev` profile gives it an in-memory SQLite database with the +`@Entity` tables created, so it needs nothing installed. **There is no hot reload:** +after changing backend code, stop it and run the command again (it takes seconds — +the build regenerates the wiring on the way). + +## 2. Connect both MCP servers (once per machine) + +```bash +claude mcp add --transport http cn1-backend http://127.0.0.1:8080/mcp +``` + +For the simulator, follow `references/mcp-agent-control.md`: run +`mvn -pl common cn1:run`, then **MCP -> Expose This Tool To Agents** and +**MCP -> Install in MCP Hosts...**. Restart the MCP host after registering either. + +## 3. Point the app at the local backend + +Keep the base URL in **one** place in the app (a constant, or a value read at +start-up) so switching between local and deployed is one edit: + +| Where the app runs | URL that reaches the local backend | +| --- | --- | +| Simulator (`cn1:run`) | `http://127.0.0.1:8080` | +| Android emulator | `http://10.0.2.2:8080` | +| iOS simulator | `http://127.0.0.1:8080` | +| A physical phone | `http://:8080` | +| JavaScript build in a browser | The backend must answer CORS for the page's origin | + +## 4. Share the API, don't transcribe it + +Declare the API once, as a `@RestClient` interface both sides compile, rather than +writing a client that mirrors a controller by hand. The app gets a typed client; +building the backend with `-Dcn1.restServer=true` gets a synchronous `...Server` +interface to implement and a dispatcher that routes to it. A change to the +interface then breaks whichever side did not follow. The interface has to live +where both modules can compile it -- `backend/` does not depend on `common/`, which +carries the UI -- so it goes in a small module both depend on; the developer +guide's Backend chapter ("Sharing the contract with the app") shows the layout. +See `references/api-clients.md` for the client half and `references/backend.md` +for the server. + +## 5. The loop + +1. **Learn the server.** `backend_routes` (what exists), `backend_beans` (what is + wired to what), `backend_schema` (the tables). +2. **Change the backend**, restart it, and **exercise the endpoint directly** + with `backend_call` before touching the UI. A failing endpoint is much cheaper + to diagnose here than through a screen. +3. **Change the app**, run it in the simulator, and **drive the flow** over the + simulator's MCP: `ui_snapshot` -> `ui_find` the field -> `ui_set_text` -> + `ui_activate` the button -> read the returned snapshot. +4. **Check what the server saw.** `backend_requests` lists the requests the UI + actually sent, newest first, with status and time; `failuresOnly: true` shows + only 5xx answers and the exception each one threw. `backend_logs` has the + console. +5. **Check what was stored.** `backend_sql` with a `SELECT` against the table the + flow writes. +6. Repeat until the UI, the requests and the rows all agree. + +When a step fails, the tools tell you which side to fix: + +| Symptom | Look at | +| --- | --- | +| The UI shows an error, `backend_requests` shows nothing | The app's URL (step 3), or the request was never sent | +| `backend_requests` shows 404 | `backend_routes`: path or method mismatch | +| `backend_requests` shows 500 | The `error` and `causes` of that request, then `backend_logs` | +| 200 but the UI shows nothing | The app's parsing of the response: `backend_call` the same request and compare | +| 200 but the row is wrong or missing | `backend_sql`; a rolled-back `@Transactional` method throws, and the request log shows it | + +## 6. Tests before "done" + +- **Backend**: unit-test services by constructing them; integration-test the wired + server by starting the generated `BackendWiring` on a free port — both are shown + in `references/backend.md`. +- **App**: screen tests with `cn1:test` (`references/testing-and-screenshots.md`). + Keep them independent of a running backend — fake the client, or point it at a + backend the test itself starts — so the suite does not depend on whatever you + left running. + +## 7. Ship + +```bash +mvn -pl backend -Dcodename1.platform=backend cn1:backend-package # native server binary +``` + +and the app's platform builds as in `references/build-and-run.md`. Before +deploying, switch the app's base URL to the real server, and give the backend its +production settings through the environment (`DATABASE_URL`, `PORT`, +`cn1.mcp.token` if it publishes `@McpTool` methods). The development MCP tools are +not compiled into the packaged binary. + +If you cannot run the backend or the simulator in your environment, **say so** in +your report rather than claiming the flow works. diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index 322ad9cb712..3bab228f0df 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -172,6 +172,8 @@ private void validateClaudeSkillBundled() throws Exception { ".agent-skills/codename-one/references/mcp-agent-control.md", ".agent-skills/codename-one/references/on-device-debugging.md", ".agent-skills/codename-one/references/ai-and-speech.md", + ".agent-skills/codename-one/references/backend.md", + ".agent-skills/codename-one/references/full-stack-loop.md", ".agent-skills/codename-one/tools/README.md", ".agent-skills/codename-one/tools/IsApiSupported.java", ".agent-skills/codename-one/tools/IsCssValid.java" @@ -205,6 +207,10 @@ private void validateClaudeSkillBundled() throws Exception { // build is attachable will give up at "cannot reproduce in the simulator". assertContains(agentsMd, "references/on-device-debugging.md", "AGENTS.md should point agents at the on-device debug/MCP loops"); + // And the server: an agent that does not know the backend is there, or that + // it can be inspected over MCP, writes a client against guesses. + assertContains(agentsMd, "references/backend.md", + "AGENTS.md should point agents at the backend reference"); String claudeStub = getText(entries, ".claude/skills/codename-one/SKILL.md"); assertContains(claudeStub, "name: codename-one", "Claude stub must keep the skill frontmatter"); diff --git a/vm/JavaAPI/src/java/util/concurrent/CancellationException.java b/vm/JavaAPI/src/java/util/concurrent/CancellationException.java index 4274032444d..75b1711bb75 100644 --- a/vm/JavaAPI/src/java/util/concurrent/CancellationException.java +++ b/vm/JavaAPI/src/java/util/concurrent/CancellationException.java @@ -1,6 +1,34 @@ +/* + * 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. + */ package java.util.concurrent; +/// Thrown by [Future#get] when the work was cancelled before it ran. public class CancellationException extends IllegalStateException { + /// An exception with no message. public CancellationException() { } + + /// An exception with `message`. + /// + /// @param message the detail message public CancellationException(String message) { super(message); } } diff --git a/vm/JavaAPI/src/java/util/concurrent/ExecutionException.java b/vm/JavaAPI/src/java/util/concurrent/ExecutionException.java index a0c3a74772e..904d3dec7fd 100644 --- a/vm/JavaAPI/src/java/util/concurrent/ExecutionException.java +++ b/vm/JavaAPI/src/java/util/concurrent/ExecutionException.java @@ -1,8 +1,45 @@ +/* + * 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. + */ package java.util.concurrent; +/// Thrown by [Future#get] when the work failed; the cause is what it threw. public class ExecutionException extends Exception { + /// An exception with no message or cause. public ExecutionException() { } + + /// An exception with `message`. + /// + /// @param message the detail message public ExecutionException(String message) { super(message); } + + /// An exception with `message` and the failure that caused it. + /// + /// @param message the detail message + /// @param cause what the work threw public ExecutionException(String message, Throwable cause) { super(message, cause); } + + /// An exception wrapping the failure that caused it. + /// + /// @param cause what the work threw public ExecutionException(Throwable cause) { super(cause); } } diff --git a/vm/JavaAPI/src/java/util/concurrent/Future.java b/vm/JavaAPI/src/java/util/concurrent/Future.java index 1608346fbc9..d3f597f773f 100644 --- a/vm/JavaAPI/src/java/util/concurrent/Future.java +++ b/vm/JavaAPI/src/java/util/concurrent/Future.java @@ -1,9 +1,66 @@ +/* + * 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. + */ package java.util.concurrent; +/// The result of work that finishes later -- on the backend, what an `@Async` +/// method returns, completed when its body has run on its executor. +/// +/// @param the type of the result public interface Future { + /// Stops the work if it has not started. Answers whether it was cancelled; + /// work already running is not interrupted by the backend's executors. + /// + /// @param mayInterruptIfRunning whether a running task may be interrupted + /// @return true when this call cancelled it boolean cancel(boolean mayInterruptIfRunning); + + /// Whether [#cancel] cancelled the work before it ran. + /// + /// @return true when cancelled boolean isCancelled(); + + /// Whether the work has finished: completed, failed or cancelled. + /// + /// @return true once it will not change again boolean isDone(); + + /// Waits for the work to finish and returns its result. + /// + /// @return the result + /// @throws InterruptedException when the waiting thread is interrupted + /// @throws ExecutionException when the work threw; [Throwable#getCause] is + /// what it threw + /// @throws CancellationException when the work was cancelled V get() throws InterruptedException, ExecutionException; + + /// Waits up to `timeout` for the work to finish and returns its result. + /// + /// @param timeout how long to wait, in `unit` + /// @param unit the unit of `timeout` + /// @return the result + /// @throws InterruptedException when the waiting thread is interrupted + /// @throws ExecutionException when the work threw + /// @throws TimeoutException when it has not finished in time + /// @throws CancellationException when the work was cancelled V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException; } diff --git a/vm/JavaAPI/src/java/util/concurrent/TimeUnit.java b/vm/JavaAPI/src/java/util/concurrent/TimeUnit.java index 214ebc0804c..a3d3adc558c 100644 --- a/vm/JavaAPI/src/java/util/concurrent/TimeUnit.java +++ b/vm/JavaAPI/src/java/util/concurrent/TimeUnit.java @@ -1,12 +1,44 @@ +/* + * 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. + */ package java.util.concurrent; +/// A unit of time, for timeouts such as [Future#get(long, TimeUnit)]. Conversions +/// saturate: a value too large for the target unit becomes Long.MAX_VALUE or +/// Long.MIN_VALUE rather than overflowing. public enum TimeUnit { + /// Billionths of a second. NANOSECONDS(0), + /// Millionths of a second. MICROSECONDS(1), + /// Thousandths of a second. MILLISECONDS(2), + /// Seconds. SECONDS(3), + /// Minutes. MINUTES(4), + /// Hours. HOURS(5), + /// Days. DAYS(6); private final int index; @@ -28,6 +60,11 @@ static long x(long d, long m, long over) { return d * m; } + /// `sourceDuration` in `sourceUnit`, converted to this unit. + /// + /// @param sourceDuration the duration + /// @param sourceUnit its unit + /// @return the duration in this unit public long convert(long sourceDuration, TimeUnit sourceUnit) { switch(this) { case NANOSECONDS: return sourceUnit.toNanos(sourceDuration); @@ -41,6 +78,10 @@ public long convert(long sourceDuration, TimeUnit sourceUnit) { } } + /// `d` of this unit in nanoseconds. + /// + /// @param d the duration + /// @return the converted duration public long toNanos(long d) { if (this == NANOSECONDS) return d; if (this == MICROSECONDS) return x(d, C1/C0, MAX/(C1/C0)); @@ -51,6 +92,10 @@ public long toNanos(long d) { return x(d, C6/C0, MAX/(C6/C0)); } + /// `d` of this unit in microseconds. + /// + /// @param d the duration + /// @return the converted duration public long toMicros(long d) { if (this == NANOSECONDS) return d / (C1/C0); if (this == MICROSECONDS) return d; @@ -61,6 +106,10 @@ public long toMicros(long d) { return x(d, C6/C1, MAX/(C6/C1)); } + /// `d` of this unit in milliseconds. + /// + /// @param d the duration + /// @return the converted duration public long toMillis(long d) { if (this == NANOSECONDS) return d / (C2/C0); if (this == MICROSECONDS) return d / (C2/C1); @@ -71,6 +120,10 @@ public long toMillis(long d) { return x(d, C6/C2, MAX/(C6/C2)); } + /// `d` of this unit in seconds. + /// + /// @param d the duration + /// @return the converted duration public long toSeconds(long d) { if (this == NANOSECONDS) return d / (C3/C0); if (this == MICROSECONDS) return d / (C3/C1); @@ -81,6 +134,10 @@ public long toSeconds(long d) { return x(d, C6/C3, MAX/(C6/C3)); } + /// `d` of this unit in minutes. + /// + /// @param d the duration + /// @return the converted duration public long toMinutes(long d) { if (this == NANOSECONDS) return d / (C4/C0); if (this == MICROSECONDS) return d / (C4/C1); @@ -91,6 +148,10 @@ public long toMinutes(long d) { return x(d, C6/C4, MAX/(C6/C4)); } + /// `d` of this unit in hours. + /// + /// @param d the duration + /// @return the converted duration public long toHours(long d) { if (this == NANOSECONDS) return d / (C5/C0); if (this == MICROSECONDS) return d / (C5/C1); @@ -101,6 +162,10 @@ public long toHours(long d) { return x(d, C6/C5, MAX/(C6/C5)); } + /// `d` of this unit in days. + /// + /// @param d the duration + /// @return the converted duration public long toDays(long d) { if (this == NANOSECONDS) return d / (C6/C0); if (this == MICROSECONDS) return d / (C6/C1); @@ -117,6 +182,11 @@ private int excessNanos(long d, long m) { return 0; } + /// Waits on `obj` for `timeout` of this unit, as `obj.wait` does. + /// + /// @param obj the monitor, which the caller must hold + /// @param timeout how long to wait + /// @throws InterruptedException when the thread is interrupted public void timedWait(Object obj, long timeout) throws InterruptedException { if (timeout > 0) { long ms = toMillis(timeout); @@ -125,6 +195,11 @@ public void timedWait(Object obj, long timeout) throws InterruptedException { } } + /// Waits up to `timeout` of this unit for `thread` to end, as `thread.join` does. + /// + /// @param thread the thread to wait for + /// @param timeout how long to wait + /// @throws InterruptedException when the waiting thread is interrupted public void timedJoin(Thread thread, long timeout) throws InterruptedException { if (timeout > 0) { long ms = toMillis(timeout); @@ -133,6 +208,10 @@ public void timedJoin(Thread thread, long timeout) throws InterruptedException { } } + /// Sleeps for `timeout` of this unit, as `Thread.sleep` does. + /// + /// @param timeout how long to sleep + /// @throws InterruptedException when the thread is interrupted public void sleep(long timeout) throws InterruptedException { if (timeout > 0) { long ms = toMillis(timeout); diff --git a/vm/JavaAPI/src/java/util/concurrent/TimeoutException.java b/vm/JavaAPI/src/java/util/concurrent/TimeoutException.java index a0e5b4f07a0..69be4c33b8d 100644 --- a/vm/JavaAPI/src/java/util/concurrent/TimeoutException.java +++ b/vm/JavaAPI/src/java/util/concurrent/TimeoutException.java @@ -1,6 +1,34 @@ +/* + * 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. + */ package java.util.concurrent; +/// Thrown by [Future#get(long, TimeUnit)] when the work has not finished in time. public class TimeoutException extends Exception { + /// An exception with no message. public TimeoutException() { } + + /// An exception with `message`. + /// + /// @param message the detail message public TimeoutException(String message) { super(message); } } diff --git a/vm/backend/demo/dbcheck/com/demo/DbCheck.java b/vm/backend/demo/dbcheck/com/demo/DbCheck.java index 0be65bcad02..4fae11ac07c 100644 --- a/vm/backend/demo/dbcheck/com/demo/DbCheck.java +++ b/vm/backend/demo/dbcheck/com/demo/DbCheck.java @@ -22,11 +22,15 @@ */ package com.demo; +import java.io.ByteArrayOutputStream; import java.util.ArrayList; import java.util.List; import java.util.Map; import com.codename1.backend.Database; +import com.codename1.backend.HttpServer; +import com.codename1.backend.Tcp; +import com.codename1.backend.VirtualThread; /** * Exercises the database layer against a REAL server, one engine per run. @@ -60,6 +64,7 @@ public static void main(String[] args) throws Exception { } rejectsAnUntrustedCertificate(url); refusesCleartextPasswordWithoutTls(); + aQueryParksItsVirtualThread(url); System.out.println("passed=" + passed + " failed=" + failures.size()); for(int iter = 0 ; iter < failures.size() ; iter++) { @@ -615,6 +620,97 @@ private static byte[] bytes(String value) throws Exception { return value.getBytes("UTF-8"); } + /** + * A query to a server engine parks the virtual thread that is waiting on it. + * + *

The PostgreSQL and MySQL clients speak their protocols over an ordinary + * socket, and a socket wait parks a virtual thread: the host it runs on goes + * on serving other connections while the query runs. Held here against a + * server with ONE host, so a query that blocked the host would hold every + * other request for as long as the query takes, and a second request is timed + * against a two-second sleep in the database. + * + *

The Java SE arm has no virtual threads and serves from a pool, so it is + * given two workers: the check then holds there too and both arms count the + * same passes, which BackendDatabaseTest compares. SQLite is skipped on both: + * it is a local library call, not a socket, and it does block the host -- as + * the guide says. + */ + private static void aQueryParksItsVirtualThread(final String url) throws Exception { + final boolean postgres = url.startsWith("postgres"); + if(!postgres && !url.startsWith("mysql") && !url.startsWith("mariadb")) { + note("the parked-query check needs a server engine; SQLite blocks its host by design"); + return; + } + final String sleep = postgres ? "SELECT pg_sleep(2)" : "SELECT SLEEP(2)"; + final String[] onVirtual = new String[1]; + HttpServer server = HttpServer.start("127.0.0.1", 0, 16, + VirtualThread.supported() ? 1 : 2, new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + if("/slow".equals(request.getTarget())) { + onVirtual[0] = String.valueOf(VirtualThread.isVirtual()); + Database db = Database.open(url); + try { + db.query(sleep, new Object[0]); + } finally { + db.close(); + } + return HttpServer.Response.text(200, "slept"); + } + return HttpServer.Response.text(200, "fast"); + } + }); + String verdict; + final String[] slowBody = new String[1]; + try { + final int port = server.getPort(); + Thread caller = new Thread(new Runnable() { + public void run() { + try { + slowBody[0] = get(port, "/slow"); + } catch (Exception failed) { + slowBody[0] = "failed: " + failed; + } + } + }); + caller.start(); + Thread.sleep(500); + long started = System.currentTimeMillis(); + String fast = get(port, "/fast"); + long spent = System.currentTimeMillis() - started; + caller.join(20000); + verdict = "fast".equals(fast) && spent < 1000 ? "free" + : "waited " + spent + "ms for " + fast; + } finally { + server.stop(2000); + } + check("a query that sleeps in the database completes", "slept", slowBody[0]); + check("another request is served while a query waits", "free", verdict); + check("the query ran on a virtual thread where there are any", + String.valueOf(VirtualThread.supported()), onVirtual[0]); + } + + /** One GET, answered with the response body. */ + private static String get(int port, String target) throws Exception { + Tcp conn = Tcp.connect("127.0.0.1", port, 15000); + try { + byte[] request = ("GET " + target + " HTTP/1.1\r\nHost: x\r\n" + + "Connection: close\r\n\r\n").getBytes("UTF-8"); + conn.write(request, 0, request.length); + ByteArrayOutputStream all = new ByteArrayOutputStream(); + byte[] chunk = new byte[1024]; + int n; + while((n = conn.read(chunk, 0, chunk.length)) > 0) { + all.write(chunk, 0, n); + } + String text = new String(all.toByteArray(), "UTF-8"); + int at = text.indexOf("\r\n\r\n"); + return at < 0 ? null : text.substring(at + 4); + } finally { + conn.close(); + } + } + private static void check(String name, String expected, String actual) { if(expected.equals(actual)) { passed++; diff --git a/vm/backend/demo/oteltest/com/demo/OtelServer.java b/vm/backend/demo/oteltest/com/demo/OtelServer.java index 1d88ecc03b4..05226b063bd 100644 --- a/vm/backend/demo/oteltest/com/demo/OtelServer.java +++ b/vm/backend/demo/oteltest/com/demo/OtelServer.java @@ -60,6 +60,12 @@ public static void main(String[] args) throws Exception { db.execute("INSERT INTO pets (id, name) VALUES (7, 'Rex')", null); Backend.builder() .tracing(new OtlpTracer("oteltest")) + // Both off at run time -- this profile is not a development one and + // there is no tool -- but LINKED, which is what BackendOtelTest + // needs: the control that proves its management and MCP symbol + // spellings match something. + .management() + .mcp(null) .webSockets(new Backend.WebSocketEndpoints() { public void register(HttpServer.WebSocketRegistry registry, DataSource dataSource, EntityManager entities) { diff --git a/vm/backend/demo/selftest/com/demo/SelfTest.java b/vm/backend/demo/selftest/com/demo/SelfTest.java index 853d6431a05..7fef93c7194 100644 --- a/vm/backend/demo/selftest/com/demo/SelfTest.java +++ b/vm/backend/demo/selftest/com/demo/SelfTest.java @@ -41,6 +41,7 @@ import com.codename1.backend.Http1Date; import com.codename1.backend.HttpServer; import com.codename1.backend.Json; +import com.codename1.backend.JsonCodec; import com.codename1.backend.Jwt; import com.codename1.backend.ServerSocket; import com.codename1.backend.StaticFiles; @@ -224,6 +225,8 @@ public static void main(String[] args) throws Exception { web(); clientTls(); rotatedCaBundlesAreReRead(); + futures(); + jsonCodec(); System.out.println("passed=" + passed + " failed=" + failures.size()); for(int iter = 0 ; iter < failures.size() ; iter++) { @@ -237,6 +240,152 @@ public static void main(String[] args) throws Exception { // ------------------------------------------------------------------ + /// The vm/JavaAPI classes the backend API promotes beyond the CLDC set -- + /// Future and what its get() throws, TimeUnit, Properties -- used exactly as + /// the backend reference documents them, on the translated runtime. They + /// compile against the JDK in every other test; this is where they have to + /// work on ParparVM. + /// What the build's generated JSON codecs call, on the translated runtime: + /// the typed reads, the refusal messages with their paths, and the dates. + private static void jsonCodec() throws Exception { + JsonCodec.Path root = JsonCodec.Path.ROOT; + check("codec: a whole number", "42", String.valueOf(JsonCodec.readLong( + Long.valueOf(42), root, "n", -1, Integer.MIN_VALUE, Integer.MAX_VALUE))); + check("codec: an integral double is a whole number", "3", String.valueOf( + JsonCodec.readLong(Double.valueOf(3.0), root, "n", -1, 0, 10))); + check("codec: out of range is refused with its path", + "$.items[2].n: expected a whole number from 0 to 10, got the number 11", + refusal(Long.valueOf(11), root.child("items").child(2), "n", -1)); + check("codec: a fraction is refused, not cut short", + "$.n: expected a whole number from 0 to 10, got the number 2.5", + refusal(Double.valueOf(2.5), root, "n", -1)); + check("codec: an element index in the path", "$[3]: expected a string, got true", + stringRefusal(Boolean.TRUE, root, null, 3)); + check("codec: a date from milliseconds", "86400000", String.valueOf( + JsonCodec.readDate(Long.valueOf(86400000L), root, "d", -1).getTime())); + check("codec: an ISO date with Z", "86400000", String.valueOf( + JsonCodec.readDate("1970-01-02T00:00:00Z", root, "d", -1).getTime())); + check("codec: an ISO date with an offset and a fraction", "86399500", String.valueOf( + JsonCodec.readDate("1970-01-02T02:59:59.5+03:00", root, "d", -1).getTime())); + check("codec: a date alone is UTC midnight", "951782400000", String.valueOf( + JsonCodec.readDate("2000-02-29", root, "d", -1).getTime())); + check("codec: an impossible date is refused", + "$.d: expected a number or an ISO-8601 date, got a string", + dateRefusal("2001-02-29")); + ByteSink out = new ByteSink(16); + JsonCodec.writeDate(new java.util.Date(1234L), out); + check("codec: a date is written as milliseconds", "1234", + new String(out.bytes(), 0, out.length(), "UTF-8")); + check("codec: base64 reads back", "3", String.valueOf( + JsonCodec.readBytes("AQID", root, "b", -1).length)); + check("codec: a cycle's message names the class", "true", String.valueOf( + JsonCodec.tooDeep("Order").getMessage().indexOf("Order") >= 0)); + } + + private static String refusal(Object json, JsonCodec.Path at, String name, int index) { + try { + JsonCodec.readLong(json, at, name, index, 0, 10); + return "accepted"; + } catch (IllegalArgumentException err) { + return err.getMessage(); + } + } + + private static String stringRefusal(Object json, JsonCodec.Path at, String name, int index) { + try { + JsonCodec.readString(json, at, name, index); + return "accepted"; + } catch (IllegalArgumentException err) { + return err.getMessage(); + } + } + + private static String dateRefusal(String value) { + try { + JsonCodec.readDate(value, JsonCodec.Path.ROOT, "d", -1); + return "accepted"; + } catch (IllegalArgumentException err) { + return err.getMessage(); + } + } + + private static void futures() throws Exception { + java.util.concurrent.Future ready = com.codename1.backend.AsyncResult.of("ready"); + check("future: a completed value", "ready", String.valueOf(ready.get())); + check("future: completed is done", "true", String.valueOf(ready.isDone())); + check("future: a timed get of a completed value", "ready", + String.valueOf(ready.get(1, java.util.concurrent.TimeUnit.SECONDS))); + + com.codename1.backend.AsyncTask ran = new com.codename1.backend.AsyncTask( + "selftest.ran", false) { + protected Object call() { + return com.codename1.backend.AsyncResult.of("ran"); + } + }; + com.codename1.backend.Tasks.platform(ran); + check("future: a task's result", "ran", + String.valueOf(ran.get(30, java.util.concurrent.TimeUnit.SECONDS))); + + com.codename1.backend.AsyncTask broken = new com.codename1.backend.AsyncTask( + "selftest.broken", false) { + protected Object call() { + throw new IllegalStateException("broken"); + } + }; + com.codename1.backend.Tasks.platform(broken); + String cause = "no exception"; + try { + broken.get(30, java.util.concurrent.TimeUnit.SECONDS); + } catch (java.util.concurrent.ExecutionException err) { + cause = err.getCause() == null ? "no cause" : err.getCause().getMessage(); + } + check("future: a failure is the ExecutionException's cause", "broken", cause); + + com.codename1.backend.AsyncTask cancelled = new com.codename1.backend.AsyncTask( + "selftest.cancelled", false) { + protected Object call() { + return com.codename1.backend.AsyncResult.of("never"); + } + }; + check("future: cancel before it runs", "true", String.valueOf(cancelled.cancel(false))); + String thrown = "no exception"; + try { + cancelled.get(); + } catch (java.util.concurrent.CancellationException err) { + thrown = "cancelled"; + } + check("future: get of a cancelled task", "cancelled", thrown); + + com.codename1.backend.AsyncTask pending = new com.codename1.backend.AsyncTask( + "selftest.pending", false) { + protected Object call() { + return com.codename1.backend.AsyncResult.of("never"); + } + }; + String timedOut = "no exception"; + try { + pending.get(50, java.util.concurrent.TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.TimeoutException err) { + timedOut = "timed out"; + } + check("future: a timed get that runs out", "timed out", timedOut); + + check("timeunit: seconds to millis", "2000", + String.valueOf(java.util.concurrent.TimeUnit.SECONDS.toMillis(2))); + check("timeunit: convert minutes to millis", "180000", + String.valueOf(java.util.concurrent.TimeUnit.MILLISECONDS.convert(3, + java.util.concurrent.TimeUnit.MINUTES))); + check("timeunit: a conversion saturates", String.valueOf(Long.MAX_VALUE), + String.valueOf(java.util.concurrent.TimeUnit.DAYS.toMillis(Long.MAX_VALUE))); + + java.util.Properties settings = new java.util.Properties(); + settings.load(new java.io.ByteArrayInputStream( + "a=1\nb = two\n# a comment\n".getBytes("UTF-8"))); + check("properties: a value", "1", settings.getProperty("a")); + check("properties: spaces around the separator", "two", settings.getProperty("b")); + check("properties: a default", "fallback", settings.getProperty("c", "fallback")); + } + private static void threadLocalInitialization() { final int[] attempts = new int[1]; ThreadLocal retry = new ThreadLocal() { @@ -4271,6 +4420,235 @@ private static void aLateBodyIsNotServedAnotherConnectionsBytes() throws Excepti "aaaaaa|x|6|POST", oneLateBodyRequest(true)); } + /** + * An OUTBOUND wait on a virtual thread parks the virtual thread, not its host. + * + *

One host serves everything here (workerCount 1 is one host under virtual + * threads), so a handler blocked inside a read of a slow peer would stop every + * other request until the peer answered. That is what the outbound natives did + * until they parked: recv() on a blocking descriptor held the host, and there + * is one host per core. Each case below starts a slow outbound call on one + * request and times an unrelated request against it; the unrelated one must + * not wait for the slow one. + * + *

Covered: a raw Tcp read (the PostgreSQL and MySQL clients' path), an HTTP + * call through Web (libcurl), a TLS handshake that never finishes, and a read + * deadline, which must still fire -- a parked read no longer has SO_RCVTIMEO + * to end it, so the wait carries the deadline itself. + */ + private static void anOutboundWaitLeavesTheHostFree() throws Exception { + if(!VirtualThread.supported()) { + note("outbound parking checks skipped: this runtime has no virtual threads"); + return; + } + final int delay = 2500; + final ServerSocket slow = ServerSocket.bind("127.0.0.1", 0, 8); + final ServerSocket silent = ServerSocket.bind("127.0.0.1", 0, 8); + Thread slowPeer = servePeer(slow, delay, 2); + Thread silentPeer = servePeer(silent, -1, 2); + final String[] onVirtual = new String[1]; + final int slowPort = slow.getPort(); + final int silentPort = silent.getPort(); + // A slow TLS peer, when the harness made a certificate for 127.0.0.1. A + // TLS server always runs on its pool, so it does not take the single + // virtual-thread slot the server under test needs. + final String certPath = System.getenv("CN1_SELFTEST_TLS_CERT"); + String keyPath = System.getenv("CN1_SELFTEST_TLS_KEY"); + HttpServer tlsPeer = null; + if(certPath != null && keyPath != null) { + tlsPeer = HttpServer.start("127.0.0.1", 0, 16, 2, new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + Thread.sleep(delay); + return HttpServer.Response.text(200, "pong"); + } + }, Tls.create(certPath, keyPath)); + } + final int tlsPort = tlsPeer == null ? 0 : tlsPeer.getPort(); + HttpServer server = HttpServer.start("127.0.0.1", 0, 16, 1, new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + String path = request.getTarget(); + if("/tcp".equals(path)) { + onVirtual[0] = String.valueOf(VirtualThread.isVirtual()); + Tcp out = Tcp.connect("127.0.0.1", slowPort, 5000); + try { + byte[] ask = ("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + .getBytes("UTF-8"); + out.write(ask, 0, ask.length); + ByteArrayOutputStream all = new ByteArrayOutputStream(); + byte[] chunk = new byte[256]; + int n; + while((n = out.read(chunk, 0, chunk.length)) > 0) { + all.write(chunk, 0, n); + } + String text = new String(all.toByteArray(), "UTF-8"); + return HttpServer.Response.text(200, text.endsWith("pong") ? "pong" : text); + } finally { + out.close(); + } + } + if("/web".equals(path)) { + Web.Result result = Web.get("http://127.0.0.1:" + slowPort + "/"); + return HttpServer.Response.text(200, result.getStatus() + ":" + + result.getBodyAsString()); + } + if("/tls".equals(path)) { + Tcp out = Tcp.connect("127.0.0.1", silentPort, 5000); + try { + out.startTls("127.0.0.1"); + return HttpServer.Response.text(200, "handshook with nobody"); + } catch (IOException refused) { + return HttpServer.Response.text(200, "refused"); + } finally { + out.close(); + } + } + if("/tlsread".equals(path)) { + // Verified against the peer's own certificate as the CA + // bundle: a real chain and name check, not one switched off. + Tcp out = Tcp.connect("127.0.0.1", tlsPort, 5000); + try { + out.startTls("127.0.0.1", certPath); + byte[] ask = ("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Connection: close\r\n\r\n").getBytes("UTF-8"); + out.write(ask, 0, ask.length); + ByteArrayOutputStream all = new ByteArrayOutputStream(); + byte[] chunk = new byte[256]; + int n; + while((n = out.read(chunk, 0, chunk.length)) > 0) { + all.write(chunk, 0, n); + } + String text = new String(all.toByteArray(), "UTF-8"); + return HttpServer.Response.text(200, text.endsWith("pong") ? "pong" : text); + } finally { + out.close(); + } + } + if("/deadline".equals(path)) { + Tcp out = Tcp.connect("127.0.0.1", silentPort, 5000); + long started = System.currentTimeMillis(); + try { + out.setReadTimeout(400); + byte[] chunk = new byte[16]; + out.read(chunk, 0, chunk.length); + return HttpServer.Response.text(200, "read something"); + } catch (IOException timedOut) { + long spent = System.currentTimeMillis() - started; + return HttpServer.Response.text(200, spent < 2000 ? "timed out" + : "timed out after " + spent + "ms"); + } finally { + out.close(); + } + } + return HttpServer.Response.text(200, "fast"); + } + }); + try { + int port = server.getPort(); + String[] tcp = alongsideAFastRequest(port, "/tcp", delay); + check("a handler's outbound read runs on a virtual thread", "true", onVirtual[0]); + check("an outbound Tcp read returns what the peer sent", "pong", tcp[0]); + check("another request is served while a Tcp read waits", "free", tcp[1]); + String[] web = alongsideAFastRequest(port, "/web", delay); + check("an outbound Web call returns what the peer sent", "200:pong", web[0]); + check("another request is served while a Web call waits", "free", web[1]); + // The handshake budget is CN1_TLS_HANDSHAKE_MS, 1500 in this suite. + String[] tls = alongsideAFastRequest(port, "/tls", 1500); + check("a TLS handshake with a silent peer still gives up", "refused", tls[0]); + check("another request is served while a TLS handshake waits", "free", tls[1]); + check("a parked read still honours its read timeout", "timed out", + httpGetBody("127.0.0.1", port, "/deadline")); + if(tlsPeer != null) { + String[] tlsRead = alongsideAFastRequest(port, "/tlsread", delay); + check("an outbound TLS read returns what the peer sent", "pong", tlsRead[0]); + check("another request is served while a TLS read waits", "free", tlsRead[1]); + } else { + note("outbound TLS read check skipped: CN1_SELFTEST_TLS_CERT and " + + "CN1_SELFTEST_TLS_KEY name no certificate for a local peer"); + } + } finally { + server.stop(2000); + if(tlsPeer != null) { + tlsPeer.stop(2000); + } + slow.close(); + silent.close(); + } + slowPeer.join(10000); + silentPeer.join(10000); + } + + /** + * Starts `slowPath` on a thread of its own, waits until it is under way, then + * times a trivial request. Answers the slow request's body and "free" when the + * trivial one came back in well under `slowMillis`, or how long it took. + */ + private static String[] alongsideAFastRequest(final int port, final String slowPath, + int slowMillis) throws Exception { + final String[] slowBody = new String[1]; + Thread caller = new Thread(new Runnable() { + public void run() { + try { + slowBody[0] = httpGetBody("127.0.0.1", port, slowPath); + } catch (Exception failed) { + slowBody[0] = "failed: " + failed; + } + } + }); + caller.start(); + Thread.sleep(400); + long started = System.currentTimeMillis(); + String fast = httpGetBody("127.0.0.1", port, "/fast"); + long spent = System.currentTimeMillis() - started; + caller.join(20000); + String verdict = "fast".equals(fast) && spent < slowMillis / 2 ? "free" + : "waited " + spent + "ms for " + fast; + return new String[] {slowBody[0], verdict}; + } + + /** + * A peer for the outbound checks, on platform threads: accepts `connections` + * connections and, for each, reads the request and then waits `delayMillis` + * before answering "pong" as an HTTP response -- or, with a negative delay, + * never answers and holds the connection open for a while. + */ + private static Thread servePeer(final ServerSocket listener, final int delayMillis, + final int connections) { + Thread acceptor = new Thread(new Runnable() { + public void run() { + for(int iter = 0 ; iter < connections ; iter++) { + final int client = listener.accept(); + if(client < 0) { + return; + } + Thread one = new Thread(new Runnable() { + public void run() { + try { + ServerSocket.setBlocking(client, true); + if(delayMillis < 0) { + Thread.sleep(6000); + return; + } + byte[] request = new byte[1024]; + ServerSocket.read(client, request, 0, request.length); + Thread.sleep(delayMillis); + byte[] answer = ("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n" + + "Connection: close\r\n\r\npong").getBytes("UTF-8"); + ServerSocket.write(client, answer, 0, answer.length); + } catch (Exception ignored) { + // The caller gave up; nothing to report from here. + } finally { + ServerSocket.closeFd(client); + } + } + }); + one.start(); + } + } + }); + acceptor.start(); + return acceptor; + } + private static String oneLateBodyRequest(boolean interleave) throws Exception { final String[] seen = new String[1]; HttpServer server = HttpServer.start("127.0.0.1", 0, 16, 1, new HttpServer.Handler() { @@ -5757,6 +6135,7 @@ private static void json() throws Exception { anOutboundFragmentIsRefused(); caseVariantHeadersSignAsOneField(); aLateBodyIsNotServedAnotherConnectionsBytes(); + anOutboundWaitLeavesTheHostFree(); aFileBackedResponseClosesItsDescriptorWhenTheHeadFails(); anEncodedMountPrefixIsTheSameMount(); aMountDeclaredWithAnEscapeIsStillReachable(); diff --git a/vm/backend/impl/javase/com/codename1/backend/DevConsole.java b/vm/backend/impl/javase/com/codename1/backend/DevConsole.java new file mode 100644 index 00000000000..2f0e478e690 --- /dev/null +++ b/vm/backend/impl/javase/com/codename1/backend/DevConsole.java @@ -0,0 +1,112 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.io.OutputStream; +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; + +/// The console lines a development server printed, for the MCP log tool. +/// +/// The Java SE arm tees `System.out` and `System.err` into a ring of +/// recent lines; the packaged runtime has no way to replace them and keeps +/// nothing, which is why the development tools say so when asked there. +public final class DevConsole { + private static final int CAPACITY = 2000; + private static final String[] LINES = new String[CAPACITY]; + private static int next; + private static int size; + private static boolean installed; + + private DevConsole() { + } + + /// Whether this runtime can capture the console at all. + public static boolean supported() { + return true; + } + + /// Starts capturing. Idempotent. + public static synchronized void install() { + if (installed) { + return; + } + installed = true; + try { + // UTF-8 named, not the platform default, which differs between the + // machines a developer runs this on. + System.setOut(new PrintStream(new Tee(System.out, "out"), true, "UTF-8")); + System.setErr(new PrintStream(new Tee(System.err, "err"), true, "UTF-8")); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException("UTF-8 is always supported", err); + } + } + + /// The newest `limit` lines, oldest first. + public static synchronized List recent(int limit) { + int count = Math.min(limit, size); + List out = new ArrayList(count); + for (int iter = count - 1 ; iter >= 0 ; iter--) { + out.add(LINES[(next - 1 - iter + CAPACITY) % CAPACITY]); + } + return out; + } + + static synchronized void add(String line) { + LINES[next] = line; + next = (next + 1) % CAPACITY; + if (size < CAPACITY) { + size++; + } + } + + /// Writes through to the real stream and collects whole lines. + private static final class Tee extends OutputStream { + private final PrintStream target; + private final String name; + private final java.io.ByteArrayOutputStream line = new java.io.ByteArrayOutputStream(); + + Tee(PrintStream target, String name) { + this.target = target; + this.name = name; + } + + @Override + public synchronized void write(int b) { + target.write(b); + if (b == '\n') { + add("[" + name + "] " + new String(line.toByteArray(), + java.nio.charset.StandardCharsets.UTF_8)); + line.reset(); + } else if (b != '\r' && line.size() < 4000) { + line.write(b); + } + } + + @Override + public void flush() { + target.flush(); + } + } +} diff --git a/vm/backend/impl/javase/com/codename1/backend/Reactor.java b/vm/backend/impl/javase/com/codename1/backend/Reactor.java index 2062e563950..7b29dcfc4f0 100644 --- a/vm/backend/impl/javase/com/codename1/backend/Reactor.java +++ b/vm/backend/impl/javase/com/codename1/backend/Reactor.java @@ -66,6 +66,18 @@ private Reactor(Selector selector) { this.selector = selector; } + /// Null: this runtime has no virtual-thread hosts to wake. See the packaged + /// runtime's Reactor. + public static int[] createWakePipe() { + return null; + } + + public static void wake(int writeFd) { + } + + public static void drainWake(int readFd) { + } + public static Reactor create() throws IOException { return new Reactor(Selector.open()); } diff --git a/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java b/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java index 38e8e3881ca..0cb59169c77 100644 --- a/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java +++ b/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java @@ -38,14 +38,37 @@ public static long create(int fd, int stackBytes) { return 0; } + /// Never a virtual thread on this runtime; see [#create]. + public static long createTask(long token, int stackBytes) { + return 0; + } + public static final int FINISHED = 0; public static final int PARKED_IO = 1; public static final int RUNNABLE = 2; + public static final int WAITING = 3; public static int resume(long handle) { return FINISHED; } + /// Nothing ever waits here; see [#create]. + public static int waitCount(long handle) { + return 0; + } + + public static int waitDescriptor(long handle, int index) { + return -1; + } + + public static int waitEvents(long handle, int index) { + return 0; + } + + public static long waitTimeout(long handle) { + return -1; + } + public static void free(long handle) { } @@ -54,6 +77,11 @@ public static int descriptorOf(long handle) { return -1; } + /// Always 0: the JVM development runtime has no virtual threads of its own. + public static long current() { + return 0; + } + public static boolean isVirtual() { return false; } diff --git a/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java b/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java new file mode 100644 index 00000000000..e4c5adfb426 --- /dev/null +++ b/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java @@ -0,0 +1,218 @@ +/* + * 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. + */ +package com.codename1.backend.mcp; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/// Connects an MCP host that only speaks stdio -- Codex, older desktop hosts -- to +/// a backend's HTTP endpoint. Each line read from stdin is one JSON-RPC message, +/// POSTed as is; each answer is written to stdout as one line. +/// +/// ```java +/// java -cp codenameone-backend.jar com.codename1.backend.mcp.StdioBridge \ +/// http://127.0.0.1:8080/mcp [token] +/// ``` +/// +/// A host that speaks HTTP -- Claude Code among them -- needs none of this and +/// should be pointed at the URL directly. +public final class StdioBridge { + /// How long to wait for the backend to accept the connection. + static final int CONNECT_TIMEOUT_MILLIS = 5000; + + /// How long to wait for an answer, in milliseconds: `CN1_MCP_TIMEOUT_MS`, + /// two minutes by default. Messages are forwarded one at a time, so without + /// a bound a backend that accepted a request and then stalled would hold + /// every later message too, and the host would wait forever instead of + /// being told the backend is unreachable. + static int readTimeoutMillis() { + String configured = System.getenv("CN1_MCP_TIMEOUT_MS"); + int fallback = 120000; + if (configured == null) { + return fallback; + } + try { + int value = Integer.parseInt(configured.trim()); + return value > 0 ? value : fallback; + } catch (NumberFormatException notANumber) { + return fallback; + } + } + + private StdioBridge() { + } + + public static void main(String[] args) throws Exception { + if (args.length < 1) { + System.err.println("usage: StdioBridge [bearer-token]"); + System.exit(2); + } + URL url = new URL(args[0]); + String token = args.length > 1 ? args[1] : System.getenv("CN1_MCP_TOKEN"); + // The process's own streams: they live as long as it does, and closing + // them would take stdin and stdout from anything else in the JVM. + BufferedReader in = new BufferedReader(new InputStreamReader(System.in, //NOPMD CloseResource - the process stdin + StandardCharsets.UTF_8)); + OutputStream out = System.out; //NOPMD CloseResource - the process stdout + String line; + while ((line = in.readLine()) != null) { + if (line.trim().length() == 0) { + continue; + } + String answer = post(url, token, line); + if (answer != null && answer.trim().length() > 0) { + out.write((answer.trim() + "\n").getBytes(StandardCharsets.UTF_8)); + out.flush(); + } + } + } + + /// Forwards one line; package-private for the test of the unreachable path. + static String post(URL url, String token, String body) { + return post(url, token, body, readTimeoutMillis()); + } + + /// [#post(URL,String,String)] with an explicit bound on the answer's wait. + static String post(URL url, String token, String body, int readTimeout) { + try { + HttpURLConnection c = (HttpURLConnection) url.openConnection(); + // A timeout is an IOException, so it reaches unreachable() below and + // the host gets an error under its request's id. + c.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); + c.setReadTimeout(readTimeout); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json"); + c.setRequestProperty("Accept", "application/json, text/event-stream"); + if (token != null && token.length() > 0) { + c.setRequestProperty("Authorization", "Bearer " + token); + } + c.getOutputStream().write(body.getBytes(StandardCharsets.UTF_8)); + int status = c.getResponseCode(); + java.io.InputStream stream = status >= 400 ? c.getErrorStream() : c.getInputStream(); + if (stream == null) { + return null; + } + java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int n; + while ((n = stream.read(chunk)) > 0) { + buffer.write(chunk, 0, n); + } + String answer = new String(buffer.toByteArray(), StandardCharsets.UTF_8); + if (status < 200 || status >= 300) { + return refused(body, status, answer); + } + return answer; + } catch (IOException err) { + return unreachable(body, err); + } catch (RuntimeException err) { + return unreachable(body, err); + } + } + + /// The answer the host gets when the backend is not up: an error under the id + /// it asked with, or nothing for a notification. + static String unreachable(String body, Exception err) { + return errorUnderRequestId(body, "backend unreachable: " + err.getMessage()); + } + + /// The answer the host gets when the backend refused the HTTP request -- a 401 + /// for a missing or wrong token, most often. That refusal comes before the + /// server reads the JSON-RPC body, so its error carries a null id, and a host + /// waiting on its own id would never see the request complete. So it is + /// answered again under the id the host asked with; a notification gets + /// nothing, and a body that is no single request passes the server's through. + static String refused(String body, int status, String answer) { + Object request = parse(body); + if (!(request instanceof java.util.Map) && !(request instanceof java.util.List)) { + return answer; + } + String detail = answer == null ? "" : answer.trim(); + Object parsed = parse(detail); + if (parsed instanceof java.util.Map + && ((java.util.Map) parsed).get("error") instanceof java.util.Map) { + Object message = ((java.util.Map) ((java.util.Map) parsed).get("error")).get("message"); + detail = message == null ? "" : String.valueOf(message); + } + if (detail.length() > 200) { + detail = detail.substring(0, 200); + } + return errorUnderRequestId(body, "backend answered HTTP " + status + + (detail.length() > 0 ? ": " + detail : "")); + } + + /// An error under the TOP-LEVEL id of `body`, or null when it has none -- + /// a notification, which is never answered. Parsed, not searched: a pattern + /// took the first "id" anywhere, so a tool argument named id got the answer + /// and the host waited forever. + /// + /// A batch gets one error per element that has an id, as a batch answer: the + /// host is waiting on each of them, and a single error under no id -- or + /// nothing at all -- would leave every one incomplete. + static String errorUnderRequestId(String body, String message) { + Object request = parse(body); + if (request instanceof java.util.List) { + java.util.List answers = new java.util.ArrayList(); + for (Object element : (java.util.List) request) { + if (element instanceof java.util.Map + && ((java.util.Map) element).containsKey("id")) { + answers.add(errorFor(((java.util.Map) element).get("id"), message)); + } + } + return answers.isEmpty() ? null : com.codename1.backend.Json.write(answers); + } + if (!(request instanceof java.util.Map) + || !((java.util.Map) request).containsKey("id")) { + return null; + } + return com.codename1.backend.Json.write(errorFor(((java.util.Map) request).get("id"), + message)); + } + + private static java.util.Map errorFor(Object id, String message) { + java.util.Map error = new java.util.LinkedHashMap(); + error.put("code", Integer.valueOf(-32000)); + error.put("message", message); + java.util.Map response = new java.util.LinkedHashMap(); + response.put("jsonrpc", "2.0"); + response.put("id", id); + response.put("error", error); + return response; + } + + private static Object parse(String text) { + try { + return com.codename1.backend.Json.parse(text); + } catch (IOException parseErr) { + return null; + } catch (RuntimeException parseErr) { + return null; + } + } +} diff --git a/vm/backend/impl/parparvm/com/codename1/backend/DevConsole.java b/vm/backend/impl/parparvm/com/codename1/backend/DevConsole.java new file mode 100644 index 00000000000..c79a7fddce4 --- /dev/null +++ b/vm/backend/impl/parparvm/com/codename1/backend/DevConsole.java @@ -0,0 +1,45 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.util.ArrayList; +import java.util.List; + +/// The packaged runtime's half of [DevConsole]: it cannot redirect the +/// process's console, so it captures nothing and says so. The development tools +/// are for `cn1:backend`, which runs on the Java SE arm. +public final class DevConsole { + private DevConsole() { + } + + public static boolean supported() { + return false; + } + + public static void install() { + } + + public static List recent(int limit) { + return new ArrayList(); + } +} diff --git a/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java b/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java index c396a619702..406dab9c07a 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java +++ b/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java @@ -93,6 +93,28 @@ public void close() { } } + /// A non-blocking pipe, `{readEnd, writeEnd`}, for waking a thread that + /// polls: register the read end, and [#wake] the write end from any + /// thread. Null where there is none (Windows, which has no virtual threads to + /// wake either). + public static int[] createWakePipe() { + int[] fds = new int[2]; + return createWakePipeImpl(fds) == 0 ? fds : null; + } + + /// Wakes whoever polls the pipe's read end. Safe from any thread. + public static void wake(int writeFd) { + wakeImpl(writeFd); + } + + /// Empties the pipe once its poller has woken. + public static void drainWake(int readFd) { + drainWakeImpl(readFd); + } + + private static native int createWakePipeImpl(int[] out); + private static native void wakeImpl(int fd); + private static native void drainWakeImpl(int fd); private static native int createImpl(); private static native int registerImpl(int poller, int fd, int events, boolean modify); private static native int unregisterImpl(int poller, int fd); diff --git a/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java b/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java index 1119e1198b4..e27b6f72a3c 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java +++ b/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java @@ -50,6 +50,18 @@ public static long create(int fd, int stackBytes) { return createImpl(fd, stackBytes); } + /// A virtual thread that runs a background task: when first resumed it calls + /// `Tasks.runVirtual(token)`. It has no descriptor -- + /// [#descriptorOf] answers -1 -- so its host runs it from the ring and + /// never parks it on the poller. + /// + /// #### Returns + /// + /// a handle, or 0 if the stack could not be allocated + public static long createTask(long token, int stackBytes) { + return createTaskImpl(token, stackBytes); + } + /// [#resume]: the connection is done and the handle should be freed. public static final int FINISHED = 0; /// [#resume]: waiting for bytes; its descriptor goes back to the poller. @@ -61,12 +73,38 @@ public static long create(int fd, int stackBytes) { /// poller instead would wait for a client that is waiting for the response /// this virtual thread owes it, and the connection would hang for ever. public static final int RUNNABLE = 2; + /// [#resume]: parked on an OUTBOUND descriptor -- a database socket, a TLS + /// peer, an HTTP call -- rather than on its own. The host registers the + /// descriptors [#waitCount] names with its poller and resumes it when one is + /// ready or [#waitTimeout] runs out, running other virtual threads meanwhile. + public static final int WAITING = 3; - /// Run it until it parks, yields or finishes. One of the three constants. + /// Run it until it parks, yields or finishes. One of the four constants. public static int resume(long handle) { return resumeImpl(handle); } + /// How many descriptors a virtual thread that answered [#WAITING] waits on; + /// 0 for none, which is a wait on the timeout alone. + public static int waitCount(long handle) { + return waitCountImpl(handle); + } + + /// The `index`-th descriptor a [#WAITING] virtual thread waits on. + public static int waitDescriptor(long handle, int index) { + return waitDescriptorImpl(handle, index); + } + + /// What it waits for on that descriptor: [Reactor#READ], [Reactor#WRITE] or both. + public static int waitEvents(long handle, int index) { + return waitEventsImpl(handle, index); + } + + /// How long it is willing to wait, in milliseconds from now, or -1 for no bound. + public static long waitTimeout(long handle) { + return waitTimeoutImpl(handle); + } + /// The descriptor this virtual thread serves, or -1. public static int descriptorOf(long handle) { return descriptorImpl(handle); @@ -87,6 +125,13 @@ public static void yieldNow() { yieldImpl(); } + /// The handle of the virtual thread the caller runs on, or 0 on a host or + /// platform thread. What a waiter hands its host so it can nap rather than + /// be resumed again at once. + public static long current() { + return currentImpl(); + } + /// Whether the caller is running on a virtual thread rather than a host thread. public static boolean isVirtual() { return isVirtualImpl(); @@ -101,10 +146,16 @@ public static boolean supported() { } private static native long createImpl(int fd, int stackBytes); + private static native long createTaskImpl(long token, int stackBytes); private static native int resumeImpl(long handle); + private static native int waitCountImpl(long handle); + private static native int waitDescriptorImpl(long handle, int index); + private static native int waitEventsImpl(long handle, int index); + private static native long waitTimeoutImpl(long handle); private static native int descriptorImpl(long handle); private static native void freeImpl(long handle); private static native boolean isVirtualImpl(); + private static native long currentImpl(); private static native boolean supportedImpl(); private static native void yieldImpl(); private static native void reportImpl(); diff --git a/vm/backend/native/cn1_backend_net.c b/vm/backend/native/cn1_backend_net.c index 825e262b778..2b398288f98 100644 --- a/vm/backend/native/cn1_backend_net.c +++ b/vm/backend/native/cn1_backend_net.c @@ -35,6 +35,15 @@ * Every blocking call is bracketed with CN1_YIELD_THREAD / CN1_RESUME_THREAD. * Without that, a thread parked in recv() is a thread the concurrent collector * cannot mark past, so one idle connection would stall GC for the whole process. + * + * THE DESCRIPTOR IS NON-BLOCKING, and every wait goes through cn1BackendAwaitFd. + * On a virtual thread that PARKS: the host registers the descriptor with its own + * poller and runs other virtual threads until it is ready, which is what makes a + * slow database query cost one virtual thread rather than one host -- and there + * is one host per core. On any other thread the same call polls, which holds the + * thread exactly as the blocking recv() it replaced did. A pooled connection is + * opened on one kind of thread and used on another, so the mode is the + * descriptor's for its whole life and the wait is chosen per call. */ #include "cn1_globals.h" #include @@ -56,6 +65,12 @@ typedef int cn1_socklen; #include #define CN1_CLOSE_SOCKET close typedef socklen_t cn1_socklen; + +/* Defined in cn1_backend_server.c, beside the virtual-thread scheduler. */ +#define CN1_NET_EVENT_READ 1 +#define CN1_NET_EVENT_WRITE 2 +int cn1BackendAwaitFd(int fd, int events, long long deadline); +long long cn1BackendDeadline(long long millis); #endif static int cn1BackendFd(JAVA_LONG handle) { @@ -94,8 +109,9 @@ static int cn1ConnectPending(void) { * misconfiguration looks like a slow start in development and a hung process in * production. * - * The socket goes back to blocking before returning: every read and write after - * this expects that. The deadline is per address, so a host resolving to several + * On Windows the socket goes back to blocking before returning, which its reads + * and writes expect; everywhere else it stays non-blocking, as the reads and + * writes there expect (see the top of this file). The deadline is per address, so a host resolving to several * can take the timeout once for each -- which is the point, since the reachable * one is usually not the first. */ @@ -127,6 +143,38 @@ static void cn1IgnoreSigPipe(void) { } #endif +#ifndef _WIN32 +/* + * The same, for every other platform: a non-blocking connect whose wait parks a + * virtual thread and polls anywhere else, with no deadline when none was asked + * for. The descriptor is LEFT non-blocking -- the reads and writes below expect + * that -- where the Windows arm puts it back. + */ +static int cn1ConnectWithTimeout(int fd, const struct sockaddr* addr, cn1_socklen len, + int timeoutMillis) { + int err = 0; + cn1_socklen errLen = (cn1_socklen)sizeof(err); + int ready; + if(cn1SetNonBlocking(fd, 1) != 0) { + return -1; + } + if(connect(fd, addr, len) == 0) { + return 0; + } + if(!cn1ConnectPending()) { + return -1; + } + ready = cn1BackendAwaitFd(fd, CN1_NET_EVENT_WRITE, + cn1BackendDeadline((long long)timeoutMillis)); + if(ready <= 0) { + return -1; /* timed out, or the wait itself failed */ + } + if(getsockopt(fd, SOL_SOCKET, SO_ERROR, (char*)&err, &errLen) != 0 || err != 0) { + return -1; + } + return 0; +} +#else static int cn1ConnectWithTimeout(int fd, const struct sockaddr* addr, cn1_socklen len, int timeoutMillis) { int err = 0; @@ -178,6 +226,7 @@ static int cn1ConnectWithTimeout(int fd, const struct sockaddr* addr, cn1_sockle cn1SetNonBlocking(fd, 0); return 0; } +#endif JAVA_LONG com_codename1_backend_Tcp_connectImpl___java_lang_String_int_int_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT host, JAVA_INT port, JAVA_INT timeoutMillis) { struct addrinfo hints; @@ -297,24 +346,63 @@ JAVA_INT com_codename1_backend_Tcp_readImpl___long_byte_1ARRAY_int_int_R_int(COD return -2; } data = (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer); +#ifdef _WIN32 + CN1_YIELD_THREAD; + n = (long)recv(fd, (char*)&data[offset], (size_t)length, 0); + CN1_RESUME_THREAD; +#else /* - * This blocks, and on a virtual thread it blocks the HOST. + * PARKS on a virtual thread rather than blocking its host. The descriptor is + * non-blocking, so recv() answers EAGAIN when nothing has arrived, and the + * wait goes to cn1BackendAwaitFd: the host registers this descriptor with its + * poller and resumes this virtual thread when it is readable, running every + * other one meanwhile. Before this, as many slow database reads as there are + * hosts -- one per core -- stopped the server answering anything at all. * - * CN1_YIELD_THREAD releases the thread to the COLLECTOR; it is not a park. The - * server's own readImpl parks on EAGAIN because its descriptor is registered in - * a host's poller, which is what resumes it. An outbound socket is in no poller, - * so there is nothing to wake it and yielding here would spin. + * SO_RCVTIMEO, which setReadTimeoutImpl sets, no longer bounds a non-blocking + * recv, so it is read back here and becomes the wait's deadline: running out + * answers -2, as the blocking recv timing out did. * - * The consequence is real: with one host per core, as many concurrent slow - * database reads as there are cores occupy every host, and unrelated HTTP - * connections stop being served. Making this park means giving outbound - * descriptors the same poller registration inbound ones have -- a scheduler - * feature, not a local change -- and until that exists the guide says so under - * "Limits worth knowing" rather than the mode quietly not holding. + * The data pointer is taken again after every wait. The array is an ordinary + * Java object and the wait is where the collector can run, as the inbound + * readImpl says; this reads into the CALLER'S array, never into a host's + * shared buffer, so another virtual thread resumed meanwhile cannot touch it. */ - CN1_YIELD_THREAD; - n = (long)recv(fd, (char*)&data[offset], (size_t)length, 0); - CN1_RESUME_THREAD; + { + long long deadline = 0; + int timed = 0; + CN1_YIELD_THREAD; + for(;;) { + int ready; + n = (long)recv(fd, (char*)&data[offset], (size_t)length, 0); + if(n >= 0) { + break; + } + if(errno == EINTR) { + continue; + } + if(errno != EAGAIN && errno != EWOULDBLOCK) { + break; + } + if(!timed) { + struct timeval tv; + cn1_socklen tvLen = (cn1_socklen)sizeof(tv); + timed = 1; + if(getsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, &tvLen) == 0) { + deadline = cn1BackendDeadline((long long)tv.tv_sec * 1000LL + + (long long)(tv.tv_usec / 1000)); + } + } + ready = cn1BackendAwaitFd(fd, CN1_NET_EVENT_READ, deadline); + data = (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer); + if(ready <= 0) { + n = -1; + break; + } + } + CN1_RESUME_THREAD; + } +#endif if(n == 0) { return -1; /* orderly shutdown by the peer */ } @@ -333,6 +421,45 @@ JAVA_INT com_codename1_backend_Tcp_writeImpl___long_byte_1ARRAY_int_int_R_int(CO } data = (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer); CN1_YIELD_THREAD; +#ifndef _WIN32 + { + /* The send side of the read above: a full send buffer is EAGAIN on this + non-blocking descriptor, and the wait parks a virtual thread until the + peer drains it, bounded by SO_SNDTIMEO as the blocking send was. */ + long long deadline = 0; + int timed = 0; + while(written < length) { + long n = (long)send(fd, (const char*)&data[offset + written], + (size_t)(length - written), CN1_OUT_SEND_FLAGS); + if(n > 0) { + written += (JAVA_INT)n; + continue; + } + if(n < 0 && errno == EINTR) { + continue; + } + if(n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + int ready; + if(!timed) { + struct timeval tv; + cn1_socklen tvLen = (cn1_socklen)sizeof(tv); + timed = 1; + if(getsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, &tvLen) == 0) { + deadline = cn1BackendDeadline((long long)tv.tv_sec * 1000LL + + (long long)(tv.tv_usec / 1000)); + } + } + ready = cn1BackendAwaitFd(fd, CN1_NET_EVENT_WRITE, deadline); + data = (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer); + if(ready > 0) { + continue; + } + } + CN1_RESUME_THREAD; + return -1; + } + } +#else /* send() may accept less than asked; loop so the Java side can treat a short write as a hard failure rather than having to retry it itself. */ while(written < length) { @@ -344,6 +471,7 @@ JAVA_INT com_codename1_backend_Tcp_writeImpl___long_byte_1ARRAY_int_int_R_int(CO } written += (JAVA_INT)n; } +#endif CN1_RESUME_THREAD; return written; } diff --git a/vm/backend/native/cn1_backend_server.c b/vm/backend/native/cn1_backend_server.c index f3fa8f66898..0227823df11 100644 --- a/vm/backend/native/cn1_backend_server.c +++ b/vm/backend/native/cn1_backend_server.c @@ -621,6 +621,12 @@ JAVA_INT com_codename1_backend_ServerSocket_awaitReadableImpl___int_int_R_int(CO // served, then nothing, with no thread in epoll_pwait, five in futex, // five in nanosleep, and the process at 7% CPU. CN1_YIELD_THREAD; + // SAID, not assumed. The reason is sticky: a collector-backpressure yield + // leaves it RUNNABLE, and the host's reset before resume runs where no + // virtual thread is current and so changes nothing. Left unsaid, this + // park was reported runnable after any such yield, and the host spun + // resuming a connection with no bytes to read. + cn1VirtualThreadSetYieldReason(CN1_VT_YIELD_IO); cn1VirtualThreadYield(); CN1_RESUME_THREAD; return 1; @@ -708,6 +714,8 @@ JAVA_INT com_codename1_backend_ServerSocket_readImpl___int_byte_1ARRAY_int_int_R // The array may MOVE while we are parked -- a collection can run, and the // buffer is an ordinary Java object -- so re-read the data pointer after // every resume rather than trusting the one taken before the park. + // Classified explicitly, for the reason given at the keep-alive park. + cn1VirtualThreadSetYieldReason(CN1_VT_YIELD_IO); cn1VirtualThreadYield(); data = (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer); } @@ -1041,6 +1049,65 @@ JAVA_INT com_codename1_backend_Reactor_unregisterImpl___int_int_R_int(CODENAME_O * is idle, which is most of its life -- without it the collector could not mark * past the reactor thread. */ +/* + * A pipe a host thread polls alongside its connections, so another thread can + * wake it: a background task handed to a virtual-thread host would otherwise + * wait out the host's poll timeout before it ran. Both ends non-blocking -- + * a full pipe already means the host will wake, so a write that would block is + * simply dropped -- and close-on-exec. + */ +JAVA_INT com_codename1_backend_Reactor_createWakePipeImpl___int_1ARRAY_R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { +#ifdef _WIN32 + (void)out; + return -1; +#else + int fds[2]; + int i; + JAVA_ARRAY arr; + if(out == JAVA_NULL || ((JAVA_ARRAY)out)->length < 2) { + return -1; + } + if(pipe(fds) != 0) { + return -1; + } + for(i = 0 ; i < 2 ; i++) { + int flags = fcntl(fds[i], F_GETFL, 0); + fcntl(fds[i], F_SETFL, flags | O_NONBLOCK); + fcntl(fds[i], F_SETFD, FD_CLOEXEC); + } + arr = (JAVA_ARRAY)out; + ((JAVA_ARRAY_INT*)CN1_ARRAY_DATA(arr))[0] = fds[0]; + ((JAVA_ARRAY_INT*)CN1_ARRAY_DATA(arr))[1] = fds[1]; + return 0; +#endif +} + +/* One byte down the pipe; EAGAIN means it is full, which is as awake as it gets. */ +JAVA_VOID com_codename1_backend_Reactor_wakeImpl___int(CODENAME_ONE_THREAD_STATE, JAVA_INT fd) { +#ifndef _WIN32 + char b = 1; + ssize_t n; + do { + n = write(fd, &b, 1); + } while(n < 0 && errno == EINTR); +#else + (void)fd; +#endif +} + +/* Empties the pipe after a wake-up, so the next poll does not report it again. */ +JAVA_VOID com_codename1_backend_Reactor_drainWakeImpl___int(CODENAME_ONE_THREAD_STATE, JAVA_INT fd) { +#ifndef _WIN32 + char buffer[64]; + ssize_t n; + do { + n = read(fd, buffer, sizeof(buffer)); + } while(n > 0 || (n < 0 && errno == EINTR)); +#else + (void)fd; +#endif +} + JAVA_INT com_codename1_backend_Reactor_waitImpl___int_int_1ARRAY_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_INT poller, JAVA_OBJECT readyFds, JAVA_INT timeoutMillis) { JAVA_ARRAY arr; JAVA_ARRAY_INT* out; @@ -1142,10 +1209,37 @@ JAVA_INT com_codename1_backend_Reactor_waitImpl___int_int_1ARRAY_int_R_int(CODEN * named in native sources as used, and nothing in Java calls this one. */ extern JAVA_VOID com_codename1_backend_HttpServer_serveVirtual___int(CODENAME_ONE_THREAD_STATE, JAVA_INT fd); +/* The most descriptors one wait can name. A database or TLS socket needs one; + * libcurl can have a second open while it races an IPv4 and an IPv6 connect. */ +#define CN1_BACKEND_VT_MAX_WAIT 8 + struct cn1BackendVtArg { JAVA_INT fd; + /* For a background task's virtual thread: the key Tasks.runVirtual finds the + * task by. The fd is then -1, which is how the host tells the two apart. */ + JAVA_LONG token; + /* + * An OUTBOUND wait: descriptors that are not the one this virtual thread + * serves -- a database socket, a TLS peer, libcurl's -- and what it waits for + * on each. Written by the virtual thread just before it parks and read by its + * host just after, on the same OS thread, so no publication is involved; the + * virtual thread clears `waiting` as soon as it is resumed. Kept here, per + * virtual thread, rather than in anything __thread: every virtual thread on a + * host shares that host's thread-local storage. + */ + int waiting; + int waitCount; + int waitFd[CN1_BACKEND_VT_MAX_WAIT]; + int waitEvents[CN1_BACKEND_VT_MAX_WAIT]; + /* Relative, in milliseconds, or -1 for none. Relative because the host keeps + * its deadlines on its own clock. */ + JAVA_LONG waitTimeout; }; +/* The Java entry point a background task's virtual thread runs; named here for + * the same reason serveVirtual is -- nothing in Java calls it. */ +extern JAVA_VOID com_codename1_backend_Tasks_runVirtual___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG token); + extern void markDeadThread(struct ThreadLocalData* d); static void cn1BackendVtBody(void* arg) { @@ -1209,6 +1303,19 @@ static void cn1VtReport(const char* why) { atomic_load(&cn1VtCreated) - atomic_load(&cn1VtFreed)); } +/* + * A background task's body: the same shape as a connection's, with the task + * looked up by token instead of a descriptor served. threadActive is cleared at + * the end for the reason given above, and the state is retired by freeImpl on + * the host once the switch back has happened. + */ +static void cn1BackendVtTaskBody(void* arg) { + struct cn1BackendVtArg* a = (struct cn1BackendVtArg*)arg; + struct ThreadLocalData* mine = getThreadLocalData(); + com_codename1_backend_Tasks_runVirtual___long(mine, a->token); + mine->threadActive = JAVA_FALSE; +} + JAVA_VOID com_codename1_backend_VirtualThread_reportImpl__(CODENAME_ONE_THREAD_STATE) { cn1VtReport("report"); } @@ -1220,7 +1327,11 @@ JAVA_LONG com_codename1_backend_VirtualThread_createImpl___int_int_R_long(CODENA if(a == 0) { return 0; } + /* malloc, not calloc, so every field is said: a stale `waiting` would have + * the host register descriptors this thread never asked for. */ + memset(a, 0, sizeof(struct cn1BackendVtArg)); a->fd = fd; + a->token = 0; vt = cn1SpawnVirtualThread(cn1BackendVtBody, a, (size_t)stackBytes); if(vt == 0) { static int reported = 0; @@ -1236,6 +1347,28 @@ JAVA_LONG com_codename1_backend_VirtualThread_createImpl___int_int_R_long(CODENA return (JAVA_LONG)(intptr_t)vt; } +/* A virtual thread for a background task rather than a connection. It has no + * descriptor (descriptorOf answers -1), so the host runs it from its ring and + * never hands it to the poller. 0 when no stack could be had. */ +JAVA_LONG com_codename1_backend_VirtualThread_createTaskImpl___long_int_R_long(CODENAME_ONE_THREAD_STATE, JAVA_LONG token, JAVA_INT stackBytes) { + struct cn1BackendVtArg* a; + struct cn1VirtualThread* vt; + a = (struct cn1BackendVtArg*)malloc(sizeof(struct cn1BackendVtArg)); + if(a == 0) { + return 0; + } + memset(a, 0, sizeof(struct cn1BackendVtArg)); + a->fd = -1; + a->token = token; + vt = cn1SpawnVirtualThread(cn1BackendVtTaskBody, a, (size_t)stackBytes); + if(vt == 0) { + free(a); + return 0; + } + atomic_fetch_add(&cn1VtCreated, 1); + return (JAVA_LONG)(intptr_t)vt; +} + /* True once the connection is done with. False means it parked and is waiting * for its descriptor to become readable again. */ /* @@ -1258,9 +1391,179 @@ JAVA_INT com_codename1_backend_VirtualThread_resumeImpl___long_R_int(CODENAME_ON atomic_fetch_add(&cn1VtFinished, 1); return 0; } - return cn1VirtualThreadYieldReason(vt) == CN1_VT_YIELD_RUNNABLE ? 2 : 1; + if(cn1VirtualThreadYieldReason(vt) == CN1_VT_YIELD_RUNNABLE) { + return 2; + } + { + /* 3: parked on an OUTBOUND descriptor, which the host registers from the + * wait record rather than re-arming the connection's own. */ + struct cn1BackendVtArg* a = (struct cn1BackendVtArg*)cn1VirtualThreadArg(vt); + if(a != 0 && a->waiting) { + return 3; + } + } + return 1; } +static struct cn1BackendVtArg* cn1BackendWaitRecord(JAVA_LONG handle) { + struct cn1VirtualThread* vt = (struct cn1VirtualThread*)(intptr_t)handle; + struct cn1BackendVtArg* a; + if(vt == 0) { + return 0; + } + a = (struct cn1BackendVtArg*)cn1VirtualThreadArg(vt); + return a != 0 && a->waiting ? a : 0; +} + +/* How many descriptors a virtual thread that answered WAITING is waiting on. */ +JAVA_INT com_codename1_backend_VirtualThread_waitCountImpl___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle) { + struct cn1BackendVtArg* a = cn1BackendWaitRecord(handle); + return a == 0 ? 0 : a->waitCount; +} + +JAVA_INT com_codename1_backend_VirtualThread_waitDescriptorImpl___long_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle, JAVA_INT index) { + struct cn1BackendVtArg* a = cn1BackendWaitRecord(handle); + if(a == 0 || index < 0 || index >= a->waitCount) { + return -1; + } + return a->waitFd[index]; +} + +JAVA_INT com_codename1_backend_VirtualThread_waitEventsImpl___long_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle, JAVA_INT index) { + struct cn1BackendVtArg* a = cn1BackendWaitRecord(handle); + if(a == 0 || index < 0 || index >= a->waitCount) { + return 0; + } + return a->waitEvents[index]; +} + +JAVA_LONG com_codename1_backend_VirtualThread_waitTimeoutImpl___long_R_long(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle) { + struct cn1BackendVtArg* a = cn1BackendWaitRecord(handle); + return a == 0 ? -1 : a->waitTimeout; +} + +/* + * Parks the calling virtual thread until one of `fds` is ready for its events + * (CN1_EVENT_READ / CN1_EVENT_WRITE), or `timeoutMillis` passes (-1: no bound). + * + * This is what lets OUTBOUND I/O -- a database socket, a TLS peer, an HTTP call -- + * give the host away instead of holding it in recv(). The host registers these + * descriptors with its own poller for exactly as long as this wait lasts and + * removes them before resuming, so a descriptor is never in a poller while the + * virtual thread could close it. + * + * Returns 1 once resumed, which means "look again", never "ready": a deadline or + * a stop can resume it too, and the caller re-tests with a zero-timeout poll. + * Returns 0, doing nothing, when the caller is not a virtual thread -- the caller + * then waits the way it always did. Call it inside CN1_YIELD_THREAD, as the + * inbound park is: a parked virtual thread cannot answer the collector. + */ +int cn1BackendVtWait(int count, const int* fds, const int* events, long long timeoutMillis) { + struct cn1VirtualThread* vt = cn1VirtualThreadCurrent(); + struct cn1BackendVtArg* a; + int i; + if(vt == 0) { + return 0; + } + a = (struct cn1BackendVtArg*)cn1VirtualThreadArg(vt); + if(a == 0 || count < 0 || count > CN1_BACKEND_VT_MAX_WAIT) { + return 0; + } + if(count == 0 && timeoutMillis < 0) { + /* Nothing to wait for and no time to wait until: a turn, not a park. */ + cn1VirtualThreadSetYieldReason(CN1_VT_YIELD_RUNNABLE); + cn1VirtualThreadYield(); + return 1; + } + for(i = 0 ; i < count ; i++) { + a->waitFd[i] = fds[i]; + a->waitEvents[i] = events[i]; + } + a->waitCount = count; + a->waitTimeout = (JAVA_LONG)timeoutMillis; + a->waiting = 1; + /* SAID, not assumed: the reason is sticky, as at the keep-alive park. */ + cn1VirtualThreadSetYieldReason(CN1_VT_YIELD_IO); + cn1VirtualThreadYield(); + a->waiting = 0; + a->waitCount = 0; + return 1; +} + +#ifndef _WIN32 +/* Milliseconds on a clock that does not jump; what every deadline below is on. + * Windows has neither virtual threads nor these callers. */ +long long cn1BackendNowMillis(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long)ts.tv_sec * 1000LL + (long long)(ts.tv_nsec / 1000000L); +} + +/* A deadline `millis` from now on cn1BackendNowMillis's clock; 0 (none) for 0 or less. */ +long long cn1BackendDeadline(long long millis) { + return millis > 0 ? cn1BackendNowMillis() + millis : 0; +} + +/* + * Waits for ONE descriptor the way a blocking call would have, on whichever kind + * of thread the caller is: a virtual thread parks (cn1BackendVtWait) and its host + * runs other virtual threads meanwhile; anything else polls, holding its thread + * as the blocking call did. `deadline` is from cn1BackendDeadline, 0 for none. + * + * Returns 1 ready, 0 the deadline passed, -1 the wait itself failed. Call it + * inside CN1_YIELD_THREAD. + */ +int cn1BackendAwaitFd(int fd, int events, long long deadline) { + struct pollfd p; + int rc; + short want = 0; + if(events & CN1_EVENT_READ) { + want |= POLLIN; + } + if(events & CN1_EVENT_WRITE) { + want |= POLLOUT; + } + for(;;) { + long long remaining = -1; + if(deadline > 0) { + remaining = deadline - cn1BackendNowMillis(); + if(remaining < 0) { + remaining = 0; + } + } + p.fd = fd; + p.events = want; + p.revents = 0; + if(cn1VirtualThreadCurrent() != 0) { + /* Ask first: an answer that is already there costs no park, and a + park is a poller round trip plus a resume. */ + rc = poll(&p, 1, 0); + if(rc > 0) { + return 1; + } + if(rc < 0 && errno != EINTR) { + return -1; + } + if(deadline > 0 && remaining == 0) { + return 0; + } + cn1BackendVtWait(1, &fd, &events, remaining); + continue; + } + rc = poll(&p, 1, remaining > 0x7fffffffLL ? 0x7fffffff : (int)remaining); + if(rc > 0) { + return 1; + } + if(rc == 0) { + return 0; + } + if(errno != EINTR) { + return -1; + } + } +} +#endif + /* The descriptor this virtual thread serves. The run queue holds handles, and a * handle that comes back from the queue has to be matched to its slot again. */ JAVA_INT com_codename1_backend_VirtualThread_descriptorImpl___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG handle) { @@ -1358,6 +1661,12 @@ JAVA_BOOLEAN com_codename1_backend_VirtualThread_isVirtualImpl___R_boolean(CODEN return cn1VirtualThreadCurrent() != 0 ? JAVA_TRUE : JAVA_FALSE; } +/* The running virtual thread's handle -- the same pointer create() handed out -- + * or 0 on a host or platform thread. A waiter gives it to its host to nap. */ +JAVA_LONG com_codename1_backend_VirtualThread_currentImpl___R_long(CODENAME_ONE_THREAD_STATE) { + return (JAVA_LONG)(intptr_t)cn1VirtualThreadCurrent(); +} + /* * Give up the host thread without waiting for anything. * @@ -1375,6 +1684,11 @@ JAVA_VOID com_codename1_backend_VirtualThread_yieldImpl__(CODENAME_ONE_THREAD_ST // park is: a virtual thread that is not on a host cannot answer the // collector, and a collector waiting for it stops the whole server. CN1_YIELD_THREAD; + // RUNNABLE: it wants its next turn, not bytes. Left at the default this + // yield was reported as parked on I/O, so the host put the connection back + // on the poller -- where a handler waiting to respond waits for a client + // that is waiting for the response, and neither moves. + cn1VirtualThreadSetYieldReason(CN1_VT_YIELD_RUNNABLE); cn1VirtualThreadYield(); CN1_RESUME_THREAD; } diff --git a/vm/backend/native/cn1_backend_tls.c b/vm/backend/native/cn1_backend_tls.c index ed96de39159..9064119ef70 100644 --- a/vm/backend/native/cn1_backend_tls.c +++ b/vm/backend/native/cn1_backend_tls.c @@ -184,6 +184,10 @@ static long long cn1TlsNowMillis(void) { * stubbed -- so the reference has to disappear with the client's own TLS code, * which it does, both being inside CN1_BACKEND_NO_TLS. */ +/* Defined in cn1_backend_server.c: parks a virtual thread, polls anywhere else. */ +int cn1BackendAwaitFd(int fd, int events, long long deadline); +long long cn1BackendDeadline(long long millis); + int cn1BackendTlsHandshakeWithin(SSL* ssl, int fd, long long budgetMillis, int connecting) { long long deadline = cn1TlsNowMillis() + budgetMillis; @@ -201,16 +205,20 @@ int cn1BackendTlsHandshakeWithin(SSL* ssl, int fd, long long budgetMillis, for(;;) { int err; long long remaining; - struct pollfd p; int polled; - int pollErrno; CN1_YIELD_THREAD; + /* Emptied before every attempt: the error queue is per OS thread, and a + virtual thread that parked below shares its host's with every other + virtual thread that ran meanwhile -- SSL_get_error reads it first. */ + ERR_clear_error(); out = connecting ? SSL_connect(ssl) : SSL_accept(ssl); + /* Classified BEFORE the resume, which can hand the host to another + virtual thread (the collector's backpressure) and so to its errors. */ + err = out == 1 ? SSL_ERROR_NONE : SSL_get_error(ssl, out); CN1_RESUME_THREAD; if(out == 1) { break; } - err = SSL_get_error(ssl, out); if(err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE) { break; } @@ -219,22 +227,15 @@ int cn1BackendTlsHandshakeWithin(SSL* ssl, int fd, long long budgetMillis, out = 0; /* out of time: an unfinished handshake */ break; } - p.fd = fd; - p.events = (short)(err == SSL_ERROR_WANT_READ ? POLLIN : POLLOUT); - p.revents = 0; + /* A virtual thread PARKS here, on its host's poller, rather than holding + the host for the peer's round trips; any other thread polls as before. + Both honour the same budget, which the helper takes on its own clock. */ CN1_YIELD_THREAD; - polled = poll(&p, 1, (int)remaining); - /* Captured before the resume, which is a GC safepoint that can park this - thread on a timed wait and leave errno as ETIMEDOUT -- the same trap - cn1_backend_server.c documents at its own poll. */ - pollErrno = errno; + polled = cn1BackendAwaitFd(fd, err == SSL_ERROR_WANT_READ ? 1 : 2, + cn1BackendDeadline(remaining)); CN1_RESUME_THREAD; - if(polled == 0) { - out = 0; /* the budget expired inside the wait */ - break; - } - if(polled < 0 && pollErrno != EINTR) { - out = 0; + if(polled <= 0) { + out = 0; /* the budget expired inside the wait, or it failed */ break; } } diff --git a/vm/backend/native/cn1_backend_tlsclient.c b/vm/backend/native/cn1_backend_tlsclient.c index 00d719e2bec..3f7fa92cc2f 100644 --- a/vm/backend/native/cn1_backend_tlsclient.c +++ b/vm/backend/native/cn1_backend_tlsclient.c @@ -90,6 +90,39 @@ static long long cn1ClientTlsHandshakeBudget(void) { #if !defined(_WIN32) int cn1BackendTlsHandshakeWithin(SSL* ssl, int fd, long long budgetMillis, int connecting); +/* Defined in cn1_backend_server.c: parks a virtual thread, polls anywhere else. */ +int cn1BackendAwaitFd(int fd, int events, long long deadline); +long long cn1BackendDeadline(long long millis); + +/* + * The wait behind an SSL_read or SSL_write that answered WANT_READ/WANT_WRITE. + * + * The descriptor is non-blocking -- cn1_backend_net.c leaves every outbound one + * that way -- so OpenSSL hands the wait back rather than blocking in it, and this + * is where it happens: parked on the host's poller on a virtual thread, polled + * anywhere else. `which` is the socket option whose deadline bounds it + * (SO_RCVTIMEO or SO_SNDTIMEO, which Tcp.setReadTimeout sets together), so a + * stalled peer still fails the call after the time a blocking read allowed. + * `*deadline` starts at -1 and is resolved on the first wait, so a call that + * never waits never asks. Returns 1 to retry, 0 to give up. + */ +static int cn1ClientTlsAwait(SSL* ssl, int rc, int which, long long* deadline) { + int err = SSL_get_error(ssl, rc); + int fd = SSL_get_fd(ssl); + if(err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE) { + return 0; + } + if(*deadline < 0) { + struct timeval tv; + socklen_t tvLen = (socklen_t)sizeof(tv); + *deadline = 0; + if(getsockopt(fd, SOL_SOCKET, which, (char*)&tv, &tvLen) == 0) { + *deadline = cn1BackendDeadline((long long)tv.tv_sec * 1000LL + + (long long)(tv.tv_usec / 1000)); + } + } + return cn1BackendAwaitFd(fd, err == SSL_ERROR_WANT_READ ? 1 : 2, *deadline) > 0; +} #endif static int cn1ClientTlsInitialised = 0; @@ -373,6 +406,10 @@ JAVA_LONG com_codename1_backend_Tcp_startTlsImpl___long_java_lang_String_java_la return 0; } SSL_set_fd(ssl, fd); + /* A write that answers WANT_WRITE is retried after a park, and the Java array + it reads from is looked up again then: the retry must not be refused for + arriving with a pointer OpenSSL has not seen before. */ + SSL_set_mode(ssl, SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); SSL_set_tlsext_host_name(ssl, h); /* The name check. Without it a valid certificate for any other host would * pass, which is most of what TLS is for here. @@ -415,13 +452,12 @@ JAVA_LONG com_codename1_backend_Tcp_startTlsImpl___long_java_lang_String_java_la /* The helper yields around its own syscalls, as the loop below the budget check does. */ long long budget = cn1ClientTlsHandshakeBudget(); - if(budget > 0) { - rc = cn1BackendTlsHandshakeWithin(ssl, SSL_get_fd(ssl), budget, 1); - } else { - CN1_YIELD_THREAD; - rc = SSL_connect(ssl); - CN1_RESUME_THREAD; - } + /* ALWAYS the waiting loop, even unbounded: the descriptor is + non-blocking (cn1_backend_net.c leaves it so), and a bare SSL_connect + on it answers WANT_READ at the first round trip. "No bound" is a + budget of about 35 years rather than a second code path. */ + rc = cn1BackendTlsHandshakeWithin(ssl, SSL_get_fd(ssl), + budget > 0 ? budget : (1LL << 40), 1); } #else CN1_YIELD_THREAD; @@ -459,7 +495,23 @@ JAVA_INT com_codename1_backend_Tcp_tlsReadImpl___long_byte_1ARRAY_int_int_R_int( } data = (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer); CN1_YIELD_THREAD; +#if !defined(_WIN32) + { + long long deadline = -1; + for(;;) { + /* Per attempt: the error queue is the host thread's, and other + virtual threads ran on it while this one was parked. */ + ERR_clear_error(); + n = SSL_read(ssl, (char*)&data[offset], (int)length); + if(n > 0 || !cn1ClientTlsAwait(ssl, n, SO_RCVTIMEO, &deadline)) { + break; + } + data = (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer); + } + } +#else n = SSL_read(ssl, (char*)&data[offset], (int)length); +#endif CN1_RESUME_THREAD; if(n > 0) { return (JAVA_INT)n; @@ -484,7 +536,21 @@ JAVA_INT com_codename1_backend_Tcp_tlsWriteImpl___long_byte_1ARRAY_int_int_R_int while(written < (int)length) { int n; CN1_YIELD_THREAD; +#if !defined(_WIN32) + { + long long deadline = -1; + for(;;) { + ERR_clear_error(); + n = SSL_write(ssl, (char*)&data[offset + written], (int)length - written); + if(n > 0 || !cn1ClientTlsAwait(ssl, n, SO_SNDTIMEO, &deadline)) { + break; + } + data = (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(buffer); + } + } +#else n = SSL_write(ssl, (char*)&data[offset + written], (int)length - written); +#endif CN1_RESUME_THREAD; if(n <= 0) { return -2; diff --git a/vm/backend/native/cn1_backend_web.c b/vm/backend/native/cn1_backend_web.c index 991b8e5e6c8..34203f25ed4 100644 --- a/vm/backend/native/cn1_backend_web.c +++ b/vm/backend/native/cn1_backend_web.c @@ -36,14 +36,179 @@ * that ships enabled. */ #include "cn1_globals.h" +#include "cn1_virtual_thread.h" #include #include #include #ifndef _WIN32 #include /* CN1_RESUME_THREAD expands to usleep */ +#include #endif #include +#ifndef _WIN32 +/* Defined in cn1_backend_server.c, beside the virtual-thread scheduler. */ +int cn1BackendVtWait(int count, const int* fds, const int* events, long long timeoutMillis); + +/* The most sockets one transfer holds at once -- two while libcurl races an IPv4 + * and an IPv6 connect, plus its resolver's wake-up pair. The same bound as a + * virtual thread's wait record. */ +#define CN1_WEB_MAX_SOCKETS 8 + +/* What libcurl has asked to be told about, kept by its socket and timer + * callbacks. Lives on the calling virtual thread's own stack for one transfer. */ +typedef struct { + curl_socket_t fd[CN1_WEB_MAX_SOCKETS]; + int what[CN1_WEB_MAX_SOCKETS]; + int count; + long timeoutMillis; +} CN1WebWatch; + +static int cn1WebSocketCallback(CURL* easy, curl_socket_t s, int what, void* userp, + void* socketp) { + CN1WebWatch* w = (CN1WebWatch*)userp; + int i; + (void)easy; (void)socketp; + for(i = 0 ; i < w->count ; i++) { + if(w->fd[i] == s) { + break; + } + } + if(what == CURL_POLL_REMOVE) { + if(i < w->count) { + w->count--; + w->fd[i] = w->fd[w->count]; + w->what[i] = w->what[w->count]; + } + return 0; + } + if(i == w->count) { + if(w->count >= CN1_WEB_MAX_SOCKETS) { + return -1; /* libcurl fails the transfer rather than losing one */ + } + w->fd[i] = s; + w->count++; + } + w->what[i] = what; + return 0; +} + +static int cn1WebTimerCallback(CURLM* multi, long timeoutMillis, void* userp) { + (void)multi; + ((CN1WebWatch*)userp)->timeoutMillis = timeoutMillis; + return 0; +} + +/* + * curl_easy_perform for a VIRTUAL thread: the same transfer, driven through the + * multi interface so that every wait parks this virtual thread on its host's + * poller instead of blocking the host inside libcurl's own poll(). A call to a + * slow API from a handler then costs one virtual thread, not one of the hosts -- + * of which there is one per core, so a handful of slow calls used to stop the + * server answering anything. + * + * libcurl reports which sockets it wants watched, and for what, through the + * socket callback, and when it next needs a turn regardless through the timer + * callback; the wait asks the host for exactly that, and afterwards a zero-time + * poll says which socket woke it, so libcurl is told precisely what happened. + * Everything the transfer does -- the connect and CONNECTTIMEOUT, TLS, redirects, + * LOW_SPEED_TIME -- is libcurl's own logic unchanged. Falls back to the blocking + * call when the multi handle cannot be had. + */ +static CURLcode cn1WebPerformParked(CURL* curl) { + CURLM* multi = curl_multi_init(); + CN1WebWatch watch; + CURLcode rc = CURLE_FAILED_INIT; + CURLMsg* msg; + int running = 0; + int left; + if(multi == NULL) { + return curl_easy_perform(curl); + } + memset(&watch, 0, sizeof(watch)); + watch.timeoutMillis = -1; + curl_multi_setopt(multi, CURLMOPT_SOCKETFUNCTION, cn1WebSocketCallback); + curl_multi_setopt(multi, CURLMOPT_SOCKETDATA, &watch); + curl_multi_setopt(multi, CURLMOPT_TIMERFUNCTION, cn1WebTimerCallback); + curl_multi_setopt(multi, CURLMOPT_TIMERDATA, &watch); + if(curl_multi_add_handle(multi, curl) != CURLM_OK) { + curl_multi_cleanup(multi); + return curl_easy_perform(curl); + } + curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &running); + while(running) { + int fds[CN1_WEB_MAX_SOCKETS]; + int events[CN1_WEB_MAX_SOCKETS]; + struct pollfd probe[CN1_WEB_MAX_SOCKETS]; + int n = watch.count; + int i; + int ready = 0; + long long timeout = (long long)watch.timeoutMillis; + if(timeout == 0) { + watch.timeoutMillis = -1; + curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &running); + continue; + } + if(n == 0 && timeout < 0) { + /* Nothing to watch and no timer, which libcurl should never leave + us with while a transfer runs; look again shortly rather than + waiting for ever on nothing. */ + timeout = 10; + } + for(i = 0 ; i < n ; i++) { + fds[i] = (int)watch.fd[i]; + events[i] = ((watch.what[i] & CURL_POLL_IN) ? 1 : 0) + | ((watch.what[i] & CURL_POLL_OUT) ? 2 : 0); + probe[i].fd = fds[i]; + probe[i].events = (short)(((events[i] & 1) ? POLLIN : 0) + | ((events[i] & 2) ? POLLOUT : 0)); + probe[i].revents = 0; + } + /* Ask first, as every other wait here does: a socket that is already + ready costs no park. */ + if(n > 0) { + ready = poll(probe, (nfds_t)n, 0); + } + if(ready <= 0) { + cn1BackendVtWait(n, fds, events, timeout); + if(n > 0) { + for(i = 0 ; i < n ; i++) { + probe[i].revents = 0; + } + ready = poll(probe, (nfds_t)n, 0); + } + } + if(ready > 0) { + for(i = 0 ; i < n ; i++) { + int mask = 0; + if(probe[i].revents & (POLLIN | POLLHUP)) { + mask |= CURL_CSELECT_IN; + } + if(probe[i].revents & POLLOUT) { + mask |= CURL_CSELECT_OUT; + } + if(probe[i].revents & (POLLERR | POLLNVAL)) { + mask |= CURL_CSELECT_ERR; + } + if(mask != 0) { + curl_multi_socket_action(multi, (curl_socket_t)fds[i], mask, &running); + } + } + } else { + curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &running); + } + } + while((msg = curl_multi_info_read(multi, &left)) != NULL) { + if(msg->msg == CURLMSG_DONE) { + rc = msg->data.result; + } + } + curl_multi_remove_handle(multi, curl); + curl_multi_cleanup(multi); + return rc; +} +#endif + typedef struct { char* data; size_t length; @@ -519,9 +684,19 @@ JAVA_LONG com_codename1_backend_Web_performImpl___java_lang_String_java_lang_Str curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)bodyLength); } - /* The transfer blocks; yield so the concurrent collector is not stalled by it. */ + /* The transfer blocks; yield so the concurrent collector is not stalled by it. + On a virtual thread it PARKS instead of blocking, so its host goes on + running every other virtual thread; see cn1WebPerformParked. */ CN1_YIELD_THREAD; +#ifndef _WIN32 + if(cn1VirtualThreadCurrent() != 0) { + rc = cn1WebPerformParked(curl); + } else { + rc = curl_easy_perform(curl); + } +#else rc = curl_easy_perform(curl); +#endif CN1_RESUME_THREAD; if(rc == CURLE_OK) { diff --git a/vm/backend/src/com/codename1/backend/AsyncResult.java b/vm/backend/src/com/codename1/backend/AsyncResult.java new file mode 100644 index 00000000000..e633e63398f --- /dev/null +++ b/vm/backend/src/com/codename1/backend/AsyncResult.java @@ -0,0 +1,89 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/// A [Future] that is already complete: what an `@Async` method +/// returns from its body. +/// +/// ```java +/// @Async +/// public Future build(String month) { +/// return AsyncResult.of(compute(month)); +/// } +/// ``` +/// +/// The caller never sees this object. It receives the Future of the call +/// itself at once, which completes with this value when the body has run -- +/// the same arrangement as Spring's `AsyncResult` and +/// `CompletableFuture.completedFuture`. +public final class AsyncResult implements Future { + private final V value; + private final Throwable failure; + + private AsyncResult(V value, Throwable failure) { + this.value = value; + this.failure = failure; + } + + /// A result holding `value`. + public static AsyncResult of(V value) { + return new AsyncResult(value, null); + } + + /// A result that failed with `failure`, for a body that reports rather than throws. + public static AsyncResult failed(Throwable failure) { + return new AsyncResult(null, failure); + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return false; + } + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public boolean isDone() { + return true; + } + + @Override + public V get() throws ExecutionException { + if (failure != null) { + throw new ExecutionException(failure); + } + return value; + } + + @Override + public V get(long timeout, TimeUnit unit) throws ExecutionException { + return get(); + } +} diff --git a/vm/backend/src/com/codename1/backend/AsyncTask.java b/vm/backend/src/com/codename1/backend/AsyncTask.java new file mode 100644 index 00000000000..2868a0e49d6 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/AsyncTask.java @@ -0,0 +1,338 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/// One call of an `@Async` method, and the [Future] its caller holds. +/// +/// The build generates a subclass per `@Async` method whose fields are the +/// call's arguments and whose [#call] invokes the method's original body. +/// The rewritten method constructs one, hands it to its executor and returns it, +/// so the caller's `Future` is this object. A method whose body itself +/// returns a Future -- [AsyncResult#of] -- completes this one with that +/// Future's value. +/// +/// The caller's span, if it was being traced, is the parent of the span the +/// call runs in, so the background work shows up under the request that started +/// it. +public abstract class AsyncTask implements Runnable, Future { + private final String name; + private final boolean returnsVoid; + private final Span parent; + /// The caller's server's tracer when there is no parent span to carry it -- + /// in particular the untraced marker of a server with tracing off. + private final Tracer owner; + private boolean done; + private boolean cancelled; + private boolean started; + private Object value; + private Throwable failure; + /// Run once this completes, outside its lock; see [#whenDone]. + private java.util.List listeners; + + /// #### Parameters + /// + /// - `name`: what the span is called: the class and the method + /// + /// - `returnsVoid`: @param returnsVoid whether the method returns nothing, so a failure has + /// nobody to be delivered to and is reported instead + protected AsyncTask(String name, boolean returnsVoid) { + this.name = name; + this.returnsVoid = returnsVoid; + this.parent = Tracing.captureParent(); + this.owner = Tracing.captureOwner(); + } + + /// Runs the method's body. Generated. + protected abstract Object call() throws Exception; + + @Override + public final void run() { + synchronized (this) { + if (cancelled || started) { + return; + } + started = true; + } + Object result = null; + Throwable error = null; + AsyncTask chained = null; + try { + result = Tracing.inBackground(name, parent, owner, new Tracing.Work() { + @Override + public Object run(Span span) throws Exception { + return call(); + } + }); + if (result instanceof AsyncTask && result != this //NOPMD CompareObjectsWithEquals - itself, by identity + && !((AsyncTask) result).isDone()) { + // Another @Async call's pending future: finished when that one + // is, instead of waiting for it here. Waiting held this worker, + // and with the inner call queued on the same executor a + // one-thread pool -- or enough concurrent outer calls on any + // pool -- waited for itself for ever. Spring's interceptor does + // block here; the result is the same either way. + chained = (AsyncTask) result; + } else if (result instanceof Future) { + // Any other Future is awaited, as Spring awaits it. + result = ((Future) result).get(); + } + } catch (ExecutionException err) { + error = err.getCause() != null ? err.getCause() : err; + } catch (Throwable err) { + error = err; + } + if (chained != null) { + final AsyncTask inner = chained; + inner.whenDone(new Runnable() { + @Override + public void run() { + adopt(inner); + } + }); + return; + } + java.util.List fire; + synchronized (this) { + value = result; + failure = error; + fire = completeLocked(); + } + fire(fire); + if (error != null && returnsVoid) { + // Nobody holds a Future for a void method, so the failure goes to the + // executor running this, which reports and counts it -- the only + // place it is ever seen. + if (error instanceof RuntimeException) { + throw (RuntimeException) error; + } + if (error instanceof Error) { + throw (Error) error; + } + throw new RuntimeException(name + " failed: " + error, error); + } + } + + /// Ends a call whose virtual thread the server freed at shutdown before it + /// finished: nothing will ever complete it otherwise, and a caller blocked + /// in get() would wait for ever. + void abandon(String reason) { + java.util.List fire; + synchronized (this) { + if (done) { + return; + } + failure = new IllegalStateException(name + ": " + reason); + fire = completeLocked(); + } + fire(fire); + } + + /// Marks this done and wakes its waiters; answers the listeners to run once + /// the lock is released. Called under this object's lock. + private java.util.List completeLocked() { + done = true; + notifyAll(); + java.util.List out = listeners; + listeners = null; + return out; + } + + private static void fire(java.util.List listeners) { + for (int iter = 0 ; listeners != null && iter < listeners.size() ; iter++) { + ((Runnable) listeners.get(iter)).run(); + } + } + + /// Runs `listener` once this completes -- at once when it already has. + void whenDone(Runnable listener) { + synchronized (this) { + if (!done) { + if (listeners == null) { + listeners = new java.util.ArrayList(1); + } + listeners.add(listener); + return; + } + } + listener.run(); + } + + /// Completes this with the outcome of `inner`, the pending call it returned. + private void adopt(AsyncTask inner) { + Object v = null; + Throwable f = null; + synchronized (inner) { + if (inner.cancelled) { + f = new java.util.concurrent.CancellationException(inner.name + + " was cancelled"); + } else { + v = inner.value; + f = inner.failure; + } + } + java.util.List fire; + synchronized (this) { + if (done) { + return; + } + value = v; + failure = f; + fire = completeLocked(); + } + fire(fire); + } + + /// Cancels the call if it has not started. A running call is never interrupted. + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + java.util.List fire; + synchronized (this) { + if (started || done) { + return false; + } + cancelled = true; + fire = completeLocked(); + } + fire(fire); + return true; + } + + @Override + public synchronized boolean isCancelled() { + return cancelled; + } + + @Override + public synchronized boolean isDone() { + return done; + } + + @Override + public Object get() throws InterruptedException, ExecutionException { + if (VirtualThread.isVirtual()) { + awaitCooperatively(Long.MAX_VALUE); + return completed(); + } + synchronized (this) { + while (!done) { + wait(); + } + return result(); + } + } + + @Override + public Object get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + long deadline = deadline(System.currentTimeMillis(), unit.toMillis(timeout)); + if (VirtualThread.isVirtual()) { + if (!awaitCooperatively(deadline)) { + throw new TimeoutException(name + " did not finish in time"); + } + return completed(); + } + synchronized (this) { + while (!done) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + throw new TimeoutException(name + " did not finish in time"); + } + wait(left); + } + return result(); + } + } + + /// `now + millis`, saturated at Long.MAX_VALUE -- every wait the backend + /// takes from a caller goes through it (Future.get, the executor, scheduler + /// and task shutdowns, a pool borrow), because each accepts a long. A wait + /// that only ever takes `deadline - now` survives the overflow by + /// two's-complement wraparound; one that COMPARES the clock to the deadline, + /// as the virtual-thread wait does, gave up at once. Saturating here keeps + /// every one of them correct whichever way it is written. toMillis() already + /// saturates a huge timeout -- get(Long.MAX_VALUE, DAYS) -- to Long.MAX_VALUE, + /// and adding the clock to that wrapped to a deadline in the past, so the + /// longest possible wait timed out at once. + static long deadline(long now, long millis) { + if (millis > 0 && now > Long.MAX_VALUE - millis) { + return Long.MAX_VALUE; + } + return now + millis; + } + + /// Waits on a virtual thread without blocking its host. + /// + /// A virtual thread that called wait() would block the OS thread under it, + /// and every other virtual thread that host runs with it -- including, + /// quite possibly, the virtual thread running the very task being waited + /// for. With every host stuck that way the server stops: measured, 32 + /// concurrent requests each waiting on an @Async(thread = VIRTUAL) task + /// wedged a native server within seconds. Yielding instead gives the host + /// back after each check, OUTSIDE the monitor, so the task can finish. + /// + /// #### Returns + /// + /// false when the deadline passed first + /// + /// Each check naps rather than yields: a plain yield put the waiter straight + /// back on its host's run ring, so the host polled with a zero timeout and + /// resumed it again at once -- a whole core spent re-checking for as long as a + /// slow task ran. The nap grows from a millisecond to 20, so a quick task is + /// still seen almost at once and a slow one costs a few wake-ups a second. + private boolean awaitCooperatively(long deadline) { + long nap = 1; + while (!isDone()) { + long now = System.currentTimeMillis(); + if (now >= deadline) { + return false; + } + HttpServer.napUntil(Math.min(deadline, now + nap)); + nap = Math.min(20, nap * 2); + } + return true; + } + + private synchronized Object completed() throws ExecutionException { + return result(); + } + + private Object result() throws ExecutionException { + if (cancelled) { + throw new java.util.concurrent.CancellationException(name + " was cancelled"); + } + if (failure != null) { + throw new ExecutionException(failure); + } + return value; + } + + @Override + public String toString() { + return name; + } +} diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index d0f5646b3fb..6289b500359 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -78,15 +78,74 @@ public final class Backend { /// second server in the same process, or one the application installed -- and /// stopping this one must not shut that down. private final Tracer ownTracer; + /// The generated wiring of this server's beans, or null. + private final Application application; + /// The metrics exporter this server started, or null. + private final com.codename1.backend.metrics.MetricReader metricReader; + /// This server's managed beans; see [#getManagedBeans]. + private final List managedBeans; + /// This server's session settings and store. + private final Sessions sessions; + /// This server's executors. + private final Tasks.Registry tasks; + /// This server's managed-attribute gauges, removed from the process's when it stops. + private final List gauges; + /// Whether this server records metrics, which its scheduler reads when the + /// application builds it. Set once, before the Backend is handed to anyone. + private boolean measured; + + /// Whether this server records request and job metrics. + public boolean isMeasured() { + return measured; + } + + /// The tracer this server installed, or null: its scheduled jobs are traced by it. + public Tracer getTracer() { + return ownTracer; + } + + /// The recent requests of this server, kept once the development tools ask. + private final RequestLog requestLog; + /// Whether start-up has finished -- the application's started() hook + /// included. The listener accepts before that hook runs, so health must not + /// report the server ready to a load balancer until it has returned. + private boolean ready; private Backend(HttpServer server, DataSource dataSource, EntityManager entities, - Config config, int shutdownMillis, Tracer ownTracer) { + Config config, int shutdownMillis, Tracer ownTracer, + Application application, + com.codename1.backend.metrics.MetricReader metricReader, + List managedBeans, Sessions sessions, Tasks.Registry tasks, + RequestLog requestLog, List gauges) { + this.gauges = gauges; + int added = 0; + try { + for ( ; added < gauges.size() ; added++) { + Object[] g = (Object[]) gauges.get(added); + com.codename1.backend.metrics.Metrics.addSource((String) g[0], (String) g[1], + (String) g[2], (com.codename1.backend.metrics.Gauge.Source) g[3]); + } + } catch (RuntimeException err) { + // A start that fails here must not leave the gauges it did add. + for (int iter = 0 ; iter < added ; iter++) { + Object[] g = (Object[]) gauges.get(iter); + com.codename1.backend.metrics.Metrics.removeSource((String) g[0], + (com.codename1.backend.metrics.Gauge.Source) g[3]); + } + throw err; + } + this.tasks = tasks; + this.requestLog = requestLog; + this.metricReader = metricReader; + this.managedBeans = managedBeans; + this.sessions = sessions; this.server = server; this.dataSource = dataSource; this.entities = entities; this.config = config; this.shutdownMillis = shutdownMillis; this.ownTracer = ownTracer; + this.application = application; } /// A builder whose defaults come from the configuration this process sees. @@ -99,6 +158,15 @@ public static Builder builder(Config config) { return new Builder(config); } + private String listenAddress = "127.0.0.1"; //NOPMD AvoidUsingHardCodedIP - loopback default, never dialled + + /// The address a client on this machine reaches the listener at: the one + /// it is bound to (bracketed when IPv6), or 127.0.0.1 when it listens on + /// every interface. A listener bound to one address answers on no other. + public String getListenAddress() { + return listenAddress; + } + /// The running server, for its metrics or to stop it. public HttpServer getServer() { return server; @@ -119,9 +187,54 @@ public Config getConfig() { return config; } + /// The build-generated wiring of this server's beans, or null when it has none. + public Application getApplication() { + return application; + } + + /// The managed beans THIS server registered, as a copy. Per server rather than + /// per process: a second server in the same process -- or this one started + /// again -- must not list or invoke the beans of one that has stopped. + public List getManagedBeans() { + return new ArrayList(managedBeans); + } + + /// Whether the server has finished starting and serves its application. + public synchronized boolean isReady() { + return ready; + } + + synchronized void markReady() { + ready = true; + } + + /// The log of this server's recent requests, off until something enables it. + public RequestLog getRequestLog() { + return requestLog; + } + + /// This server's sessions: their settings and the store they are kept in. + public Sessions getSessions() { + return sessions; + } + /// Blocks until the server stops. public void awaitTermination() { server.awaitTermination(); + // And the teardown: a stop() from a handler, callback or task finishes + // it on another thread after the listener has ended, and a process + // returning from run() here would exit with @PreDestroy, the sessions + // and the pool still being closed. + synchronized (this) { + while (stopping && !stopped) { + try { + wait(); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return; + } + } + } } /// Stops accepting, lets what is in flight finish, and closes the database. @@ -130,14 +243,277 @@ public void awaitTermination() { /// closing the pool first would fail the requests that were still being /// served with it. public void stop() { + // Once: a program that calls stop() and a signal hook that calls it + // again would otherwise run every @PreDestroy and destroyMethod twice, + // closing resources twice or repeating a shutdown write. A second caller + // waits for the first to finish rather than returning while the server + // is still draining. + synchronized (this) { + if (stopping && (HttpServer.servingOnThisThread() + || TaskExecutor.runningOnThisThread())) { + // A caller that is itself in-flight work -- a handler, a callback, + // a task -- and lost the race: waiting would hold the very request + // or task the winner's drain is waiting for, so the winner would + // sit out the whole timeout and then tear the server down under + // this caller. It returns instead; the stop is under way. + return; + } + while (stopping) { + try { + wait(); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return; + } + } + if (stopped) { + return; + } + stopping = true; + } + // A caller that is itself in-flight work -- a handler, a callback, a task + // -- is discounted by the drains below, so they return while it still + // runs. The teardown after them (beans destroyed, pool closed) must then + // wait for it to leave: it goes on using those, and its request still + // stores its session and destroys its request beans on the way out. + boolean callerInFlight = HttpServer.servingOnThisThread() + || TaskExecutor.runningOnThisThread(); + // The callbacks below work for this server, whichever thread stops it. + Object callerTasks = Tasks.enter(tasks); + boolean deferred = false; + try { + drain(); + if (callerInFlight) { + deferred = true; + // The tracer is arranged HERE, on the caller's thread, where its + // request's span can be found: it ends after the response is + // written, later than the teardown below may run, and the tracer + // must outlive it or the request that stopped the server loses its + // own trace. + final boolean tracerArranged = ownTracer != null + && Tracing.shutdownWhenServingEnds(ownTracer, shutdownMillis); + Thread finisher = new Thread(new Runnable() { + @Override + public void run() { + finishAfterCaller(!tracerArranged); + } + }, "cn1-backend-stop"); + finisher.start(); + } else { + tearDown(true); + } + } finally { + Tasks.leave(callerTasks); + if (!deferred) { + markStopped(); + } + } + } + + /// The teardown of a stop() called from in-flight work, once that work -- + /// and any other the drain discounted -- has left, or the timeout has passed. + private void finishAfterCaller(boolean stopTracer) { + long deadline = System.currentTimeMillis() + Math.max(0, shutdownMillis); + InFlight work = inFlight; + if (work != null) { + work.awaitIdle(deadline); + } + Tasks.awaitIdle(tasks, deadline); + Object previous = Tasks.enter(tasks); + try { + tearDown(stopTracer); + } finally { + Tasks.leave(previous); + markStopped(); + } + } + + private void markStopped() { + synchronized (this) { + stopping = false; + stopped = true; + notifyAll(); + } + // Outside this object's lock: claimProcessSlot holds the class lock and + // then asks this object, so taking them the other way round deadlocks. + releaseProcessSlot(this); + } + + /// Frees the process slot `stopped` holds, so a stopped Backend -- its + /// destroyed beans, its sessions -- is not kept reachable until the next start. + private static synchronized void releaseProcessSlot(Backend stopped) { + if (processLive == stopped) { //NOPMD CompareObjectsWithEquals - the backend itself, by identity + processLive = null; + } + } + + /// The one Backend this process runs, until it stops. + private static Backend processLive; + /// Whether a start is under way, which holds the slot until it settles. + private static boolean processStarting; + + /// ONE Backend per process. Scaling out is more processes -- sessions in the + /// database session store, scheduler locks in the database -- never more servers in one + /// JVM, and the runtime keeps process-wide state that assumes it: the + /// installed tracer, the metrics registry and its exporter, the default task + /// executors, the virtual-thread hosts. A second server beside the first + /// would share or fight over each of them, so it is refused outright rather + /// than half supported. Starting again after stop() has finished is fine. + static void claimProcess() { + // One stopping -- a stop() from a handler finishes its teardown after the + // handler returns -- is waited for: stop-then-start works either way. + Backend previous; + synchronized (Backend.class) { + previous = processLive; + } + if (previous != null) { + previous.awaitStopIfStopping(); + } + claimProcessSlot(); + } + + private static synchronized void claimProcessSlot() { + if (processStarting) { + throw new IllegalStateException("A backend is already starting in this process; " + + "one process runs one backend"); + } + if (processLive != null && !processLive.isStopped()) { + throw new IllegalStateException("A backend is already running in this process; " + + "stop it before starting another -- one process runs one backend"); + } + processStarting = true; + } + + /// Ends a start: `started` holds the slot until it stops, or null when the + /// start failed and the slot is free again. + static synchronized void settleProcess(Backend started) { + processStarting = false; + processLive = started; + } + + /// Waits, up to its shutdown timeout and a little over, for a stop already + /// under way to finish; returns at once when none is. + synchronized void awaitStopIfStopping() { + long deadline = System.currentTimeMillis() + Math.max(0, shutdownMillis) + 5000; + while (stopping && !stopped) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + return; + } + try { + wait(left); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return; + } + } + } + + /// Whether [#stop] has finished: the beans are destroyed and the pool closed. + public synchronized boolean isStopped() { + return stopped; + } + + /// The requests and websocket callbacks this server is running, counted so a + /// stop() called from one of them can wait for it to leave before the + /// teardown. Null for a Backend built without a listener of its own. + InFlight inFlight; + + /// A count of work in flight that can be waited out. + static final class InFlight { + private int count; + + synchronized void enter() { + count++; + } + + synchronized void leave() { + count--; + notifyAll(); + } + + synchronized void awaitIdle(long deadline) { + while (count > 0) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + return; + } + try { + wait(left); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return; + } + } + } + } + + private boolean stopping; + private boolean stopped; + + /// Stops taking work and lets what is in flight finish, up to the timeout. + private void drain() { + // Scheduled jobs first, so none starts while the server drains; the + // jobs already running are waited for with the requests. + if (application != null) { + try { + application.stopping(); + } catch (Throwable err) { + System.err.println("Stopping the application failed: " + err); + } + } server.stop(shutdownMillis); + // Background work next: @Async calls and scheduled runs still going get + // the same grace the requests did, while the beans they use are alive. + Tasks.shutdown(tasks, shutdownMillis); + // Out of the process's server gauges, which would otherwise keep reading + // a stopped server and pool -- AFTER the drain: a scheduled run that ends + // in it records its duration and outcome, and with the last measured + // server's metrics already off that record was silently dropped. + com.codename1.backend.metrics.Metrics.disableServer(server, dataSource); + for (Object element : gauges) { + Object[] g = (Object[]) element; + com.codename1.backend.metrics.Metrics.removeSource((String) g[0], + (com.codename1.backend.metrics.Gauge.Source) g[3]); + } + // This server's exporter too, BEFORE the beans: an export in progress + // reads gauges whose sources are those beans, and its final export must + // not call one during its @PreDestroy, or against a closed pool. + if (metricReader != null) { + // An application's own reader failing must not keep the sessions, + // the beans and the pool from being torn down after it. + try { + metricReader.shutdown(shutdownMillis); + } catch (Throwable err) { + System.err.println("Stopping the metric reader failed: " + err); + } + } + } + + /// Destroys the beans and closes the pool, once nothing uses them. + /// + /// @param stopTracer false when the tracer's shutdown is already arranged + /// on the end of the request that called stop() + private void tearDown(boolean stopTracer) { + // @PreDestroy after the drain, so no request is still using a bean it + // tears down, and before the pool closes, so a bean can still flush to + // the database on its way out. Session beans first: they may use the + // singletons, never the other way round. + sessions.close(); + if (application != null) { + try { + application.stopped(); + } catch (Throwable err) { + System.err.println("Destroying the application's beans failed: " + err); + } + } if (dataSource != null) { dataSource.close(); } // LAST, so the spans of the requests the drain let finish are exported // rather than lost with the process -- and, when a request handler is the // caller, after THAT request's span has ended, which is after this returns. - if (ownTracer != null) { + if (ownTracer != null && stopTracer) { Tracing.shutdownAfterServing(ownTracer, shutdownMillis); } } @@ -185,6 +561,641 @@ HttpServer.Handler[] create(DataSource dataSource, EntityManager entities) throws Exception; } + /// The build-generated wiring of an application: every bean, constructed and + /// injected by straight-line code the build wrote, and the lifecycle calls + /// around them. + /// + /// Nothing here is looked up or reflected. The build resolves which + /// constructor each bean gets, which bean each injection point receives and + /// in what order they are built, and writes that down as `new` and + /// setter calls; this interface is only where the server calls into it. + public interface Application { + /// Constructs the beans and returns the routers, once the database, if + /// any, is open. + HttpServer.Handler[] create(Environment environment) throws Exception; + + /// Registers the websocket endpoints, which are beans too. + void registerWebSockets(HttpServer.WebSocketRegistry registry) throws Exception; + + /// The server is accepting: scheduled jobs and exporters start here. + void started(Backend backend) throws Exception; + + /// The server is about to drain: no new scheduled run starts after this. + void stopping(); + + /// The server has drained: the beans' destroy methods run here. + void stopped(); + + /// Whether a generated class needs [Backend#currentRequest]: a + /// request- or session-scoped bean reached from a singleton. False keeps + /// the per-request thread-local write out of servers that have none. + boolean tracksCurrentRequest(); + + /// A request has been answered; `beans` are its + /// `@RequestScope` beans, whose destroy methods run here. + void requestEnded(Object[] beans); + + /// A session has ended -- invalidated, expired, or the server stopped; + /// `beans` are its `@SessionScope` beans, whose destroy + /// methods run here. + void sessionEnded(Object[] beans); + + /// The scheduler running this application's `@Scheduled` jobs, or null. + Scheduler getScheduler(); + + /// Every bean the build wired: name, type, scope and what it was given. + /// For the management endpoint and the development MCP server. + List describeBeans(); + + /// Every route the build generated: method, path and handler. + List describeRoutes(); + } + + /// The request the calling thread is serving, for generated scoped proxies. + private static final ThreadLocal CURRENT_REQUEST = new ThreadLocal(); + + /// The request the calling thread is serving, or null outside one. Maintained + /// only for applications whose build asked for it -- see + /// [Application#tracksCurrentRequest]. + public static HttpServer.Request currentRequest() { + return (HttpServer.Request) CURRENT_REQUEST.get(); + } + + /// `registry`, with every endpoint it is handed wrapped so its callbacks + /// run carrying `tasks`. A websocket callback runs inside HttpServer, + /// outside the request wrapper that sets the executors, so with two servers in + /// one process an @Async call from onText would otherwise go to whichever + /// server started last -- and be stopped with it. + static HttpServer.WebSocketRegistry withTasks(final HttpServer.WebSocketRegistry registry, + final Tasks.Registry tasks) { + return withTasks(registry, tasks, null); + } + + /// [#withTasks(HttpServer.WebSocketRegistry,Tasks.Registry)], with the + /// server's own tracer bound for every callback as well. + static HttpServer.WebSocketRegistry withTasks(final HttpServer.WebSocketRegistry registry, + final Tasks.Registry tasks, + final Tracer tracer) { + return withTasks(registry, tasks, tracer, null); + } + + /// And counted in `inFlight` while each callback runs, for a stop() one of + /// them makes. + static HttpServer.WebSocketRegistry withTasks(final HttpServer.WebSocketRegistry registry, + final Tasks.Registry tasks, + final Tracer tracer, + final InFlight inFlight) { + return new HttpServer.WebSocketRegistry() { + @Override + public void route(String path, WebSocket endpoint) { + registry.route(path, endpoint == null ? null + : new TaskBound(endpoint, tasks, tracer, inFlight)); + } + + @Override + public void fallback(final HttpServer.WebSocketHandler router) { + registry.fallback(router == null ? null : new HttpServer.WebSocketHandler() { + @Override + public WebSocket open(HttpServer.Request request) throws Exception { + Object previous = Tasks.enter(tasks); + // Counted like every callback: a router that stops the + // server must see its teardown wait for it to return. + if (inFlight != null) { + inFlight.enter(); + } + try { + WebSocket endpoint = router.open(request); + return endpoint == null ? null + : new TaskBound(endpoint, tasks, tracer, inFlight); + } finally { + if (inFlight != null) { + inFlight.leave(); + } + Tasks.leave(previous); + } + } + }); + } + }; + } + + /// An endpoint whose every callback runs carrying its server's executors and + /// its tracer: the upgrade's span has ended by the time a message arrives, so + /// without one a span the callback starts -- or a call it makes -- would report + /// to whichever server installed its tracer last. + static final class TaskBound implements WebSocket { + private final WebSocket endpoint; + private final Tasks.Registry tasks; + private final Tracer tracer; + private final InFlight inFlight; + + TaskBound(WebSocket endpoint, Tasks.Registry tasks) { + this(endpoint, tasks, null, null); + } + + TaskBound(WebSocket endpoint, Tasks.Registry tasks, Tracer tracer) { + this(endpoint, tasks, tracer, null); + } + + TaskBound(WebSocket endpoint, Tasks.Registry tasks, Tracer tracer, InFlight inFlight) { + this.endpoint = endpoint; + this.tasks = tasks; + this.tracer = tracer; + this.inFlight = inFlight; + } + + private Object[] bind() { + if (inFlight != null) { + inFlight.enter(); + } + return new Object[] {Tasks.enter(tasks), Tracing.own(tracer)}; + } + + private void unbind(Object[] previous) { + Tracing.disown(previous[1]); + Tasks.leave(previous[0]); + if (inFlight != null) { + inFlight.leave(); + } + } + + @Override + public void onOpen(WebSocketSession session) throws Exception { + Object[] previous = bind(); + try { + endpoint.onOpen(session); + } finally { + unbind(previous); + } + } + + @Override + public void onText(WebSocketSession session, String message) throws Exception { + Object[] previous = bind(); + try { + endpoint.onText(session, message); + } finally { + unbind(previous); + } + } + + @Override + public void onBinary(WebSocketSession session, byte[] message, int offset, int length) + throws Exception { + Object[] previous = bind(); + try { + endpoint.onBinary(session, message, offset, length); + } finally { + unbind(previous); + } + } + + @Override + public void onPing(WebSocketSession session, byte[] payload, int offset, int length) + throws Exception { + Object[] previous = bind(); + try { + endpoint.onPing(session, payload, offset, length); + } finally { + unbind(previous); + } + } + + @Override + public void onPong(WebSocketSession session, byte[] payload, int offset, int length) + throws Exception { + Object[] previous = bind(); + try { + endpoint.onPong(session, payload, offset, length); + } finally { + unbind(previous); + } + } + + @Override + public void onClose(WebSocketSession session, int code, String reason) + throws Exception { + Object[] previous = bind(); + try { + endpoint.onClose(session, code, reason); + } finally { + unbind(previous); + } + } + + @Override + public void onError(WebSocketSession session, Exception error) { + Object[] previous = bind(); + try { + endpoint.onError(session, error); + } finally { + unbind(previous); + } + } + + /// Forwarded like every callback: left to the interface default, the + /// wrapper answered null, the handshake sent no Sec-WebSocket-Protocol, + /// and a client asking for the endpoint's protocol could not get it. + @Override + public String[] getSubprotocols() { + Object[] previous = bind(); + try { + return endpoint.getSubprotocols(); + } finally { + unbind(previous); + } + } + } + + /// The handler a Backend puts in front of the server's handlers: it sets up + /// what a request carries -- this server's sessions, executors and current + /// request -- runs the chain, finishes the session and records metrics and + /// the log. Named and static rather than an anonymous class holding the + /// builder. + private static final class Serving implements HttpServer.Handler { + private final HttpServer.Handler[] chain; + private final Sessions sessions; + private final Tasks.Registry tasks; + private final RequestLog requestLog; + private final java.util.concurrent.atomic.AtomicBoolean instrumented; + private final Application app; + private final boolean track; + /// This server's tracer, or the untraced marker; bound for the request + /// so work it hands to another thread -- an @Async call -- keeps it. + private final Tracer tracer; + /// Every request counted in and out, for a stop() one of them makes. + private final InFlight inFlight; + + Serving(HttpServer.Handler[] chain, Sessions sessions, Tasks.Registry tasks, + RequestLog requestLog, java.util.concurrent.atomic.AtomicBoolean instrumented, + Application app, boolean track, Tracer tracer, InFlight inFlight) { + this.tracer = tracer; + this.inFlight = inFlight; + this.chain = chain; + this.sessions = sessions; + this.tasks = tasks; + this.requestLog = requestLog; + this.instrumented = instrumented; + this.app = app; + this.track = track; + } + + /// What a request that threw still owes: its request beans + /// destroyed, and the sessions it ended or changed stored. + /// + /// Per session: `attempted` holds the ones the normal path already + /// tried, the one whose store call threw among them, and each of the + /// others is still finished here -- one failing save must not leave the + /// rest undeleted, or their beans undestroyed. + private void failed(HttpServer.Request request, List attempted, + long startedMillis, Throwable err) { + endRequestBeans(request); + List ended = request.endedSessions(); + for (int e = 0 ; ended != null && e < ended.size() ; e++) { + HttpSession ending = (HttpSession) ended.get(e); + if (attempted.contains(ending)) { + continue; + } + try { + sessions.finish(request, ending, null); + } catch (Exception storeErr) { + System.err.println("Could not end the " + + "session of a failed request: " + + storeErr); + } + } + HttpSession session = request.resolvedSession(); + if (session != null && !attempted.contains(session)) { + // The handler threw, but what it did to + // the session stands, as in a servlet + // container -- and a session-scoped bean + // it built must be kept or destroyed, not + // dropped with the request unreleased. + try { + sessions.finish(request, session, null); + } catch (Exception storeErr) { + System.err.println("Could not store the " + + "session of a failed request: " + + storeErr); + } + } + requestLog.record(request, 500, startedMillis, err); + } + + /// Destroy passes over request beans that destroying others created. + private static final int MAX_DESTROY_PASSES = 32; + + /// Destroys the request's scoped beans, once. + private void endRequestBeans(HttpServer.Request request) { + endRequestBeans(request, null); + } + + /// Ends the request's scoped beans, serialising `response`'s deferred + /// JSON body first when there are any. respondJson() leaves the returned + /// object to be serialised by the writer, after this -- so a map, list or + /// DTO a request bean owned was written after its @PreDestroy had + /// cleared or closed it. Spring MVC writes the body before it destroys + /// request-scoped beans, and so does this. Only when there are beans to + /// end: without any, the zero-copy write stays as it is. + private void endRequestBeans(HttpServer.Request request, + HttpServer.Response response) { + if (app != null) { + Object[] beans = request.takeScopedBeans(); + try { + if (beans != null && response != null) { + response.serializeDeferredJson(); + } + } finally { + // Even when serialising throws -- a Writable that fails, a + // cyclic collection: the array is already taken, and the + // later passes would find nothing to destroy. + // Until none are left: a @PreDestroy may use a request bean + // nobody had built yet, which builds it now -- and ITS destroy + // may build another. One extra pass left the last one's + // resources open. Bounded, so beans that keep building each + // other cannot hold the request for ever. + for (int pass = 0 ; beans != null ; pass++) { + if (pass == MAX_DESTROY_PASSES) { + System.err.println("cn1: request-scoped beans were still " + + "being created by each other's @PreDestroy after " + + MAX_DESTROY_PASSES + " passes; the rest are not " + + "destroyed"); + break; + } + app.requestEnded(beans); + beans = request.takeScopedBeans(); + } + } + } + } + + @Override + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + long started = instrumented.get() + ? com.codename1.backend.metrics.Metrics.requestStarted() + : 0L; + Object previous = null; + // This server's sessions, not a process-wide set: + // cookies are not scoped by port, so a client of + // two servers on one host would otherwise present + // one's session to the other and be let in. + request.sessions = sessions; + inFlight.enter(); + Object previousTasks = Tasks.enter(tasks); + Object previousOwner = Tracing.own(tracer); + // Who rotates a session: see HttpSession.rotatedFor. + Object previousServing = HttpSession.enterRequest(request); + if (track) { + previous = CURRENT_REQUEST.get(); + CURRENT_REQUEST.set(request); + } + long startedMillis = requestLog.enabled + ? System.currentTimeMillis() : 0L; + // What the metrics record; stays 500 when a + // handler or the session store throws. + int status = 500; + try { + HttpServer.Response response = null; + List attempted = new ArrayList(2); + try { + for (HttpServer.Handler element : chain) { + response = element.handle(request); + if (response != null) { + break; + } + } + // Request beans end BEFORE the session is + // stored: a @PreDestroy that changes the + // session, or starts one, would otherwise + // change it after its only save. + endRequestBeans(request, response); + // Inside the logged region: a session + // store that fails to save is a 500 the + // client receives, and the request log + // must say so rather than record the + // handler's own status. + List ended = request.endedSessions(); + HttpSession session = request.resolvedSession(); + // First, so their clearing cookies come + // before the new session's. Each is noted + // BEFORE its store call, so a failure + // finishes the others and not it again. + for (int e = 0 ; ended != null && e < ended.size() ; e++) { + HttpSession ending = (HttpSession) ended.get(e); + attempted.add(ending); + response = sessions.finish(request, ending, response); + } + if (session != null) { + attempted.add(session); + boolean stored = false; + try { + response = sessions.finish(request, session, response); + stored = true; + } finally { + if (!stored) { + // A new session whose first save failed: its cookie + // was never sent, so nothing can find it again -- + // its beans and any half-written row go now. + sessions.discardUnsaved(session); + } + } + } + } catch (Exception err) { + // The handler's response is replaced by a 500; a + // file it carried is closed here or never. + if (response != null) { + response.discard(); + } + failed(request, attempted, startedMillis, err); + throw err; + } catch (Error err) { + if (response != null) { + response.discard(); + } + // The same clean-up: an invalidated session + // must still be deleted, or the client's old + // cookie keeps its signed-in state. + failed(request, attempted, startedMillis, err); + throw err; + } + // Null is a 404 from here, which is what a + // router answers for a path it does not route. + requestLog.record(request, response == null ? 404 + : response.getStatus(), startedMillis, null); + status = response == null ? 404 : response.getStatus(); + return response; + } finally { + // In the finally so a failed request is in + // the duration histogram too, and so the + // route label it set is cleared -- left + // behind, the worker's next unrouted + // request would be recorded under it. + com.codename1.backend.metrics.Metrics.requestEnded(started, + request.getMethod(), status); + // Destroyed while this is still the current + // request: a @PreDestroy that calls another + // request-scoped bean goes through that bean's + // stand-in, which looks the request up. + try { + // Normally done already, and then a + // no-op: the beans are taken once. + endRequestBeans(request); + } finally { + try { + // FIRST, while this server's executors and tracer are + // still the thread's: leaving may run the @PreDestroy of + // a retired session's beans, and a destroy callback that + // submits a task or starts a span belongs to this server + // -- restored first, it went to the newest other server's + // executors or tracer, or to the defaults. + sessions.leave(request); + } finally { + if (track) { + CURRENT_REQUEST.set(previous); + } + Tasks.leave(previousTasks); + Tracing.disown(previousOwner); + HttpSession.leaveRequest(previousServing); + // Last: a stop() this request made tears down only now. + inFlight.leave(); + } + } + } + } + } + + /// The address a client should use for a listener bound to `host`: the + /// address itself -- a listener bound to one address answers on no other -- + /// in brackets when it is IPv6, and 127.0.0.1 for every-interface binds. + static String advertised(String host) { + String h = host == null ? "" : host.trim(); + if (h.length() == 0 || "0.0.0.0".equals(h) //NOPMD AvoidUsingHardCodedIP - recognises the wildcard bind + || "::".equals(h) || "[::]".equals(h)) { + return "127.0.0.1"; //NOPMD AvoidUsingHardCodedIP - loopback, what a local client reaches a wildcard bind at + } + return h.indexOf(':') >= 0 && !h.startsWith("[") ? "[" + h + "]" : h; + } + + /// Whether `host` names this machine's loopback interface. + /// + /// Only what is loopback by definition: `localhost`, the IPv6 `::1`, and a + /// NUMERIC IPv4 literal in 127/8. A name that merely starts "127." -- + /// 127.backend.example -- resolves wherever its DNS says, and a prefix test + /// took it for loopback and let a tokenless MCP endpoint listen publicly. + static boolean isLoopback(String host) { + String h = host.trim(); + if ("localhost".equalsIgnoreCase(h) || "::1".equals(h) //NOPMD AvoidUsingHardCodedIP - recognises loopback + || "[::1]".equals(h) + || "0:0:0:0:0:0:0:1".equals(h)) { //NOPMD AvoidUsingHardCodedIP - recognises loopback + return true; + } + // By hand: vm/JavaAPI has no String.split. + int octets = 0; + int start = 0; + for (int end = 0 ; end <= h.length() ; end++) { + if (end < h.length() && h.charAt(end) != '.') { + char c = h.charAt(end); + if (c < '0' || c > '9' || end - start >= 3) { + return false; + } + continue; + } + if (end == start || Integer.parseInt(h.substring(start, end)) > 255 + || (octets == 0 && !"127".equals(h.substring(start, end)))) { + return false; + } + octets++; + start = end + 1; + } + return octets == 4; + } + + /// What an [Application] is built from. + public static final class Environment { + private final Config config; + private final DataSource dataSource; + private final EntityManager entities; + + private final List tools; + private final List managed; + /// {name, description, unit, Gauge.Source}, for the server to add and later remove. + final List gauges = new ArrayList(); + + Environment(Config config, DataSource dataSource, EntityManager entities, + List tools, List managed) { + this.config = config; + this.dataSource = dataSource; + this.entities = entities; + this.tools = tools; + this.managed = managed; + } + + /// Publishes an `@McpTool` on this server's MCP endpoint. Generated + /// code calls this while it builds the beans; the tool belongs to this + /// server only, so a server started later in the same process does not + /// serve a tool bound to a bean that has been destroyed. + public void registerTool(com.codename1.backend.mcp.McpTool tool) { + for (Object element : tools) { + if (((com.codename1.backend.mcp.McpTool) element).name() + .equals(tool.name())) { + // Two active beans publishing one name: one would be + // unreachable, and which depends on construction order. + throw new IllegalStateException("Two MCP tools are named \"" + + tool.name() + "\"; give one a distinct name"); + } + } + tools.add(tool); + } + + /// Publishes a managed attribute as a gauge of this server's: added when + /// the server starts and removed when it stops. Generated code calls this. + public void registerGauge(String name, String description, String unit, + com.codename1.backend.metrics.Gauge.Source source) { + gauges.add(new Object[] {name, description, unit, source}); + } + + /// Registers a managed bean with this server. Generated code calls this. + public void registerManaged(ManagedBean bean) { + String objectName = bean.getObjectName(); + if (objectName == null || objectName.length() == 0 || objectName.length() > 128) { + throw new IllegalArgumentException("A managed bean needs a name of 1 to 128 " + + "characters"); + } + for (int iter = 0 ; iter < objectName.length() ; iter++) { + char c = objectName.charAt(iter); + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '_' || c == '.' || c == '-')) { + // One URL segment, matched undecoded; see the build's check. + throw new IllegalArgumentException("Managed bean \"" + objectName + + "\": a name is letters, digits, _, - and . only"); + } + } + for (Object element : managed) { + if (((ManagedBean) element).getObjectName() + .equals(bean.getObjectName())) { + throw new IllegalStateException("Two managed resources are named \"" + + bean.getObjectName() + "\"; set objectName on one"); + } + } + managed.add(bean); + } + + public Config getConfig() { + return config; + } + + /// The pool, or null when this server has no database. + public DataSource getDataSource() { + return dataSource; + } + + /// The entity manager, or null when the build generated no entities. + public EntityManager getEntityManager() { + return entities; + } + } + /// Where a server's websocket endpoints come from. /// /// Deliberately the same shape as [Handlers]: the server calls this once @@ -199,6 +1210,30 @@ void register(HttpServer.WebSocketRegistry registry, DataSource dataSource, } /// Collects what a server needs and starts one. + /// A route the server serves on its own behalf -- the management endpoints, + /// the MCP endpoint -- seen through a type that names neither. Only the + /// builder methods that install one create it, so a server whose entry point + /// never calls them links none of their code. + abstract static class OwnRoute { + /// The handler this configuration asks for, or null when it is off. + abstract HttpServer.Handler open(Config config, String serviceName, List tools) + throws IOException; + + /// Called once the server is running, with what [#open] returned. + abstract void attach(HttpServer.Handler opened, Backend running); + + /// The setting that would guard `opened` when it answers anyone who + /// reaches the port, or null when it's guarded. + String unguardedBy(HttpServer.Handler opened) { + return null; + } + + /// The start-up line naming where `opened` is served, under `base`. + String announce(HttpServer.Handler opened, String base) { + return null; + } + } + public static final class Builder { private Config config; private final List handlers = new ArrayList(); @@ -216,10 +1251,21 @@ public static final class Builder { private StaticFiles staticFiles; private WebSocketEndpoints webSocketEndpoints; private boolean createTables; + private final List mcpTools = new ArrayList(); + /// The application a start in progress has begun building, until a Backend owns it. + private Application createdApplication; + /// The executors a start in progress opened, until a Backend owns them. + private Tasks.Registry startingTasks; private boolean createTablesGiven; private boolean handlersNeedADatabase; private boolean quiet; private Tracer tracer; + private Application application; + private com.codename1.backend.metrics.MetricReader metricReader; + private OwnRoute managementRoute; + private OwnRoute mcpRoute; + private String[] compiledSettings; + private String serviceName; Builder(Config config) { this.config = config; @@ -235,6 +1281,13 @@ public Builder handler(HttpServer.Handler handler) { return this; } + /// The build-generated wiring of this server's beans. See + /// [Application]; the generated entry point calls this. + public Builder application(Application application) { + this.application = application; + return this; + } + /// Adds handlers built once the database exists. See [Handlers]. public Builder handlers(Handlers factory) { this.factory = factory; @@ -395,18 +1448,135 @@ public Builder tracing(Tracer tracer) { return this; } + /// Exports metrics with this reader, once `open` has read the + /// configuration and agreed to. The build calls this from the entry point + /// of a project that enables OpenTelemetry. + public Builder metrics(com.codename1.backend.metrics.MetricReader reader) { + this.metricReader = reader; + return this; + } + + /// Serves the MCP endpoint, with the application's `@McpTool` + /// methods and, when `devTools` is given and the profile is a + /// development one, the development tools. The build calls this; see + /// [com.codename1.backend.mcp.McpServer]. + /// + /// This method is the only code that names the endpoint's classes. The + /// generated entry point calls it only for a build that asked for MCP, and + /// the translator drops a method nothing calls -- so a server that did not + /// ask has none of the endpoint in its binary. + public Builder mcp(final com.codename1.backend.mcp.McpServer.Extension devTools) { + this.mcpRoute = new OwnRoute() { + @Override + HttpServer.Handler open(Config config, String name, List tools) + throws IOException { + return com.codename1.backend.mcp.McpServer.fromConfig(config, devTools, + name, tools); + } + + // Each cast is of the object open() above returned, never + // anything else. + @Override + void attach(HttpServer.Handler opened, Backend running) { + ((com.codename1.backend.mcp.McpServer) opened).attach(running); + } + + @Override + String unguardedBy(HttpServer.Handler opened) { + return ((com.codename1.backend.mcp.McpServer) opened).hasToken() ? null + : com.codename1.backend.mcp.McpServer.TOKEN; + } + + @Override + String announce(HttpServer.Handler opened, String base) { + com.codename1.backend.mcp.McpServer server = + (com.codename1.backend.mcp.McpServer) opened; + return "cn1: MCP endpoint at " + base + server.getPath() + + (server.hasDevTools() ? " (with development tools)" : ""); + } + }; + return this; + } + + /// Serves the management endpoints -- health, metrics, jobs and managed + /// beans -- when the configuration turns them on; see [Management]. Like + /// [#mcp], this is the only code that names them, and the generated entry + /// point calls it only for a build that asked for them. + public Builder management() { + this.managementRoute = new OwnRoute() { + @Override + HttpServer.Handler open(Config config, String name, List tools) + throws IOException { + return Management.fromConfig(config); + } + + @Override + void attach(HttpServer.Handler opened, Backend running) { + ((Management) opened).attach(running); + } + }; + return this; + } + + /// Settings compiled in from the settings annotations, as key and value + /// pairs: the bottom layer of the configuration, below the properties + /// files and the environment. The build calls this. + public Builder compiledSettings(String[] keysAndValues) { + this.compiledSettings = keysAndValues; + return this; + } + + /// Adds a tool of the program's own to the MCP endpoint, beside the + /// `@McpTool` methods the build found. Needs [#mcp]. + public Builder mcpTool(com.codename1.backend.mcp.McpTool tool) { + if (tool == null) { + throw new IllegalArgumentException("No tool"); + } + mcpTools.add(tool); + return this; + } + + /// The name this server reports itself as, to MCP clients. + public Builder serviceName(String name) { + this.serviceName = name; + return this; + } + /// Suppresses the line this prints when the server comes up. public Builder quiet() { this.quiet = true; return this; } + private SessionStore sessionStore; + + /// Keeps sessions in `store` instead of the configured one. Installed + /// before the server listens, so no request can have used another store + /// first -- which is why this is the way to set one, rather than + /// replacing the store of a server already running. + public Builder sessionStore(SessionStore store) { + this.sessionStore = store; + return this; + } + /// Starts the server and returns, without installing a signal handler or /// waiting. Tests want this; a process wants [#run]. public Backend start() throws Exception { + claimProcess(); + Backend running = null; + try { + running = startOnce(); + return running; + } finally { + settleProcess(running); + } + } + + private Backend startOnce() throws Exception { if (config == null) { config = Config.load(); } + config = config.withCompiledDefaults(compiledSettings); // BEFORE the database, so the statements start-up runs -- the ORM's // CREATE TABLE -- are traced like any other, and before anything that // could fail, so a refused configuration is refused up front. @@ -416,44 +1586,110 @@ public Backend start() throws Exception { // this start-up commits, and put back if it does not. Tracing.Swap claim = tracing ? Tracing.swap(tracer) : null; Backend started; + // A flag and a finally, not a catch of Exception: an Error -- a bean's + // static initializer failing, a class missing -- must undo the start + // as surely as an exception does, and none of these clean-ups rethrow + // anything but what they caught. + boolean ok = false; try { // The tracer itself rather than a flag, so the start-up below // cannot ask a flag and then dereference a field on its word. started = startTraced(tracing ? tracer : null); - } catch (Exception err) { - if (tracing) { + ok = true; + } finally { + if (!ok && tracing) { Tracing.rollBack(claim); } - throw err; } if (tracing) { - Tracing.commit(claim); + // Owned: this server's tracer runs until this server stops it, + // whatever server starts after it in the same process. + Tracing.commit(claim, true); } return started; } private Backend startTraced(Tracer active) throws Exception { + Object callerTasks = Tasks.peek(); + try { + return startTracedOnce(active); + } finally { + Tasks.leave(callerTasks); + } + } + + private Backend startTracedOnce(Tracer active) throws Exception { DataSource pool = openDataSource(); + boolean ok = false; try { - return startWith(pool, active); - } catch (Exception err) { - // EVERY failure after the pool is open, not just the bind. A - // controller constructor that rejects its configuration, a - // static root that is not a directory, a CREATE TABLE the - // server refuses: each of those used to leave the connections - // open, and a supervisor that retries turns that into a pool of - // dead sessions the database still counts. - // - // Only a pool this builder OPENED. One handed in belongs to the - // caller and is theirs to close. - // Ownership is whether THIS BUILDER opened it, which is not the - // same as whether anything was configured: .dataSource(url) - // makes the builder open one, and reading "was one configured" here - // left exactly that case leaking on a failed start. - if (pool != null && dataSource == null) { - pool.close(); + Backend started = startWith(pool, active); + ok = true; + return started; + } finally { + if (!ok) { + abandonStart(pool); } - throw err; + } + } + + /// Undoes a start that failed after the pool opened, however it failed. + private void abandonStart(DataSource pool) { + // EVERY failure after the pool is open, not just the bind. A + // controller constructor that rejects its configuration, a + // static root that is not a directory, a CREATE TABLE the + // server refuses: each of those used to leave the connections + // open, and a supervisor that retries turns that into a pool of + // dead sessions the database still counts. + // + // Only a pool this builder OPENED. One handed in belongs to the + // caller and is theirs to close. + // Ownership is whether THIS BUILDER opened it, which is not the + // same as whether anything was configured: .dataSource(url) + // makes the builder open one, and reading "was one configured" here + // left exactly that case leaking on a failed start. + Application built = createdApplication; + createdApplication = null; + Tasks.Registry tasks = startingTasks; + startingTasks = null; + if (tasks != null) { + // Whatever @PostConstruct submitted stops before the beans it + // uses are destroyed, as in stop() -- and with stop()'s grace. A + // zero wait only interrupted a running task, which it may ignore, + // and went straight on to its beans' @PreDestroy and the pool's + // close while it was still using both. + Tasks.shutdown(tasks, startupDrainMillis()); + } + if (built != null) { + // Before the pool closes, as Backend.stop() orders it, so a + // bean can still flush to the database on its way out. + try { + built.stopped(); + } catch (Throwable destroyErr) { + System.err.println("Destroying the application's beans failed: " + + destroyErr); + } + } + if (pool != null && dataSource == null) { + pool.close(); + } + } + + /// The shutdown timeout for tearing down a failed start: the configured + /// one, or none when it is itself what failed -- a negative value, or one + /// that does not parse. + private int startupDrainMillis() { + if (shutdownMillis >= 0) { + return shutdownMillis; + } + try { + int configured = config == null ? 10000 + : config.getInt(Config.SERVER_SHUTDOWN_MILLIS, 10000); + return Math.max(0, configured); + } catch (IOException err) { + // The unreadable value is what failed the start; nothing to wait by. + return 0; + } catch (RuntimeException err) { + return 0; } } @@ -468,6 +1704,74 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { routers.add(relay); } routers.addAll(handlers); + // FIRST among the routers, after the relay: its paths are its own, and + // a catch-all controller route must not answer a health check. + HttpServer.Handler management = managementRoute == null ? null + : managementRoute.open(config, serviceName, null); + if (management != null) { + // At the front, not appended: the handlers above are already in + // the list, and a catch-all one would otherwise answer + // /manage/health or a managed operation's path first. + routers.add(relay != null ? 1 : 0, management); + } + // This server's executors, which the threads building and serving + // it carry; Tasks explains why they are not the process's. + final Tasks.Registry tasks = Tasks.open(config); + startingTasks = tasks; + final InFlight inFlight = new InFlight(); + // Restored by startTraced when this returns or throws. + Tasks.enter(tasks); + // From here until a Backend owns it, a failed start must still run + // the destroy callbacks of the beans create() built -- including a + // create() that fails partway -- or a caller that retries leaks + // whatever their constructors and @PostConstruct opened. + createdApplication = application; + // Fresh for every start, so a builder started twice does not carry + // the first server's beans into the second. + List tools = new ArrayList(mcpTools); + List managedBeans = new ArrayList(); + Environment environment = null; + if (application != null) { + environment = new Environment(config, pool, manager, tools, managedBeans); + HttpServer.Handler[] built = application.create(environment); + if (built != null) { + for (HttpServer.Handler element : built) { + if (element != null) { + routers.add(element); + } + } + } + } + // After the application: its @McpTool methods are registered while its + // beans are built, and whether the endpoint has anything to serve + // depends on them. + HttpServer.Handler mcpServer = mcpRoute == null ? null + : mcpRoute.open(config, serviceName, tools); + if (mcpServer != null) { + routers.add(0, mcpServer); + } + // A tokenless MCP endpoint -- the development default -- answers + // anyone who can reach the port, and its tools write SQL and call every + // handler. With no address chosen the listener would bind every + // interface, so it binds loopback instead; an address chosen + // explicitly that is not loopback needs the token. + String bindHost = host; + String unguardedBy = mcpServer == null ? null : mcpRoute.unguardedBy(mcpServer); + if (unguardedBy != null) { + if (bindHost == null || bindHost.length() == 0) { + bindHost = "127.0.0.1"; //NOPMD AvoidUsingHardCodedIP - a tokenless MCP endpoint binds loopback only + if (!quiet) { + System.out.println("cn1: the MCP endpoint has no token, so the server " + + "listens on 127.0.0.1 only; set " + unguardedBy + + " to serve other machines"); + } + } else if (!isLoopback(bindHost)) { + throw new IOException("The MCP endpoint has no token and the server is " + + "bound to " + bindHost + ", so any machine that reaches it " + + "could run its tools; set " + unguardedBy + + ", or bind to a loopback address"); + } + } if (factory != null) { HttpServer.Handler[] built = factory.create(pool, manager); if (built != null) { @@ -493,11 +1797,15 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { // would depend on what happened to be in a directory. routers.add(staticFiles); } - boolean servesWebSockets = webSocketEndpoints != null; - if (routers.isEmpty() && !servesWebSockets) { + boolean servesWebSockets = webSocketEndpoints != null || application != null; + // The management and MCP endpoints are the server's own, not the + // application's: a server whose only routes are those still answers + // every request of its users with a 404. + int ownRoutes = (management != null ? 1 : 0) + (mcpServer != null ? 1 : 0); + if (routers.size() == ownRoutes && !servesWebSockets) { throw new IOException("This server has no handlers, so every request would " - + "be a 404. Add one with handler(), webSockets(), or a " - + "@RestController class for the build to generate one from."); + + "be a 404. Add a @RestController or @WebSocketMapping class " + + "for the build to generate one from."); } if (routers.isEmpty()) { // A WEBSOCKET-ONLY SERVER IS A REAL SERVER, and it is what the @@ -567,30 +1875,151 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { // context exists to leak there. This is a packaged-runtime path. boolean ownsContext = context != null && tls == null; HttpServer server; + // For EVERY server, handler-only ones too: their handlers can call + // getSession(), and skipping the settings would, among other things, + // send a TLS server's session cookie without Secure. + final Sessions sessions = Sessions.configure(config, context != null, pool, + application); + if (sessionStore != null) { + sessions.setStore(sessionStore); + } + // This server's own, which the development tools switch on. + final RequestLog requestLog = new RequestLog(); + // Whether THIS server records request metrics, known only once the + // exporter has opened below; until then, and on a server that turned + // metrics off, its requests stay out of the process's histograms. + // Atomic: the workers are running before it is known, and a plain + // write would not have to become visible to them at all. + final java.util.concurrent.atomic.AtomicBoolean instrumented = + new java.util.concurrent.atomic.AtomicBoolean(); + boolean bound = false; + final Application app = application; + final boolean track = application != null && application.tracksCurrentRequest(); try { - server = HttpServer.start(host, listenPort, listenBacklog, workerCount, - new Chain(chain), context, webSocketEndpoints == null ? null - : new HttpServer.WebSocketRoutes() { - @Override - public void register(HttpServer.WebSocketRegistry registry) - throws Exception { - // The same two arguments a Handlers factory gets, - // and for the same reason: an endpoint that needs - // the database declares it rather than reaching - // for a static. - webSocketEndpoints.register(registry, pool, manager); - } - }); - } catch (Exception err) { - if (ownsContext) { + HttpServer.WebSocketRoutes routes = null; + if (webSocketEndpoints != null || application != null) { + routes = new HttpServer.WebSocketRoutes() { + @Override + public void register(HttpServer.WebSocketRegistry direct) + throws Exception { + // Every endpoint's callbacks carry this server's + // executors, as its HTTP requests do. + HttpServer.WebSocketRegistry registry = withTasks(direct, tasks, + active != null ? active : Tracing.NONE, inFlight); + // The same two arguments a Handlers factory gets, + // and for the same reason: an endpoint that needs + // the database declares it rather than reaching + // for a static. + if (webSocketEndpoints != null) { + webSocketEndpoints.register(registry, pool, manager); + } + if (application != null) { + application.registerWebSockets(registry); + } + } + }; + } + server = HttpServer.start(bindHost, listenPort, listenBacklog, workerCount, + new Serving(chain, sessions, tasks, requestLog, instrumented, app, track, + active != null ? active : Tracing.NONE, inFlight), + context, routes, active != null ? active : Tracing.NONE); + bound = true; + } finally { + if (!bound && ownsContext) { context.close(); } - throw err; } + // From now on this server's virtual tasks run on its own hosts, and + // its requests are traced by its own tracer. + synchronized (tasks) { + tasks.server = server; + } + // Given to start() above, before the listener accepted anything: + // always set, with tracing off the untraced marker, so another traced + // server in the process cannot claim this one's requests. // The websocket routes went in through start() above, before the // listener began accepting -- registering them here instead left a // window in which a valid upgrade was answered as ordinary HTTP. - Backend backend = new Backend(server, pool, manager, config, drain, active); + boolean measuring = management != null; + Backend backend; + // Every failure between the bind and a Backend that owns the server + // stops the listener: otherwise the beans are destroyed and the pool + // closed by the outer cleanup while the orphaned listener keeps the + // port and answers with a torn-down application. The exporter + // refusing its configuration, an application metric that collides + // with a built-in one, a managed gauge whose Prometheus name clashes. + boolean owned = false; + // Whether the reader has anything to stop: only when open() answered + // true. False -- the contract's "nothing was started" -- is neither + // kept nor shut down: measuring can be true for the management + // endpoints alone, and keeping the reader on that had stop() shut + // down a reader that never opened. Nor one whose open() THREW, which + // starts nothing either: the likeliest reason is that the same + // reader is already exporting for another server, and shutting it + // down here stopped that server's exporter. + boolean readerOpen = false; + try { + if (metricReader != null) { + readerOpen = metricReader.open(config); + measuring |= readerOpen; + } + if (measuring) { + com.codename1.backend.metrics.Metrics.enableServer(server, pool); + instrumented.set(true); + } + backend = new Backend(server, pool, manager, config, drain, + active, application, + readerOpen ? metricReader : null, + managedBeans, sessions, tasks, requestLog, + environment == null ? new ArrayList() : environment.gauges); + owned = true; + } finally { + if (!owned) { + server.stop(0); + // The listener was accepting, so a request may already have + // built session-scoped beans; nothing later destroys them -- + // the outer clean-up ends only the singletons, and after them. + sessions.close(); + com.codename1.backend.metrics.Metrics.disableServer(server, pool); + if (readerOpen) { + metricReader.shutdown(0); + } + } + } + backend.measured = measuring; + backend.listenAddress = advertised(bindHost); + backend.inFlight = inFlight; + // Backend.stop() tears the beans down from here on. + createdApplication = null; + startingTasks = null; + // From here the Backend owns everything, so ANY failure until it is + // announced -- an MCP extension refusing its configuration as much + // as a job that cannot start -- stops it: the outer clean-up no + // longer knows the listener, the executors or the beans. + boolean running = false; + try { + if (management != null) { + managementRoute.attach(management, backend); + } + if (mcpServer != null) { + mcpRoute.attach(mcpServer, backend); + if (!quiet) { + // The line an agent's setup instructions point at. + System.out.println(mcpRoute.announce(mcpServer, "http" + + (context != null ? "s" : "") + "://" + advertised(bindHost) + + ":" + server.getPort())); + } + } + if (application != null) { + application.started(backend); + } + running = true; + } finally { + if (!running) { + backend.stop(); + } + } + backend.markReady(); if (!quiet) { announce(backend, listenPort, context != null); } @@ -639,29 +2068,6 @@ public HttpServer.Response handle(HttpServer.Request request) { } } - /// The handlers in the order they were added; the first that answers - /// wins. - private static final class Chain implements HttpServer.Handler { - private final HttpServer.Handler[] chain; - - Chain(HttpServer.Handler[] chain) { - this.chain = chain; - } - - @Override - public HttpServer.Response handle(HttpServer.Request request) throws Exception { - for (HttpServer.Handler handler : chain) { - HttpServer.Response response = handler.handle(request); - if (response != null) { - return response; - } - } - // Null is a 404 from here, which is what a router - // answers for a path it does not route. - return null; - } - } - /// Stops a server when the process is asked to shut down. private static final class StopOnSignal implements Runnable { private final Backend backend; diff --git a/vm/backend/src/com/codename1/backend/Config.java b/vm/backend/src/com/codename1/backend/Config.java index 5f6d595b767..f056d5075f3 100644 --- a/vm/backend/src/com/codename1/backend/Config.java +++ b/vm/backend/src/com/codename1/backend/Config.java @@ -68,7 +68,12 @@ /// /// 5. `application.properties`; /// -/// 6. the default the caller passed in. +/// 6. what the build compiled in from the settings annotations -- +/// `@ServerConfig`, `@SessionConfig` and the rest of +/// `com.codename1.backend.annotations` -- so a file or the environment can +/// still change anything the source says; +/// +/// 7. the default the caller passed in. /// /// A value may reference an environment variable as `${NAME}` or /// `${NAME:fallback}`. That resolution happens when the value is READ @@ -154,20 +159,49 @@ public final class Config { "cn1.otel.queue.size", "OTEL_BSP_MAX_QUEUE_SIZE", "cn1.otel.batch.size", "OTEL_BSP_MAX_EXPORT_BATCH_SIZE", "cn1.otel.export.delayMillis", "OTEL_BSP_SCHEDULE_DELAY", + "cn1.otel.metrics.endpoint", "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "cn1.otel.metrics.headers", "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "cn1.otel.metrics.protocol", "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", + "cn1.otel.metrics.intervalMillis", "OTEL_METRIC_EXPORT_INTERVAL", }; private final Properties profileFile; private final Properties baseFile; + private final Properties compiled; private final String profile; private final List loadedFrom; private Config(Properties baseFile, Properties profileFile, String profile, List loadedFrom) { + this(baseFile, profileFile, new Properties(), profile, loadedFrom); + } + + private Config(Properties baseFile, Properties profileFile, Properties compiled, + String profile, List loadedFrom) { this.baseFile = baseFile; this.profileFile = profileFile; + this.compiled = compiled; this.profile = profile; this.loadedFrom = loadedFrom; } + /// This configuration over a bottom layer of compiled-in values: pairs of + /// key and value, in that order, which every other layer overrides. The + /// generated entry point passes what the settings annotations say. + public Config withCompiledDefaults(String[] keysAndValues) { + if (keysAndValues == null || keysAndValues.length == 0) { + return this; + } + if (keysAndValues.length % 2 != 0) { + throw new IllegalArgumentException("Compiled settings come in key and value pairs"); + } + Properties merged = new Properties(); + merged.putAll(compiled); + for (int iter = 0 ; iter < keysAndValues.length ; iter += 2) { + merged.setProperty(keysAndValues[iter], keysAndValues[iter + 1]); + } + return new Config(baseFile, profileFile, merged, profile, loadedFrom); + } + /// Reads the configuration for this process: the active profile, then the two /// properties files, from [#LOCATION] or the working directory. public static Config load() throws IOException { @@ -246,6 +280,55 @@ public String get(String key) throws IOException { return get(key, null); } + /// A path a handler of the server's own answers, in the canonical form every + /// request target is compared in: escaped unreserved characters decoded, + /// other escapes upper-cased. Compared as configured, `/%6dcp` was announced + /// and never matched a request, which reaches handlers as `/mcp`. + public String getRoutePath(String key, String fallback) throws IOException { + String value = get(key, fallback); + return value == null ? null : HttpServer.canonicalDeclaredPath(value.trim()); + } + + /// A secret that clients present in a request header -- a bearer token -- or + /// null when no layer has one. + /// + /// Refused when no request could carry it exactly: the request parser rejects + /// a control character in a header value and trims surrounding spaces and + /// tabs, so a token read with a secret file's trailing newline would answer + /// every request 401 while the server looked healthy. The value stays out of + /// the message; it is a secret. + /// + /// #### Throws + /// + /// - `IOException`: when the value holds a control character, or leading or + /// trailing whitespace + public String getHeaderSecret(String key) throws IOException { + String value = get(key); + if (value == null || value.length() == 0 || sendableFieldValue(value)) { + return value; + } + throw new IOException(key + " holds a control character or leading or trailing " + + "whitespace, so no request can carry it in a header; remove it (a secret " + + "file's trailing newline is the usual cause)"); + } + + /// Whether a request header could carry this value exactly: no control + /// character but tab, and no space or tab at either end, which the request + /// parser trims as surrounding whitespace. + static boolean sendableFieldValue(String value) { + int last = value.length() - 1; + for (int iter = 0 ; iter <= last ; iter++) { + char c = value.charAt(iter); + if ((c < 0x20 && c != '\t') || c == 0x7f) { + return false; + } + if ((iter == 0 || iter == last) && (c == ' ' || c == '\t')) { + return false; + } + } + return true; + } + /// The value for `key`, or `fallback` when no layer has one. public String get(String key, String fallback) throws IOException { String raw = raw(key); @@ -305,6 +388,27 @@ public String describe() { return out.toString(); } + /// Every key the properties files and the compiled-in settings set, sorted, + /// for a development listing. The environment is not enumerated: it holds + /// far more than this server's settings, and secrets that are none of its + /// business. + public List keys() { + java.util.TreeSet names = new java.util.TreeSet(); + java.util.Enumeration e = compiled.propertyNames(); + while (e.hasMoreElements()) { + names.add(e.nextElement()); + } + e = baseFile.propertyNames(); + while (e.hasMoreElements()) { + names.add(e.nextElement()); + } + e = profileFile.propertyNames(); + while (e.hasMoreElements()) { + names.add(e.nextElement()); + } + return new ArrayList(names); + } + /// The value as written, before any ${} in it is resolved. private String raw(String key) { String value = fromProcess(key); @@ -323,7 +427,11 @@ private String raw(String key) { if (value != null) { return value; } - return baseFile.getProperty(key); + value = baseFile.getProperty(key); + if (value != null) { + return value; + } + return compiled.getProperty(key); } /// A system property of that name, then the environment variable it maps to. diff --git a/vm/backend/src/com/codename1/backend/CronSchedule.java b/vm/backend/src/com/codename1/backend/CronSchedule.java new file mode 100644 index 00000000000..f68bbed82ab --- /dev/null +++ b/vm/backend/src/com/codename1/backend/CronSchedule.java @@ -0,0 +1,579 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.util.TimeZone; + +/// When a cron job fires: six sets of allowed values, held as bit masks. +/// +/// The build parses every literal `@Scheduled(cron = ...)` expression +/// and writes the masks into the entry point it generates, so a server never +/// parses one and a malformed one never reaches a server. [#parse] exists +/// for the other case -- an expression read from configuration, which only exists +/// at start-up -- and implements the same grammar; the plugin's tests hold the two +/// to agreeing on every expression they know. +/// +/// The grammar is Spring's six fields: second, minute, hour, day of month, +/// month, day of week. Each is `*` (or `?` for the two day fields), a +/// value, a range `a-b`, a step `*``/n` or `a-b/n` or +/// `a/n`, or a comma-separated list of those. Months and days take their +/// three-letter English names; Sunday is 0 or 7. `L` as the day of month is +/// the last day of the month. The macros `@yearly`, `@annually`, +/// `@monthly`, `@weekly`, `@daily`, `@midnight` and +/// `@hourly` stand for their usual expressions. +/// +/// When both day fields are restricted a day must match BOTH, which is +/// Spring's rule rather than Unix cron's either-or. +public final class CronSchedule { + /// Bit n set when second n fires. + private final long seconds; + private final long minutes; + private final long hours; + /// Bits 1-31. + private final long daysOfMonth; + /// Bits 1-12. + private final long months; + /// Bits 0-6, Sunday = 0. + private final long daysOfWeek; + private final boolean lastDayOfMonth; + private final String zone; + /// Milliseconds east of UTC, for UTC and a fixed offset. + private final int fixedOffset; + /// Null for UTC and a fixed offset. + private final TimeZone timeZone; + private final String expression; + + private static final long DAY = 86400000L; + /// How many steps to look for a match before concluding there is none. A full + /// Gregorian cycle -- 400 years, 146097 days, after which weekdays and leap + /// days repeat exactly -- because day of month AND day of week must both + /// match, and a valid schedule can go decades between matches: + /// `0 0 0 29 2 MON` fired in 2016 and next fires in 2044. A step that + /// rejects a day moves to the next day or month, so a cycle's worth of steps + /// -- plus a margin for the few that stay within a day at a DST transition -- + /// covers every date there is; a schedule that matches nothing in a cycle + /// matches nothing ever. + private static final int SEARCH_DAYS = 146097 + 366; + + /// The masks the build computed. Called by generated code. + /// + /// #### Parameters + /// + /// - `zone`: @param zone a zone ID, a fixed offset such as `+02:00`, or null or + /// empty for UTC + public CronSchedule(long seconds, long minutes, long hours, long daysOfMonth, + long months, long daysOfWeek, boolean lastDayOfMonth, String zone, + String expression) { + this.seconds = seconds; + this.minutes = minutes; + this.hours = hours; + this.daysOfMonth = daysOfMonth; + this.months = months; + this.daysOfWeek = daysOfWeek; + this.lastDayOfMonth = lastDayOfMonth; + this.expression = expression; + this.zone = zone == null || zone.length() == 0 ? "UTC" : zone; + int fixed = parseFixedOffset(this.zone); + if (fixed != Integer.MIN_VALUE) { + fixedOffset = fixed; + timeZone = null; + } else { + fixedOffset = 0; + timeZone = TimeZone.getTimeZone(this.zone); + if (!knownZone(this.zone, timeZone)) { + // A misspelt zone is not an error to TimeZone: the JDK answers + // GMT, and the job would fire at UTC times instead of local ones + // with nothing said. The build checks a literal zone; this is the + // check for one from configuration or code. + throw new IllegalArgumentException("Unknown time zone \"" + this.zone + + "\" for cron expression " + expression); + } + } + if (seconds == 0 || minutes == 0 || hours == 0 || months == 0 || daysOfWeek == 0 + || (daysOfMonth == 0 && !lastDayOfMonth)) { + throw new IllegalArgumentException("A cron field allows no value: " + expression); + } + } + + /// Whether `id` names a zone this runtime knows. The JDK falls back to + /// GMT for an id it does not know, which is detectable; where a tz database + /// is installed the id must name one of its files, which also covers the + /// packaged runtime, whose platform lookup cannot say "unknown". With neither + /// to consult, the id is taken as given. + static boolean knownZone(String id, TimeZone tz) { + if ("UTC".equals(id) || "GMT".equalsIgnoreCase(id)) { + return true; + } + if (id.indexOf("..") >= 0 || id.startsWith("/")) { + return false; + } + if ("GMT".equals(tz.getID())) { + return false; + } + java.io.File database = new java.io.File("/usr/share/zoneinfo"); + if (database.isDirectory()) { + return new java.io.File(database, id).isFile(); + } + return true; + } + + /// The expression this was made from, for a listing. + public String getExpression() { + return expression; + } + + /// The zone it is read in. + public String getZone() { + return zone; + } + + /// Parses an expression at run time, for one that came from configuration. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: naming what is wrong + public static CronSchedule parse(String expression, String zone) { + if (expression == null) { + throw new IllegalArgumentException("No cron expression"); + } + String text = expression.trim(); + String macro = macro(text); + if (macro != null) { + text = macro; + } + String[] fields = split(text); + if (fields.length != 6) { + throw new IllegalArgumentException("A cron expression has six fields -- second, " + + "minute, hour, day of month, month, day of week -- and \"" + expression + + "\" has " + fields.length + ". (A five-field Unix expression needs a " + + "leading 0 for the second.)"); + } + boolean last = "L".equalsIgnoreCase(fields[3]); + long dom = last ? 0 : field(fields[3], 1, 31, null, true, expression, "day of month"); + long dow = field(fields[5], 0, 7, DAYS, true, expression, "day of week"); + if ((dow & (1L << 7)) != 0) { + dow = (dow & ~(1L << 7)) | 1L; + } + long months = field(fields[4], 1, 12, MONTHS, false, expression, "month"); + if (!last && !canEverMatch(dom, months)) { + // What the build refuses for a literal expression, refused here for + // one read from configuration: accepted, it would start and then + // never fire, and the scheduler would quietly disable the job. + throw new IllegalArgumentException("\"" + expression + "\" names no day that " + + "exists in the months it allows, so it would never fire"); + } + return new CronSchedule( + field(fields[0], 0, 59, null, false, expression, "second"), + field(fields[1], 0, 59, null, false, expression, "minute"), + field(fields[2], 0, 23, null, false, expression, "hour"), + dom, months, dow, last, zone, expression); + } + + /// Whether some allowed day of month exists in some allowed month. + private static boolean canEverMatch(long days, long months) { + int[] lengths = {0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + for (int m = 1 ; m <= 12 ; m++) { + if ((months & (1L << m)) == 0) { + continue; + } + for (int d = 1 ; d <= lengths[m] ; d++) { + if ((days & (1L << d)) != 0) { + return true; + } + } + } + return false; + } + + private static final String[] MONTHS = {"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", + "AUG", "SEP", "OCT", "NOV", "DEC"}; + private static final String[] DAYS = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}; + + /// The expansion of a macro, or null. + public static String macro(String text) { + // equalsIgnoreCase, never toLowerCase: the latter follows the device + // locale, and under a Turkish one "@HOURLY" folds its I to a dotless i. + if ("@yearly".equalsIgnoreCase(text) || "@annually".equalsIgnoreCase(text)) { + return "0 0 0 1 1 *"; + } + if ("@monthly".equalsIgnoreCase(text)) { + return "0 0 0 1 * *"; + } + if ("@weekly".equalsIgnoreCase(text)) { + return "0 0 0 * * 0"; + } + if ("@daily".equalsIgnoreCase(text) || "@midnight".equalsIgnoreCase(text)) { + return "0 0 0 * * *"; + } + if ("@hourly".equalsIgnoreCase(text)) { + return "0 0 * * * *"; + } + return null; + } + + private static String[] split(String text) { + java.util.List out = new java.util.ArrayList(); + int start = -1; + for (int iter = 0 ; iter <= text.length() ; iter++) { + boolean space = iter == text.length() || text.charAt(iter) == ' ' + || text.charAt(iter) == '\t'; + if (space) { + if (start >= 0) { + out.add(text.substring(start, iter)); + start = -1; + } + } else if (start < 0) { + start = iter; + } + } + String[] result = new String[out.size()]; + for (int iter = 0 ; iter < result.length ; iter++) { + result[iter] = (String) out.get(iter); + } + return result; + } + + private static long field(String text, int min, int max, String[] names, boolean question, + String expression, String what) { + if ("*".equals(text) || (question && "?".equals(text))) { + return range(min, max, 1); + } + long mask = 0; + int start = 0; + while (start <= text.length()) { + int comma = text.indexOf(',', start); + if (comma < 0) { + comma = text.length(); + } + String part = text.substring(start, comma); + if (part.length() == 0) { + throw bad(expression, what, text); + } + int step = 1; + int slash = part.indexOf('/'); + String span = part; + if (slash >= 0) { + step = number(part.substring(slash + 1), null, 0, expression, what); + if (step <= 0) { + throw bad(expression, what, text); + } + span = part.substring(0, slash); + } + int from; + int to; + if ("*".equals(span) || (question && "?".equals(span))) { + from = min; + to = max; + } else { + int dash = span.indexOf('-'); + if (dash > 0) { + from = number(span.substring(0, dash), names, min, expression, what); + to = number(span.substring(dash + 1), names, min, expression, what); + } else { + from = number(span, names, min, expression, what); + to = slash >= 0 ? max : from; + } + } + if (from < min || to > max || from > to) { + throw new IllegalArgumentException("The " + what + " field of \"" + expression + + "\" names " + part + ", outside " + min + "-" + max); + } + mask |= range(from, to, step); + start = comma + 1; + } + return mask; + } + + private static int number(String text, String[] names, int base, String expression, + String what) { + if (names != null) { + for (int iter = 0 ; iter < names.length ; iter++) { + if (names[iter].equalsIgnoreCase(text)) { + return iter + base; + } + } + } + if (text.length() == 0 || text.length() > 4) { + throw bad(expression, what, text); + } + int value = 0; + for (int iter = 0 ; iter < text.length() ; iter++) { + char c = text.charAt(iter); + if (c < '0' || c > '9') { + throw bad(expression, what, text); + } + value = value * 10 + (c - '0'); + } + return value; + } + + private static IllegalArgumentException bad(String expression, String what, String text) { + return new IllegalArgumentException("The " + what + " field of \"" + expression + + "\" cannot be read: \"" + text + "\""); + } + + private static long range(int from, int to, int step) { + long mask = 0; + for (int iter = from ; iter <= to ; iter += step) { + mask |= 1L << iter; + } + return mask; + } + + /// Milliseconds east of UTC for "UTC", "Z", "GMT" or "+hh:mm"; MIN_VALUE otherwise. + public static int parseFixedOffset(String zone) { + if ("UTC".equalsIgnoreCase(zone) || "Z".equalsIgnoreCase(zone) + || "GMT".equalsIgnoreCase(zone)) { + return 0; + } + if (zone.length() != 6 || (zone.charAt(0) != '+' && zone.charAt(0) != '-') + || zone.charAt(3) != ':') { + return Integer.MIN_VALUE; + } + int h = digits(zone, 1); + int m = digits(zone, 4); + if (h < 0 || m < 0 || h > 18 || m > 59) { + return Integer.MIN_VALUE; + } + int offset = (h * 60 + m) * 60000; + return zone.charAt(0) == '-' ? -offset : offset; + } + + private static int digits(String s, int at) { + char a = s.charAt(at); + char b = s.charAt(at + 1); + if (a < '0' || a > '9' || b < '0' || b > '9') { + return -1; + } + return (a - '0') * 10 + (b - '0'); + } + + /// The first time strictly after `afterMillis` this fires, as epoch + /// milliseconds, or -1 if it never does (the 30th of February). + public long next(long afterMillis) { + long t = afterMillis - floorMod(afterMillis, 1000L) + 1000L; + for (int guard = 0 ; guard < SEARCH_DAYS ; guard++) { + long local = t + offsetAt(t); + long day = floorDiv(local, DAY); + int timeOfDay = (int) (local - day * DAY); + int[] civil = civilFromDays(day); + int month = civil[1]; + if ((months & (1L << month)) == 0) { + t = startOfLocalDay(firstDayOfNextMonth(civil), t); + continue; + } + if (!dayMatches(civil[0], month, civil[2], (int) floorMod(day + 4, 7))) { + t = startOfLocalDay(day + 1, t); + continue; + } + int found = nextTimeOfDay(timeOfDay / 1000); + if (found < 0) { + t = startOfLocalDay(day + 1, t); + continue; + } + long candidateLocal = day * DAY + found * 1000L; + long first = localToUtc(candidateLocal, true); + if (first == Long.MIN_VALUE) { + // A wall-clock time the zone skips -- 02:30 on a spring-forward + // night -- does not exist, and toUtc lands on another one (03:30) + // that the fields never asked for. Skipped, as Quartz does: search + // on from the first real time after the gap. + long candidate = toUtc(candidateLocal); + t = candidate > t ? candidate : t + 1000L; + continue; + } + // A time the clocks pass twice -- 01:30 on a fall-back night -- fires + // ONCE, at the first of the two, as Quartz and Spring fire it. Once the + // first has passed that day's is spent, whichever side of the change + // the search starts on: after a run at the first, the scheduler asks + // again from just past it, and a search that could still return the + // second would run a daily job twice that night. A server started + // inside the repeated hour missed that day's run like any other. + if (first > afterMillis) { + return first; + } + // Spent: search on from just past its SECOND occurrence, so the other + // times of that day -- 02:00 for an every-half-hour job -- still count. + long second = localToUtc(candidateLocal, false); + t = second >= t ? second + 1000L : t + 1000L; + } + return -1; + } + + private boolean dayMatches(int year, int month, int day, int dow) { + if ((daysOfWeek & (1L << dow)) == 0) { + return false; + } + if (lastDayOfMonth) { + return day == daysInMonth(year, month); + } + return (daysOfMonth & (1L << day)) != 0; + } + + /// The first allowed second of the day at or after `second`, or -1. + private int nextTimeOfDay(int second) { + int h = second / 3600; + int m = (second / 60) % 60; + int s = second % 60; + for (int hh = h ; hh < 24 ; hh++) { + if ((hours & (1L << hh)) == 0) { + continue; + } + for (int mm = hh == h ? m : 0 ; mm < 60 ; mm++) { + if ((minutes & (1L << mm)) == 0) { + continue; + } + int ss = nextBit(seconds, hh == h && mm == m ? s : 0, 59); + if (ss >= 0) { + return hh * 3600 + mm * 60 + ss; + } + } + } + return -1; + } + + private static int nextBit(long mask, int from, int max) { + for (int iter = from ; iter <= max ; iter++) { + if ((mask & (1L << iter)) != 0) { + return iter; + } + } + return -1; + } + + private long startOfLocalDay(long day, long notBefore) { + long t = toUtc(day * DAY); + return t <= notBefore ? notBefore + 1000L : t; + } + + private static long firstDayOfNextMonth(int[] civil) { + int y = civil[0]; + int m = civil[1] + 1; + if (m > 12) { + m = 1; + y++; + } + return daysFromCivil(y, m, 1); + } + + /// Milliseconds east of UTC at the instant `utc`. + private int offsetAt(long utc) { + if (timeZone == null) { + return fixedOffset; + } + int raw = timeZone.getRawOffset(); + long standard = utc + raw; + long day = floorDiv(standard, DAY); + int[] civil = civilFromDays(day); + int dow = (int) floorMod(day + 4, 7); + return timeZone.getOffset(1, civil[0], civil[1] - 1, civil[2], dow + 1, + (int) (standard - day * DAY)); + } + + /// The instant a wall-clock time falls on: the earlier of the two in a + /// fall-back overlap when `earliest` is set, the later otherwise, and + /// Long.MIN_VALUE when the zone skips that time. The offsets half a day + /// either side are the only two it can have: transitions are months apart. + private long localToUtc(long local, boolean earliest) { + if (timeZone == null) { + return local - fixedOffset; + } + int raw = timeZone.getRawOffset(); + long before = local - offsetAt(local - raw - DAY / 2); + long after = local - offsetAt(local - raw + DAY / 2); + boolean beforeValid = before + offsetAt(before) == local; + boolean afterValid = after + offsetAt(after) == local; + if (beforeValid && afterValid) { + return earliest ? Math.min(before, after) : Math.max(before, after); + } + if (beforeValid) { + return before; + } + return afterValid ? after : Long.MIN_VALUE; + } + + private long toUtc(long local) { + if (timeZone == null) { + return local - fixedOffset; + } + long guess = local - timeZone.getRawOffset(); + long utc = local - offsetAt(guess); + // Once more, for a local time on the other side of a transition from the + // raw-offset guess. + return local - offsetAt(utc); + } + + static long floorDiv(long value, long divisor) { + long q = value / divisor; + if ((value % divisor != 0) && ((value < 0) != (divisor < 0))) { + q--; + } + return q; + } + + static long floorMod(long value, long divisor) { + return value - floorDiv(value, divisor) * divisor; + } + + static int daysInMonth(int year, int month) { + switch (month) { + case 2: + boolean leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + return leap ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } + } + + static long daysFromCivil(int y, int m, int d) { + int adjusted = y - (m <= 2 ? 1 : 0); + long era = (adjusted >= 0 ? adjusted : adjusted - 399) / 400; + int yoe = (int) (adjusted - era * 400); + int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097L + doe - 719468L; + } + + static int[] civilFromDays(long z) { + long shifted = z + 719468L; + long era = (shifted >= 0 ? shifted : shifted - 146096) / 146097; + long doe = shifted - era * 146097; + long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + long y = yoe + era * 400; + long doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + long mp = (5 * doy + 2) / 153; + long d = doy - (153 * mp + 2) / 5 + 1; + long m = mp + (mp < 10 ? 3 : -9); + return new int[] {(int) (y + (m <= 2 ? 1 : 0)), (int) m, (int) d}; + } + + @Override + public String toString() { + return expression + ("UTC".equals(zone) ? "" : " (" + zone + ")"); + } +} diff --git a/vm/backend/src/com/codename1/backend/DataAccessException.java b/vm/backend/src/com/codename1/backend/DataAccessException.java new file mode 100644 index 00000000000..6867c6ed594 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/DataAccessException.java @@ -0,0 +1,57 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.io.IOException; + +/// A database operation failed: a statement the engine refused, a connection +/// that could not be opened or was lost, a query that returned more rows than +/// one. +/// +/// Named after Spring's, and treated the same way by `@Transactional`: +/// it rolls the transaction back although it is a checked exception. Spring's +/// rule is that unchecked exceptions roll back and checked ones commit, and in +/// Spring a failed statement rolls back because its JDBC layer throws the +/// unchecked `DataAccessException`. This runtime's data methods declare +/// [IOException], so their failures are this subtype of it, and the build's +/// rollback rule lists it beside `RuntimeException` and `Error`. A +/// plain `IOException` of the application's own -- a file it could not +/// read -- still commits, as in Spring; `rollbackFor` and +/// `noRollbackFor` change either. +public class DataAccessException extends IOException { + public DataAccessException(String message) { + super(message); + } + + public DataAccessException(String message, Throwable cause) { + super(message, cause); + } + + /// `err` as a data access failure, keeping its message and cause. + static DataAccessException of(IOException err) { + if (err instanceof DataAccessException) { + return (DataAccessException) err; + } + return new DataAccessException(err.getMessage(), err); + } +} diff --git a/vm/backend/src/com/codename1/backend/DataSource.java b/vm/backend/src/com/codename1/backend/DataSource.java index 125d6768969..05a0fafca94 100644 --- a/vm/backend/src/com/codename1/backend/DataSource.java +++ b/vm/backend/src/com/codename1/backend/DataSource.java @@ -220,7 +220,23 @@ public static DataSource of(Database db) throws IOException { /// Takes a connection, blocking until one is free. Release it in a finally, or /// prefer the methods that cannot leak one. - public synchronized Database borrow() throws IOException { + public Database borrow() throws IOException { + // THE THREAD'S TRANSACTION FIRST. A thread inside a @Transactional + // method gets that transaction's connection, so every statement it runs + // through this pool -- directly, through a dao, through a session -- + // is part of it. Outside the lock: joining may send a BEGIN, and a + // round trip to the database is not something to do while every other + // borrower waits on this monitor. + Database joined = Transactions.joined(this); + if (joined != null) { + return joined; + } + return borrowFromPool(); + } + + /// Takes a connection from the pool itself, never the thread's transaction's. + /// What a transaction borrows its own connection with. + synchronized Database borrowFromPool() throws IOException { // Checked before the idle list rather than only when it is empty: close() // can run while a borrower still holds a connection, and that borrower's // finally releases afterwards, so the list can be non-empty after closing. @@ -228,7 +244,7 @@ public synchronized Database borrow() throws IOException { throw new IOException("This pool is closed"); } long deadline = borrowTimeoutMillis == 0 ? 0 - : System.currentTimeMillis() + borrowTimeoutMillis; + : AsyncTask.deadline(System.currentTimeMillis(), borrowTimeoutMillis); while (true) { while (!idle.isEmpty()) { Database candidate = (Database) idle.remove(idle.size() - 1); @@ -248,7 +264,14 @@ public synchronized Database borrow() throws IOException { // life of the pool, and the alternative -- releasing the lock to // connect -- lets several threads decide at once that the pool // has room and open more connections than it is allowed. - Database opened = Database.open(url); + Database opened; + try { + opened = Database.open(url); + } catch (IOException err) { + // Spring's CannotGetJdbcConnectionException is a data access + // failure too, and rolls a transaction back. + throw DataAccessException.of(err); + } try { configure(opened); } catch (IOException err) { @@ -297,7 +320,17 @@ public synchronized Database borrow() throws IOException { } /// Returns a borrowed connection to the pool. - public synchronized void release(Database db) { + public void release(Database db) { + // The transaction's connection goes back when the transaction ends, not + // when one of the statements inside it does. + if (Transactions.isJoined(this, db)) { + return; + } + releaseToPool(db); + } + + /// [#release], for a connection that is certainly not a transaction's. + synchronized void releaseToPool(Database db) { if (db == null) { return; } @@ -336,6 +369,24 @@ public Object withConnection(Work body) throws Exception { /// second connection, which the database sees as another session entirely, and /// on SQLite it will simply block against the write lock the first one holds. public Object inTransaction(Work body) throws Exception { + Database joined = Transactions.joined(this); + if (joined != null) { + // Already inside the thread's transaction, which every engine refuses + // to nest: run as part of it, the way a joined @Transactional method + // does -- including its failure. A body that throws leaves the whole + // transaction unable to commit, as it rolls back when it runs alone; + // otherwise a caller catching the exception would commit what the body + // wrote before failing. + try { + return body.run(joined); + } catch (Exception err) { + Transactions.markRollbackOnly(this); + throw err; + } catch (Error err) { + Transactions.markRollbackOnly(this); + throw err; + } + } return withConnection(new TransactionWork(body)); } diff --git a/vm/backend/src/com/codename1/backend/Database.java b/vm/backend/src/com/codename1/backend/Database.java index 12f456e2520..04cee06f7ab 100644 --- a/vm/backend/src/com/codename1/backend/Database.java +++ b/vm/backend/src/com/codename1/backend/Database.java @@ -260,15 +260,21 @@ public synchronized int execute(String sql, Object[] params) throws IOException } private int executeUntraced(String sql, Object[] params) throws IOException { - params = portableParameters(params); - String rendered = bind(sql, params); - if (sqlite != null) { - return sqlite.execute(rendered, params); - } - if (postgres != null) { - return postgres.execute(rendered, params); + // Every engine's failure surfaces as a DataAccessException, which + // @Transactional rolls back for; see that class. + try { + params = portableParameters(params); + String rendered = bind(sql, params); + if (sqlite != null) { + return sqlite.execute(rendered, params); + } + if (postgres != null) { + return postgres.execute(rendered, params); + } + return mysql.execute(rendered, params); + } catch (IOException err) { + throw DataAccessException.of(err); } - return mysql.execute(rendered, params); } /// Runs a query and returns every row as a column-name to value map. @@ -297,15 +303,19 @@ public synchronized List query(String sql, Object[] params) throws IOException { } private List queryUntraced(String sql, Object[] params) throws IOException { - params = portableParameters(params); - String rendered = bind(sql, params); - if (sqlite != null) { - return sqlite.query(rendered, params); - } - if (postgres != null) { - return postgres.query(rendered, params); + try { + params = portableParameters(params); + String rendered = bind(sql, params); + if (sqlite != null) { + return sqlite.query(rendered, params); + } + if (postgres != null) { + return postgres.query(rendered, params); + } + return mysql.query(rendered, params); + } catch (IOException err) { + throw DataAccessException.of(err); } - return mysql.query(rendered, params); } /// The one row a query is expected to return, or null when it returns none. @@ -322,7 +332,7 @@ public synchronized Map queryOne(String sql, Object[] params) throws IOException return null; } if (rows.size() > 1) { - throw new IOException("Expected at most one row and the query returned " + throw new DataAccessException("Expected at most one row and the query returned " + rows.size() + ": [" + sql + "]"); } return (Map) rows.get(0); @@ -362,14 +372,18 @@ public synchronized long insert(String sql, Object[] params, String idColumn) // and add none of their own. Span span = Tracing.startDatabase(dialect.getName(), sql); if (span == null) { - return insertUntraced(sql, params, idColumn); + try { + return insertUntraced(sql, params, idColumn); + } catch (IOException err) { + throw DataAccessException.of(err); + } } Throwable failure = null; try { return insertUntraced(sql, params, idColumn); } catch (IOException err) { failure = err; - throw err; + throw DataAccessException.of(err); } catch (RuntimeException err) { failure = err; throw err; @@ -743,6 +757,61 @@ public synchronized void beginTransaction() throws IOException { transactionOwner = Thread.currentThread(); } + /// Begins a transaction like [#beginTransaction], telling the engine + /// when it will only read. + /// + /// A read-only one is a real promise on two of the engines: PostgreSQL and + /// MySQL refuse a write inside it, and SQLite takes no write lock up front + /// (BEGIN DEFERRED rather than IMMEDIATE), so it does not queue other writers + /// behind a transaction that will never write. + public synchronized void beginTransaction(boolean readOnly) throws IOException { + if (!readOnly) { + beginTransaction(); + return; + } + awaitTransactionOwner(); + if (managedTransaction) { + throw new IOException("Transaction already active"); + } + if (mysql != null) { + mysql.beginReadOnly(); + } else { + execute(sqlite == null ? "BEGIN READ ONLY" : "BEGIN DEFERRED", null); + } + managedTransaction = true; + transactionOwner = Thread.currentThread(); + } + + /// Marks a savepoint inside the open transaction, which + /// [#rollbackToSavepoint] can return to without ending the transaction. + /// The name must be a plain identifier; it is written into the statement. + public synchronized void savepoint(String name) throws IOException { + savepointControl("SAVEPOINT", name); + } + + /// Undoes everything since the savepoint, keeping the transaction open. + public synchronized void rollbackToSavepoint(String name) throws IOException { + savepointControl("ROLLBACK TO SAVEPOINT", name); + } + + /// Forgets a savepoint, keeping what was done since it. + public synchronized void releaseSavepoint(String name) throws IOException { + savepointControl("RELEASE SAVEPOINT", name); + } + + private void savepointControl(String verb, String name) throws IOException { + awaitTransactionOwner(); + if (!managedTransaction) { + throw new IOException("No active transaction"); + } + String checked = Dialect.checkIdentifier(name); + if (mysql != null) { + mysql.savepoint(verb, checked); + return; + } + execute(verb + " " + checked, null); + } + /// Commits the transaction opened through this API. public synchronized void commitTransaction() throws IOException { awaitTransactionOwner(); @@ -776,7 +845,12 @@ public synchronized Object transaction(Work body) throws Exception { try { rollbackTransaction(); } catch (Exception err) { - System.err.println("rollback failed: " + err); + // Closed, not left for a pool to lend again: a connection + // whose ROLLBACK failed still believes a transaction is open + // and owned, and its next borrower would wait on that owner + // for ever. A pool discards a closed connection. + System.err.println("rollback failed; closing the connection: " + err); + close(); } } } diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index fd88377bc65..678e0116e97 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -114,6 +114,20 @@ public static final class Request { private byte[] canonicalTarget; private int canonicalLength; private boolean canonicalChecked; + /// The sessions of the server serving this request; set by Backend. + Sessions sessions; + /// See [#endedSessions]. + private List endedSessions; + /// The sessions this request counts as using; see Sessions.enter. + List sessionsInUse; + /// The id each of those sessions had when this request found it, so its + /// end can tell a rotation of its own from one another request made. + Map sessionIdsFound; + /// This request's session once looked up; see [#getSession(boolean)]. + private HttpSession session; + private boolean sessionResolved; + /// The request's `@RequestScope` beans, by the slot the build gave each. + private Object[] scopedBeans; Request(String method, String target, String version, byte[] raw, int[] slices, int headerCount, String body) { @@ -541,6 +555,100 @@ void releaseRetained() { this.slices = null; this.body = null; this.headers = null; + this.session = null; + this.sessionResolved = false; + this.scopedBeans = null; + this.sessions = null; + this.endedSessions = null; + this.sessionsInUse = null; + this.sessionIdsFound = null; + } + + /// The session of this request, creating one if it has none. + public HttpSession getSession() { + return getSession(true); + } + + /// The session this request belongs to: the one its cookie names, or, with + /// `create` set and no valid cookie, a new one the response will + /// send a cookie for. Null when there is none and `create` is false. + /// See [Sessions] for the cookie and where sessions are kept. + public HttpSession getSession(boolean create) { + if (sessionResolved && session != null && !session.isValid()) { + // Invalidated earlier in this request: it is not the session any + // more. Kept aside so the end of the request still deletes it and + // clears its cookie; a lookup now answers as if there were none, + // and create starts a new one. + if (endedSessions == null) { + endedSessions = new ArrayList(1); + } + endedSessions.add(session); + session = null; + } + if (sessionResolved && (session != null || !create)) { + return session; + } + if (sessions == null) { + // Sessions are stored, and their cookie sent, when a Backend + // finishes the request; a bare HttpServer has nothing that would, + // so a session here would silently never persist. + throw new IllegalStateException("Sessions need a server started with " + + "Backend.builder(), which stores them and sends their cookie"); + } + Sessions owner = sessions; + String presented = sessionResolved ? null + : Sessions.cookieValue(getHeader("cookie"), owner.getCookieName()); + try { + session = owner.find(presented, create); + } catch (IOException err) { + throw new IllegalStateException("The session store failed: " + + err.getMessage(), err); + } + sessionResolved = true; + if (session != null) { + // Found under the cookie it was looked up by; a session this + // request created was found under its own id. + owner.enter(this, session, session.isNew() || presented == null + ? session.getId() : presented); + } + return session; + } + + /// The value of one cookie the client sent, or null. + public String getCookie(String name) { + return Sessions.cookieValue(getHeader("cookie"), name); + } + + /// The session, if this request looked it up. + HttpSession resolvedSession() { + return session; + } + + /// The sessions this request invalidated and then replaced, oldest first, + /// or null. All of them: one request can end a session, start another and + /// end that too, and each has to be deleted when it finishes. + List endedSessions() { + return endedSessions; + } + + /// This request's `@RequestScope` beans, grown to hold at least + /// `count`. Called by generated code. + public Object[] scopedBeans(int count) { + if (scopedBeans == null || scopedBeans.length < count) { + Object[] grown = new Object[count]; + if (scopedBeans != null) { + System.arraycopy(scopedBeans, 0, grown, 0, scopedBeans.length); + } + scopedBeans = grown; + } + return scopedBeans; + } + + /// The request's beans, handed over for destruction and forgotten. + Object[] takeScopedBeans() { + Object[] out = scopedBeans; + scopedBeans = null; + return out; } /// The body, once it has been read. The only field that is not known when @@ -570,6 +678,13 @@ void reset(Conn conn, String method, String target, String version, byte[] raw, this.canonicalTarget = null; this.canonicalLength = 0; this.canonicalChecked = false; + this.session = null; + this.sessionResolved = false; + this.scopedBeans = null; + this.sessions = null; + this.endedSessions = null; + this.sessionsInUse = null; + this.sessionIdsFound = null; } /// For HTTP/2, whose headers arrive already decoded from the HPACK state -- @@ -855,6 +970,26 @@ void releaseRetained() { this.extraHeaders = null; } + /// Closes the file this response would have sent, for one that will never + /// be written: the writer is what normally closes it, and a response + /// replaced by a 500 never reaches the writer. + void discard() { + if (fileFd >= 0) { + StaticFiles.closeFile(fileFd); + fileFd = -1; + } + } + + /// Serialises a deferred JSON body now, into an ordinary one, so the + /// object graph may change after this without changing the response. + void serializeDeferredJson() { + if (hasDeferredJson) { + body = bytes(Json.write(deferredJson)); + deferredJson = null; + hasDeferredJson = false; + } + } + /// Re-points this Response. Every field is assigned with no "unchanged" /// case: a field left behind describes the PREVIOUS response on this /// connection, and deferredJson is the one that would hurt -- it makes the @@ -899,6 +1034,20 @@ public Response(int status, String contentType, byte[] body, Map extraHeaders) { this.extraHeaders = extraHeaders; } + /// This response with other extra headers, as a NEW object. For a header + /// the server adds to one request's answer -- a session cookie -- because + /// the Response a handler returns may be a constant shared by every + /// request, and writing into it would hand one client's cookie to the next. + /// Only one of the two is ever sent, so a file descriptor it carries still + /// has exactly one owner. + Response withHeaders(Map headers) { + Response copy = new Response(status, contentType, body, fileFd, fileOffset, + fileLength, headers); + copy.deferredJson = deferredJson; + copy.hasDeferredJson = hasDeferredJson; + return copy; + } + /// A response whose body is a file. The server sends it with sendfile where /// the platform has it, so the bytes never enter user space, and CLOSES the /// descriptor when it is done -- a handler that returned one must not. @@ -941,6 +1090,20 @@ public int getStatus() { return status; } + /// Adds a response header and returns this Response. A copy is made + /// rather than writing into the map the Response was built with, which + /// may be a caller's constant; a header the server owns -- Date, + /// Content-Length -- is refused when the response is written. + public Response header(String name, String value) { + Map copy = new java.util.LinkedHashMap(); + if (extraHeaders != null) { + copy.putAll(extraHeaders); + } + copy.put(name, value); + extraHeaders = copy; + return this; + } + private static byte[] bytes(String s) { try { return s == null ? new byte[0] : s.getBytes("UTF-8"); @@ -1617,6 +1780,17 @@ public static HttpServer start(String host, int port, int backlog, int workerCou public static HttpServer start(String host, int port, int backlog, int workerCount, Handler handler, Tls tls, WebSocketRoutes webSockets) throws IOException { + return start(host, port, backlog, workerCount, handler, tls, webSockets, null); + } + + /// [#start(String,int,int,int,Handler,Tls,WebSocketRoutes)], tracing its + /// requests with `tracer` from the first one it accepts. Set afterwards, a + /// request accepted in between found no tracer of this server's and was + /// reported to the process-wide one -- another server's service and + /// credentials. Tracing.NONE marks a server that traces nothing. + static HttpServer start(String host, int port, int backlog, int workerCount, + Handler handler, Tls tls, WebSocketRoutes webSockets, + Tracer tracer) throws IOException { // BEFORE THE BIND, because the two arms failed this differently and both // badly. Java SE's Executors.newFixedThreadPool throws for a non-positive // count -- but only after the listener and the reactor are open, so the @@ -1691,6 +1865,9 @@ public static HttpServer start(String host, int port, int backlog, int workerCou final HttpServer server = new HttpServer(listener, reactor, useVirtualThreads ? null : Executors.newFixedThreadPool(workerCount), workerCount, handler, tls); + if (tracer != null) { + server.serverTracer = tracer; + } // Before any thread that could accept a connection exists. A callback that // throws takes the whole start down rather than leaving a server running // with half its routes -- the same answer a Handlers factory gets. @@ -1748,6 +1925,15 @@ public static HttpServer start(String host, int port, int backlog, int workerCou try { for (int iter = 0 ; iter < hostCount ; iter++) { server.vtHosts[iter] = new VtHost(iter == 0 ? reactor : Reactor.create()); + // So a background task handed to this host runs now rather + // than after the host's poll times out. Without one the task + // still runs, just up to a poll interval later. + int[] wake = Reactor.createWakePipe(); + if (wake != null) { + server.vtHosts[iter].wakeRead = wake[0]; + server.vtHosts[iter].wakeWrite = wake[1]; + server.vtHosts[iter].poller.add(wake[0], Reactor.READ); + } } server.pollers = new Thread[hostCount]; for (int iter = 0 ; iter < hostCount ; iter++) { @@ -1800,6 +1986,11 @@ private static void abandonStart(ServerSocket listener, HttpServer server) { server.releaseVirtualThreadSlot(); } + /// Whether this server speaks TLS, so a client of it must use https. + public boolean isSecure() { + return tls != null; + } + public int getPort() { return listener.getPort(); } @@ -1836,6 +2027,9 @@ public Map getMetrics() { out.put("openStaticFiles", Integer.valueOf(StaticFiles.openFileCount())); // Only when tracing is on, so a server that does not trace reports // exactly what it always has. + // The process's tracer: one process runs one Backend (Backend.claimProcess), + // so it is this server's. Two servers with different tracers in one JVM + // is not a supported shape and is refused at start. Tracing.metrics(out); return out; } @@ -1948,6 +2142,11 @@ public void stop(int drainMillis) { // A quarter of the drain window at most, so the goodbyes cannot eat the // time the requests in flight were promised. closeWebSocketsForShutdown(Math.max(1, Math.min(drainMillis / 4, 2000))); + // Background tasks on virtual threads get the same window as the requests: + // each host keeps resuming its own after the loop below ends -- a yielded + // task can only ever run on the host it started on -- and the sweep waits + // for them before it frees anything. + taskDrainDeadline = System.currentTimeMillis() + drainMillis; running = false; reactor.remove(listener.getFd()); listener.close(); @@ -1981,6 +2180,7 @@ public void stop(int drainMillis) { break; } } + awaitTaskDrain(callerFd); // Whatever is still open at the deadline is an idle keep-alive connection or // a request that overran; both have to be closed rather than held forever. // @@ -2141,6 +2341,9 @@ public void stop(int drainMillis) { /// Set while a websocket callback is running, so stop() called from inside one /// can discount the turn that callback is itself holding. private static final ThreadLocal SERVING_WS = new ThreadLocal(); + /// Set while a websocket handshake runs application code -- the router, + /// getSubprotocols(), onOpen() -- holding only its connection. + private static final ThreadLocal SERVING_UPGRADE = new ThreadLocal(); /// The headers a refusal this server invented may carry: none of the handler's. /// @@ -2155,6 +2358,14 @@ private static List refusalHeaders() { return new ArrayList(); } + /// Whether the calling thread is serving a request or a websocket callback of + /// some server -- work a stop() drain waits for. + static boolean servingOnThisThread() { + return SERVING_FD.get() != null || Boolean.TRUE.equals(SERVING_WS.get()) + || Boolean.TRUE.equals(SERVING_H2.get()) + || Boolean.TRUE.equals(SERVING_UPGRADE.get()); + } + /// workOutstanding(), minus what the calling handler is itself holding. /// /// A handler that calls stop() holds one in-flight request and one active @@ -2172,6 +2383,13 @@ private boolean workOutstandingBesidesCaller(int callerFd) { || http2Turns.get() > 0 || webSocketTurns.get() > 1 || pendingWork.get() > 0; } + if (Boolean.TRUE.equals(SERVING_UPGRADE.get())) { + // A handshake holds its connection and nothing else: no request is + // in flight for it and no websocket turn is running yet. + return inFlightRequests.get() > 0 || activeRequests.get() > 1 + || http2Turns.get() > 0 || webSocketTurns.get() > 0 + || pendingWork.get() > 0; + } if (callerFd < 0) { return workOutstanding(); } @@ -2233,6 +2451,9 @@ private void closePollers() { VtHost[] hosts = vtHosts; if (hosts != null) { for (VtHost host : hosts) { + if (host != null) { + releaseTaskInbox(host); + } // Host 0 SHARES the main reactor (see start()), so closing every // host's poller and then the reactor would close that one twice -- // a double free of one descriptor, not the release of two. @@ -2356,6 +2577,378 @@ private void sweepIdlePooledConnections() { private static final java.util.concurrent.atomic.AtomicBoolean VT_SLOT_TAKEN = new java.util.concurrent.atomic.AtomicBoolean(); + /// Background tasks waiting for a virtual thread, by the token its body asks with. + private static final Map VIRTUAL_TASKS = new java.util.HashMap(); + private static long nextVirtualTask; + /// Round-robin cursor over the hosts for new tasks. + private static int nextTaskHost; + + /// Whether a background task handed to [#submitVirtualTask] would get a + /// virtual thread: this build has them and a server is running on them. + static boolean acceptsVirtualTasks() { + HttpServer server = ACTIVE_SERVER; + return server != null && server.running && server.vtHosts != null; + } + + /// The tracer this server's requests report to, when it has one of its own. + /// Rather than whichever tracer is installed process-wide -- which, with two + /// servers, is the one that started last. Set by start() before any worker + /// can accept. + private volatile Tracer serverTracer; //NOPMD AvoidUsingVolatile - written before the workers start, read by every worker + + /// The server holding the virtual-thread slot, or null. + static HttpServer activeServer() { + return ACTIVE_SERVER; + } + + /// Queues `task` on a host of `server`, which must be the server + /// that owns the virtual-thread slot; false -- run it elsewhere -- when it is + /// not, is stopping, or runs no virtual threads. + /// + /// The host is woken through its pipe and creates the virtual thread itself, + /// because a virtual thread's VM state belongs to the host that runs it. + static boolean submitVirtualTask(Runnable task, HttpServer server) { + if (task == null || server == null || server != ACTIVE_SERVER || !server.running + || server.vtHosts == null) { + return false; + } + VtHost[] hosts = server.vtHosts; + long token; + VtHost host; + synchronized (VIRTUAL_TASKS) { + token = ++nextVirtualTask; + VIRTUAL_TASKS.put(Long.valueOf(token), task); + // Reduced to THIS server's hosts: the cursor is static and survives a + // restart, and a server started again with fewer hosts indexed past + // the end of its array with the old one's. + int index = nextTaskHost % hosts.length; + nextTaskHost = index + 1 >= hosts.length ? 0 : index + 1; + host = hosts[index]; + if (host != null) { + VIRTUAL_TASK_HOSTS.put(Long.valueOf(token), host); + } + } + if (host == null) { + takeVirtualTask(token); + return false; + } + synchronized (host.inbox) { + // Rechecked under the lock releaseTaskInbox takes: a shutdown that won + // the race has already drained this inbox and closed its wake pipe, + // and a token added after that would sit there with no host to run + // it -- its executor's count stuck, its Future never done. + if (host.inboxClosed) { + takeVirtualTask(token); + return false; + } + host.inbox.add(Long.valueOf(token)); + // Under the same lock that closes the pipe: released first, a + // submitter paused here could write after the descriptor was closed + // and its number reused -- the wake byte landing in another socket. + if (host.wakeWrite >= 0) { + Reactor.wake(host.wakeWrite); + } + } + return true; + } + + /// At shutdown: the tasks still queued on a host go to a platform thread + /// rather than being lost with it, and its wake pipe is closed. + private static void releaseTaskInbox(VtHost host) { + Long[] tokens; + synchronized (host.inbox) { + tokens = (Long[]) host.inbox.toArray(new Long[host.inbox.size()]); + host.inbox.clear(); + host.inboxClosed = true; + // Closed under the lock a submitter wakes the host under, so no wake + // can be written between the close and the -1. + if (host.wakeRead >= 0) { + ServerSocket.closeFd(host.wakeRead); + ServerSocket.closeFd(host.wakeWrite); + host.wakeRead = -1; + host.wakeWrite = -1; + } + } + for (Long element : tokens) { + Runnable task = takeVirtualTask(element.longValue()); + if (task != null) { + TaskExecutor.fallBack(task); + } + } + } + + /// The task a virtual thread's body runs, handed over once. + static Runnable takeVirtualTask(long token) { + synchronized (VIRTUAL_TASKS) { + VIRTUAL_TASK_HOSTS.remove(Long.valueOf(token)); + return (Runnable) VIRTUAL_TASKS.remove(Long.valueOf(token)); + } + } + + /// The host each queued virtual task was given to, by token. + private static final Map VIRTUAL_TASK_HOSTS = new java.util.HashMap(); + /// The host running the calling virtual task, while it runs; see awaitTaskDrain. + private static final ThreadLocal TASK_HOST = new ThreadLocal(); + + /// Runs the virtual task queued under `token`, on the virtual thread made + /// for it, remembering its host: a stop() it calls must not wait for that + /// host's drain, which cannot start until the stop returns. + static void runVirtualTask(long token) { + Runnable task; + Object host; + synchronized (VIRTUAL_TASKS) { + host = VIRTUAL_TASK_HOSTS.remove(Long.valueOf(token)); + task = (Runnable) VIRTUAL_TASKS.remove(Long.valueOf(token)); + } + if (task == null) { + return; + } + TASK_HOST.set(host); + try { + task.run(); + } finally { + TASK_HOST.set(null); + } + } + + /// Gives the tasks queued on `me` their virtual threads and puts them on + /// the run ring. A task that cannot have one -- no stack to be had -- goes to + /// a platform thread instead: it is not the task's fault, and this host must + /// not run it inline, where a long task would stall every connection it owns. + private void drainTaskInbox(VtHost me) { + Long[] tokens; + synchronized (me.inbox) { + if (me.inbox.isEmpty()) { + return; + } + tokens = (Long[]) me.inbox.toArray(new Long[me.inbox.size()]); + me.inbox.clear(); + } + for (Long element : tokens) { + long token = element.longValue(); + Runnable queued; + synchronized (VIRTUAL_TASKS) { + queued = (Runnable) VIRTUAL_TASKS.get(Long.valueOf(token)); + } + long handle = VirtualThread.createTask(token, VT_STACK_BYTES); + if (handle != 0 && queued != null) { + // Kept so a task abandoned at shutdown can still be told. + me.tasks.put(Long.valueOf(handle), queued); + } + if (handle != 0) { + me.taskTokens.put(Long.valueOf(handle), element); + } + if (handle == 0) { + Runnable task = takeVirtualTask(token); + if (task != null) { + TaskExecutor.fallBack(task); + } + continue; + } + me.ringAdd(handle); + } + } + + /// Gives a task's virtual thread its turn. A task has no descriptor, so every + /// answer but FINISHED puts it back on the ring: a yield -- which the VM + /// reports as parked-on-I/O when nothing says otherwise -- would otherwise + /// leave it waiting on a poller that will never report it. + private void advanceTask(VtHost me, long handle) { + int state = VirtualThread.resume(handle); + if (state == VirtualThread.FINISHED) { + me.tasks.remove(Long.valueOf(handle)); + me.taskTokens.remove(Long.valueOf(handle)); + VirtualThread.free(handle); + return; + } + if (state == VirtualThread.WAITING) { + parkWaiter(me, handle, true); + return; + } + ringOrNap(me, handle); + } + + /// Parks a virtual thread that answered WAITING: its outbound descriptors go + /// on this host's poller, and it comes back to the ring when one is ready or + /// its timeout runs out. Until then this host runs everybody else -- which is + /// the whole point: a slow database query or HTTP call used to hold the host + /// inside recv(), and there is one host per core. + /// + /// A wait with no descriptors is a wait on the timeout alone -- libcurl + /// between retries -- and is a nap. + private static void parkWaiter(VtHost me, long handle, boolean task) { + int count = VirtualThread.waitCount(handle); + long timeout = VirtualThread.waitTimeout(handle); + long deadline = timeout < 0 ? 0 : System.currentTimeMillis() + timeout; + if (count == 0) { + if (deadline == 0) { + me.ringAdd(handle); + } else { + me.napping.put(Long.valueOf(handle), Long.valueOf(deadline)); + } + return; + } + for (int iter = 0 ; iter < count ; iter++) { + int fd = VirtualThread.waitDescriptor(handle, iter); + try { + me.poller.add(fd, VirtualThread.waitEvents(handle, iter)); + } catch (IOException err) { + // Cannot be watched: undo what was registered and let it run. It + // re-tests its descriptor with a zero-time poll when resumed, so + // it sees a real error for itself rather than hanging here. + for (int undo = 0 ; undo < iter ; undo++) { + int registered = VirtualThread.waitDescriptor(handle, undo); + me.poller.remove(registered); + me.setWaiter(registered, 0); + } + me.ringAdd(handle); + return; + } + me.setWaiter(fd, handle); + } + me.addWaiter(handle, deadline, task); + } + + /// Ends the wait of the virtual thread at `index`: its descriptors come off + /// the poller BEFORE it runs, so none is ever registered while the virtual + /// thread could close it, and it goes back on the ring. Any of its other + /// descriptors still waiting in `ready` from this same poll are blanked, or + /// the loop would take one for a new connection. + private static void wakeWaiterAt(VtHost me, int index, int[] ready, int from, int n) { + long handle = me.waiters[index]; + int count = VirtualThread.waitCount(handle); + for (int iter = 0 ; iter < count ; iter++) { + int fd = VirtualThread.waitDescriptor(handle, iter); + me.poller.remove(fd); + me.setWaiter(fd, 0); + for (int later = from ; later < n ; later++) { + if (ready[later] == fd) { + ready[later] = -1; + } + } + } + me.removeWaiterAt(index); + me.ringAdd(handle); + } + + /// Wakes the waiters whose timeout has run out by `now` -- the call they are + /// in fails with its ordinary deadline error -- and answers the earliest + /// deadline still ahead, or Long.MAX_VALUE for none. + private static long wakeExpiredWaiters(VtHost me, long now) { + long earliest = Long.MAX_VALUE; + int iter = 0; + while (iter < me.waiterCount) { + long at = me.waiterDeadlines[iter]; + if (at != 0 && at <= now) { + // Swapped with the last, so the same index is looked at again. + wakeWaiterAt(me, iter, null, 0, 0); + continue; + } + if (at != 0 && at < earliest) { + earliest = at; + } + iter++; + } + return earliest; + } + + /// For a stopping host, which no longer runs its poll loop but still owes its + /// background tasks their finish: waits up to `timeoutMillis` for a waiting + /// TASK's descriptor and wakes it. Connection waiters are left where they + /// are -- stop() reclaims those, and from another thread. Any other + /// descriptor that reports is taken off the poller, since nothing will serve + /// it now and a level-triggered set would report it on every call. + private static void pumpTaskWaiters(VtHost me, int timeoutMillis) { + int[] ready = new int[READY_CAPACITY]; + int n; + try { + n = me.poller.await(ready, timeoutMillis); + } catch (IOException err) { + n = 0; + } + for (int iter = 0 ; iter < n ; iter++) { + int fd = ready[iter]; + if (fd < 0) { + continue; + } + if (fd == me.wakeRead) { + Reactor.drainWake(fd); + continue; + } + int index = me.waiterIndex(me.waiterFor(fd)); + if (index >= 0 && me.waiterIsTask[index]) { + wakeWaiterAt(me, index, ready, iter + 1, n); + } else if (index < 0) { + me.poller.remove(fd); + me.setArmed(fd, false); + } + } + long now = System.currentTimeMillis(); + int iter = 0; + while (iter < me.waiterCount) { + long at = me.waiterDeadlines[iter]; + if (me.waiterIsTask[iter] && at != 0 && at <= now) { + wakeWaiterAt(me, iter, null, 0, 0); + continue; + } + iter++; + } + } + + /// Naps a virtual thread asked for, by handle, until when. Written by the + /// virtual thread just before it yields and read by its host just after, + /// on the same OS thread; a map because every host shares it. + private static final java.util.HashMap NAP_REQUESTS = new java.util.HashMap(); + + /// Yields the calling virtual thread until `untilMillis`, or thereabouts: + /// its host leaves it off the run ring until then. A plain yield when the + /// caller is not a virtual thread. For waiters -- an @Async Future polled + /// from a handler -- that would otherwise be resumed again immediately. + static void napUntil(long untilMillis) { + long self = VirtualThread.current(); + if (self != 0) { + synchronized (NAP_REQUESTS) { + NAP_REQUESTS.put(Long.valueOf(self), Long.valueOf(untilMillis)); + } + } + VirtualThread.yieldNow(); + } + + /// Puts a virtual thread that yielded back on the ring -- or, when it asked + /// to nap and the time is still ahead, on this host's nap table instead. + private void ringOrNap(VtHost me, long handle) { + Long until; + synchronized (NAP_REQUESTS) { + until = (Long) NAP_REQUESTS.remove(Long.valueOf(handle)); + } + if (until != null && until.longValue() > System.currentTimeMillis()) { + me.napping.put(Long.valueOf(handle), until); + return; + } + me.ringAdd(handle); + } + + /// Moves the naps due by `now` back to the ring; answers the earliest + /// still ahead, or Long.MAX_VALUE for none. + private static long wakeNappers(VtHost me, long now) { + if (me.napping.isEmpty()) { + return Long.MAX_VALUE; + } + long earliest = Long.MAX_VALUE; + java.util.Iterator it = me.napping.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + long until = ((Long) e.getValue()).longValue(); + if (until <= now) { + it.remove(); + me.ringAdd(((Long) e.getKey()).longValue()); + } else if (until < earliest) { + earliest = until; + } + } + return earliest; + } + /// What a connection's virtual thread runs. Reached from native code only, /// which is also what keeps it from being dead-code eliminated. /// @@ -2411,10 +3004,126 @@ private static final class VtHost { int ringHead = 0; int ringCount = 0; + /// Tokens of background tasks other threads handed to this host. The one + /// piece of a host that another thread writes, so it is locked; the host + /// takes the whole list at once, at the top of its loop. + final ArrayList inbox = new ArrayList(); + /// The wake pipe polled with the connections, or -1 where there is none. + int wakeRead = -1; + int wakeWrite = -1; + boolean ringEmpty() { return ringCount == 0; } + /// Set, under the inbox lock, once a stopping host has finished its tasks. + boolean tasksDrained; + + /// Set, under the inbox lock, once shutdown has drained it for good. + boolean inboxClosed; + + /// The task each background virtual thread on this host runs, by handle. + /// Touched only by the host thread. + final java.util.HashMap tasks = new java.util.HashMap(); + /// Each task handle's token in VIRTUAL_TASKS. A task abandoned before its + /// first turn never reached Tasks.runVirtual, which is what removes it, + /// so the abandon has to -- or the static map keeps the task, its + /// arguments and the beans it captured for the life of the process. + final java.util.HashMap taskTokens = new java.util.HashMap(); + /// Virtual threads napping until a time, by handle, OFF the run ring: a + /// waiter that is resumed again at once only to find nothing done yet + /// keeps its host polling with a zero timeout, one core busy for as long + /// as it waits. Touched only by the host thread. + final java.util.HashMap napping = new java.util.HashMap(); + + /// Virtual threads parked on an OUTBOUND descriptor -- a database socket, + /// a TLS peer, libcurl's -- by that descriptor. Registered with this + /// host's poller only for as long as the wait lasts, so an entry here and + /// a registration there always come and go together. Touched only by the + /// host thread. + long[] waiterByFd = new long[64]; + /// The waiting virtual threads themselves, with when each stops waiting + /// (0 for never) and whether it is a background task rather than a + /// connection -- recorded at park time, because a stopping server frees + /// connection handles from another thread and asking a freed one is a + /// use-after-free. Unordered; removal swaps in the last entry. + long[] waiters = new long[16]; + long[] waiterDeadlines = new long[16]; + boolean[] waiterIsTask = new boolean[16]; + int waiterCount; + + long waiterFor(int fd) { + return fd >= 0 && fd < waiterByFd.length ? waiterByFd[fd] : 0; + } + + void setWaiter(int fd, long handle) { + if (fd < 0) { + return; + } + if (fd >= waiterByFd.length) { + int size = waiterByFd.length; + while (size <= fd) { + size = size * 2; + } + long[] grown = new long[size]; + System.arraycopy(waiterByFd, 0, grown, 0, waiterByFd.length); + waiterByFd = grown; + } + waiterByFd[fd] = handle; + } + + void addWaiter(long handle, long deadline, boolean task) { + if (waiterCount == waiters.length) { + int size = waiters.length * 2; + long[] grownWaiters = new long[size]; + long[] grownDeadlines = new long[size]; + boolean[] grownTasks = new boolean[size]; + System.arraycopy(waiters, 0, grownWaiters, 0, waiterCount); + System.arraycopy(waiterDeadlines, 0, grownDeadlines, 0, waiterCount); + System.arraycopy(waiterIsTask, 0, grownTasks, 0, waiterCount); + waiters = grownWaiters; + waiterDeadlines = grownDeadlines; + waiterIsTask = grownTasks; + } + waiters[waiterCount] = handle; + waiterDeadlines[waiterCount] = deadline; + waiterIsTask[waiterCount] = task; + waiterCount++; + } + + void removeWaiterAt(int index) { + waiterCount--; + waiters[index] = waiters[waiterCount]; + waiterDeadlines[index] = waiterDeadlines[waiterCount]; + waiterIsTask[index] = waiterIsTask[waiterCount]; + waiters[waiterCount] = 0; + } + + int waiterIndex(long handle) { + for (int iter = 0 ; iter < waiterCount ; iter++) { + if (waiters[iter] == handle) { + return iter; + } + } + return -1; + } + + int taskWaiters() { + int count = 0; + for (int iter = 0 ; iter < waiterCount ; iter++) { + if (waiterIsTask[iter]) { + count++; + } + } + return count; + } + + boolean hasQueuedTasks() { + synchronized (inbox) { + return !inbox.isEmpty(); + } + } + void ringAdd(long handle) { if (ringCount == ring.length) { // Growth allocates, which is why the ring starts big enough that @@ -2678,26 +3387,205 @@ private VtHost ownerOf(int fd) { return vtHosts[index]; } + /// Until when a stopping host keeps running its background tasks. + private volatile long taskDrainDeadline; //NOPMD AvoidUsingVolatile - set by stop(), read by every host thread + + /// After the loop: the background tasks this host is running, run to the end + /// or to the drain deadline. Only they are resumed -- no polling, no + /// connections, which stop() is taking down -- and a host that exits with a + /// yielded task still in its ring would leave that task's Future unfinished + /// and its executor's active count stuck forever. + private void drainTasksAfterStop(VtHost me) { + try { + while (System.currentTimeMillis() < taskDrainDeadline) { + drainTaskInbox(me); + // Napping tasks are still running ones; the drain resumes them too. + wakeNappers(me, Long.MAX_VALUE); + int budget = me.ringCount; + int tasks = 0; + while (budget-- > 0 && !me.ringEmpty()) { + long handle = me.ringTake(); + if (VirtualThread.descriptorOf(handle) < 0) { + advanceTask(me, handle); + tasks++; + } else { + // A connection's: stop() reclaims those. + me.ringAdd(handle); + } + } + if (tasks == 0 && !me.hasQueuedTasks()) { + // A task parked on a database or HTTP call is still running; + // wait for its descriptor rather than abandoning it. + if (me.taskWaiters() == 0) { + return; + } + pumpTaskWaiters(me, (int) Math.max(1, Math.min(50, + taskDrainDeadline - System.currentTimeMillis()))); + } + } + // Out of time. A task that never had its first turn has no frames: + // its stack is freed and its Future failed. One that STARTED is never + // freed -- free() does not unwind, so its finally blocks and monitor + // exits would never run, and a lock it holds would stay owned by a + // thread that no longer exists. It goes on running past the deadline + // instead, as a platform task that overruns does; the drain is over + // for the rest of the stop either way. + int abandoned = 0; + int overrunning = 0; + wakeNappers(me, Long.MAX_VALUE); + int left = me.ringCount; + while (left-- > 0 && !me.ringEmpty()) { + long handle = me.ringTake(); + if (VirtualThread.descriptorOf(handle) >= 0) { + me.ringAdd(handle); + continue; + } + Long token = (Long) me.taskTokens.get(Long.valueOf(handle)); + // Present only until runVirtual takes it on the task's first turn. + Runnable unstarted = token == null ? null : takeVirtualTask(token.longValue()); + if (unstarted == null) { + me.ringAdd(handle); + overrunning++; + continue; + } + Runnable task = (Runnable) me.tasks.remove(Long.valueOf(handle)); + me.taskTokens.remove(Long.valueOf(handle)); + VirtualThread.free(handle); + TaskExecutor.abandoned(task != null ? task : unstarted); + abandoned++; + } + // Parked on outbound I/O means STARTED: those overrun like any other. + overrunning += me.taskWaiters(); + if (abandoned > 0) { + System.err.println(abandoned + " background task(s) on virtual threads did " + + "not start within the shutdown window and were dropped"); + } + if (overrunning > 0) { + System.err.println(overrunning + " background task(s) on virtual threads are " + + "still running past the shutdown window; they are left to finish"); + synchronized (me.inbox) { + me.tasksDrained = true; + } + finishOverrunningTasks(me); + } + } finally { + synchronized (me.inbox) { + me.tasksDrained = true; + } + } + } + + /// Resumes a stopped host's started tasks until each has finished, so every + /// one unwinds -- its finally blocks run, its monitors are released -- rather + /// than having its stack freed from under it. + private void finishOverrunningTasks(VtHost me) { + while (true) { + // A napping task is a running one: back on the ring each pass. + wakeNappers(me, Long.MAX_VALUE); + if (me.ringEmpty()) { + if (me.taskWaiters() == 0) { + return; + } + pumpTaskWaiters(me, 50); + continue; + } + int budget = me.ringCount; + int tasks = 0; + while (budget-- > 0 && !me.ringEmpty()) { + long handle = me.ringTake(); + if (VirtualThread.descriptorOf(handle) < 0) { + advanceTask(me, handle); + tasks++; + } else { + me.ringAdd(handle); + } + } + if (tasks == 0) { + if (me.taskWaiters() == 0) { + return; + } + pumpTaskWaiters(me, 50); + } + } + } + + /// Waits, until the drain deadline, for every host to finish its tasks -- + /// except the host running a handler that called stop(): it cannot reach its + /// drain until this returns, so waiting for it waited out the whole window, + /// which the drain above already goes out of its way not to do. + private void awaitTaskDrain(int callerFd) { + VtHost[] hosts = vtHosts; + if (hosts == null) { + return; + } + // The caller's host: a handler's is the owner of its descriptor, a + // virtual task's the host it was given to. Either cannot drain until + // this returns, and waiting for it waited out the whole window. + VtHost callersHost = callerFd >= 0 ? ownerOf(callerFd) : (VtHost) TASK_HOST.get(); + while (System.currentTimeMillis() < taskDrainDeadline + 50) { + boolean all = true; + for (VtHost element : hosts) { + if (element != null && element != callersHost) { //NOPMD CompareObjectsWithEquals - hosts are compared by identity + synchronized (element.inbox) { + all &= element.tasksDrained; + } + } + } + if (all) { + return; + } + try { + Thread.sleep(20); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return; + } + } + } + /// One host thread: run whoever is ready, then poll for more. /// /// Everything it touches belongs to it. The run queue is a plain LinkedList /// because no other thread can reach it, and the descriptor table is a plain - /// long\[\] for the same reason -- affinity is what buys that, and it is worth + /// long[] for the same reason -- affinity is what buys that, and it is worth /// more than the lock it saves, because it is also what makes the VM's /// per-thread allocator state correct under a parked virtual thread. private void runVirtualThreadHost(int index) { VtHost me = vtHosts[index]; + try { + runVirtualThreadHostLoop(me, index); + } finally { + // Every way out, the early returns on a failed poller included, or + // stop() would wait out the whole window for a flag never set. + drainTasksAfterStop(me); + } + } + + private void runVirtualThreadHostLoop(VtHost me, int index) { int[] ready = new int[READY_CAPACITY]; int listenFd = listener.getFd(); boolean owner = (index == 0); // only one host accepts while (running) { + drainTaskInbox(me); // Runnable virtual threads first: they wait for a turn, not for the // network, so polling before running them would delay them by the // whole poll timeout. boolean ranSome = drainRunnable(me); + long nextNap = wakeNappers(me, System.currentTimeMillis()); + // Outbound waits whose timeout ran out go back to the ring, and the + // poll below sleeps no longer than the next one is due. + nextNap = Math.min(nextNap, wakeExpiredWaiters(me, System.currentTimeMillis())); + int timeout = 250; + if (ranSome || !me.ringEmpty()) { + timeout = 0; + } else if (nextNap != Long.MAX_VALUE) { + // Sleep in the poll until the earliest nap is due, not the full + // idle interval: the napper wakes on time, and nothing spins. + timeout = (int) Math.max(0, Math.min(250, nextNap - System.currentTimeMillis())); + } int n; try { - n = me.poller.await(ready, (ranSome || !me.ringEmpty()) ? 0 : 250); + n = me.poller.await(ready, timeout); // On ELAPSED TIME, not on an idle poll. Sweeping only when a poll // came back empty meant a host that always had at least one event // never swept at all -- and a client can keep that true with a @@ -2718,6 +3606,11 @@ private void runVirtualThreadHost(int index) { } for (int iter = 0 ; iter < n ; iter++) { int fd = ready[iter]; + if (fd == me.wakeRead && fd >= 0) { + // A task was queued; the top of the loop picks it up. + Reactor.drainWake(fd); + continue; + } if (owner && fd == listenFd) { acceptAll(); try { @@ -2730,6 +3623,19 @@ private void runVirtualThreadHost(int index) { } continue; } + // An OUTBOUND descriptor a virtual thread is parked on. Checked + // before advance(), which would take a descriptor it has no handle + // for as a newly accepted connection. + long waiter = me.waiterFor(fd); + if (waiter != 0) { + int slot = me.waiterIndex(waiter); + if (slot >= 0) { + wakeWaiterAt(me, slot, ready, iter + 1, n); + } else { + me.setWaiter(fd, 0); + } + continue; + } advance(me, fd, me.handleFor(fd)); } } @@ -2742,7 +3648,12 @@ private boolean drainRunnable(VtHost me) { while (budget-- > 0 && !me.ringEmpty()) { long handle = me.ringTake(); any = true; - advance(me, VirtualThread.descriptorOf(handle), handle); + int fd = VirtualThread.descriptorOf(handle); + if (fd < 0) { + advanceTask(me, handle); + } else { + advance(me, fd, handle); + } } return any; } @@ -2815,6 +3726,20 @@ private void advance(VtHost me, int fd, long handle) { VirtualThread.free(handle); return; } + if (state == VirtualThread.WAITING) { + // Parked on an OUTBOUND descriptor -- the handler is inside a database + // or HTTP call. Its own connection comes off the poller for the same + // reason the RUNNABLE path below takes it off: readable bytes from + // the client must not resume a virtual thread that is waiting on + // something else, and a level-triggered set would report them on + // every poll. The next ordinary park re-arms it. No idle deadline + // either: the handler is working, and the call it is in carries its + // own timeout. + me.poller.remove(fd); + me.setArmed(fd, false); + parkWaiter(me, handle, false); + return; + } if (state == VirtualThread.RUNNABLE) { // Take it out of the poller for as long as it sits in the ring. It is // neither running nor parked, so a readable descriptor would otherwise @@ -2824,7 +3749,7 @@ private void advance(VtHost me, int fd, long handle) { // disarming as it delivered. me.poller.remove(fd); me.setArmed(fd, false); - me.ringAdd(handle); + ringOrNap(me, handle); return; } // Parked on I/O: start its clock. Nothing else will, and without it a @@ -3131,6 +4056,21 @@ private WebSocket routeWebSocket(Request request, String path) throws Exception /// means the request was refused with a status and the connection is still an /// ordinary HTTP one. private boolean tryUpgrade(Conn conn, int fd, long session, Request request, Span span) { + // Marked for the handshake: the router, getSubprotocols() and onOpen() are + // application code, and a stop() from one of them must defer its + // teardown and discount this connection, as from any other callback -- + // unmarked, it waited out the drain on its own connection and then tore + // down the beans and the pool under the callback it was called from. + SERVING_UPGRADE.set(Boolean.TRUE); + try { + return tryUpgradeMarked(conn, fd, session, request, span); + } finally { + SERVING_UPGRADE.set(null); + } + } + + private boolean tryUpgradeMarked(Conn conn, int fd, long session, Request request, + Span span) { // THE CANONICAL PATH, which is what pathIs compares for every HTTP route. // Taking the raw target substring instead meant `/ch%61t` missed the // websocket route for `/chat` and fell through to the catch-all router or @@ -3276,6 +4216,8 @@ private boolean tryUpgrade(Conn conn, int fd, long session, Request request, Spa // its child -- and not the session, which can last for hours. Messages // after this are not spans of their own. Tracing.endServer(span, 101, onOpenError); + // The session is not the handshake: its turns are marked by the pump. + SERVING_UPGRADE.set(null); runWebSocket(fd, socket); return true; } @@ -4578,7 +5520,7 @@ private void serveOneRelease(int fd) { // the upgrade is done, and a refusal ends here with the status it // wrote. Returning before this point left every handshake, and // everything onOpen called out to, untraced. - Span handshake = Tracing.startServer(request, tls != null); + Span handshake = Tracing.startServer(request, tls != null, serverTracer); conn.writtenStatus = -1; boolean upgraded; try { @@ -4611,20 +5553,38 @@ private void serveOneRelease(int fd) { // one on this connection. Ended after the write, in the finally below, // so the span covers the response reaching the socket and a write that // fails is recorded as the failure it is. - Span span = Tracing.startServer(request, tls != null); + Span span = Tracing.startServer(request, tls != null, serverTracer); int sentStatus = -1; - Exception handlerError = null; + Throwable handlerError = null; try { try { response = handler.handle(request); if (response == null) { response = Response.text(404, "not found"); } - } catch (Exception err) { + } catch (Throwable err) { + rethrowIfFatal(err); System.err.println("handler failed: " + err); handlerError = err; response = Response.text(500, "internal error"); } + // A deferred JSON body is rendered HERE, into the connection's + // reusable buffer the writer then sends from, rather than inside + // the write: rendering runs the application's own code -- a + // Json.Writable, a generated codec refusing a cycle -- and a throw + // there is the handler's failure, owed a 500. Thrown from inside + // the write it dropped the connection with nothing sent. + if (response.hasDeferredJson) { + try { + conn.bodySink.reset(); + Json.write(response.deferredJson, conn.bodySink); + } catch (Throwable err) { + rethrowIfFatal(err); + System.err.println("handler failed: " + err); + handlerError = err; + response = Response.text(500, "internal error"); + } + } // Read before the write: writing releases what the Response held. int status = response.status; try { @@ -4983,8 +5943,8 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) // request, and ENDS when its stream closes (see http2Spans). // server.address is set here as for HTTP/1: :authority was copied // into these headers as "host" above, which is what startServer reads. - Span span = Tracing.startServer(request, tls != null); - Exception handlerError = null; + Span span = Tracing.startServer(request, tls != null, serverTracer); + Throwable handlerError = null; // -1 until the response has been SUBMITTED to the session, as on the // HTTP/1 path: a respond() that throws is a response the peer never // got, and must not be reported as the status the handler chose. @@ -5000,11 +5960,24 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) if (response == null) { response = Response.text(404, "not found"); } - } catch (Exception err) { + } catch (Throwable err) { + rethrowIfFatal(err); System.err.println("handler failed: " + err); handlerError = err; response = Response.text(500, "internal error"); } + // Rendered now for the same reason as on HTTP/1: a deferred JSON + // body runs the application's code, and a throw there is a 500. + if (response.hasDeferredJson) { + try { + response.serializeDeferredJson(); + } catch (Throwable err) { + rethrowIfFatal(err); + System.err.println("handler failed: " + err); + handlerError = err; + response = Response.text(500, "internal error"); + } + } try { boolean headOnly = "HEAD".equals(stream.getMethod()); List extra = new ArrayList(); @@ -5023,21 +5996,28 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) java.util.Iterator it = response.extraHeaders.keySet().iterator(); while (it.hasNext()) { Object key = it.next(); - Object value = response.extraHeaders.get(key); - if (key != null && value != null) { - String name = String.valueOf(key); - String text = String.valueOf(value); - // The native side splits this block on '\n', so a newline - // here is another field exactly as it is over HTTP/1.1. - if (isServerOwnedHeader(name)) { - System.err.println("dropped a response header the " - + "server owns: " + sanitizeForLog(name)); - } else if (isHeaderName(name) && isHeaderSafe(text)) { - extra.add(name + ": " + text); - } else { - System.err.println("dropped a response header whose name " - + "is not a token or whose value carries a control " - + "character: " + sanitizeForLog(name)); + // A List is several fields of one name -- Set-Cookie is the + // header that needs it, since a cookie cannot share a line. + Object raw = response.extraHeaders.get(key); + List several = raw instanceof List ? (List) raw : null; + int count = several == null ? 1 : several.size(); + for (int each = 0 ; each < count ; each++) { + Object value = several == null ? raw : several.get(each); + if (key != null && value != null) { + String name = String.valueOf(key); + String text = String.valueOf(value); + // The native side splits this block on '\n', so a newline + // here is another field exactly as it is over HTTP/1.1. + if (isServerOwnedHeader(name)) { + System.err.println("dropped a response header the " + + "server owns: " + sanitizeForLog(name)); + } else if (isHeaderName(name) && isHeaderSafe(text)) { + extra.add(name + ": " + text); + } else { + System.err.println("dropped a response header whose name " + + "is not a token or whose value carries a control " + + "character: " + sanitizeForLog(name)); + } } } } @@ -5309,7 +6289,22 @@ private static void endHttp2Span(Object[] entry, boolean sent) { } int status = sent && entry[1] instanceof Integer ? ((Integer) entry[1]).intValue() : -1; Tracing.endServer((Span) entry[0], status, - entry[2] instanceof Exception ? (Exception) entry[2] : null); + entry[2] instanceof Throwable ? (Throwable) entry[2] : null); + } + + /// Rethrows what a handler threw when the process cannot go on serving + /// after it; anything else becomes a 500. + /// + /// As Spring Boot's embedded Tomcat does: a handler's Error -- an + /// AssertionError, a NoClassDefFoundError, a StackOverflowError out of a + /// deep recursion -- is answered 500 like any exception, where it used to + /// drop the connection with no answer at all. What Tomcat rethrows, + /// VirtualMachineError other than a stack overflow, is rethrown here too: + /// after running out of memory there is nothing a 500 can promise. + static void rethrowIfFatal(Throwable err) { + if (err instanceof VirtualMachineError && !(err instanceof StackOverflowError)) { + throw (VirtualMachineError) err; + } } /// The HTTP/2 connection preface, sent by a client that opens with h2. @@ -6897,15 +7892,12 @@ private void writeResponse(Conn conn, int fd, long session, Response response, private void writeHeadAndBody(Conn conn, int fd, long session, Response response, boolean keepAlive, boolean headOnly) throws IOException { - // A deferred JSON body is serialised FIRST: Content-Length has to be - // written before it, and the only honest way to know it is to have the - // bytes. Into a second reusable buffer rather than the head's, because - // the head is not built yet. + // A deferred JSON body was rendered before this was called -- see the + // caller -- into a second reusable buffer rather than the head's, because + // Content-Length has to be written before it and the head is not built yet. byte[] deferred = null; int deferredLength = 0; if (response.hasDeferredJson) { - conn.bodySink.reset(); - Json.write(response.deferredJson, conn.bodySink); deferred = conn.bodySink.bytes(); deferredLength = conn.bodySink.length(); } @@ -6992,29 +7984,35 @@ private void writeHeadAndBody(Conn conn, int fd, long session, Response response java.util.Iterator it = response.extraHeaders.keySet().iterator(); while (it.hasNext()) { Object key = it.next(); - Object value = response.extraHeaders.get(key); - if (key != null && value != null) { - String name = String.valueOf(key); - String text = String.valueOf(value); - // A CR or LF here ENDS the field and starts another, so a value - // built from request data -- a decoded query parameter reaches a - // handler with real CRLF in it if the client sent %0d%0a -- lets - // the client write its own headers, or a second response. That is - // response splitting, and it is a cache-poisoning primitive. - // Dropped rather than escaped: there is no correct escaping, and a - // header the handler could not have meant is not worth sending. - if (isServerOwnedHeader(name)) { - System.err.println("dropped a response header the server owns: " - + sanitizeForLog(name)); - } else if (isHeaderName(name) && isHeaderSafe(text)) { - conn.put("\r\n"); - conn.put(name); - conn.put(": "); - conn.put(text); - } else { - System.err.println("dropped a response header whose name is " - + "not a token or whose value carries a control character: " - + sanitizeForLog(name)); + // A List is several fields of one name; see the HTTP/2 writer. + Object raw = response.extraHeaders.get(key); + List several = raw instanceof List ? (List) raw : null; + int count = several == null ? 1 : several.size(); + for (int each = 0 ; each < count ; each++) { + Object value = several == null ? raw : several.get(each); + if (key != null && value != null) { + String name = String.valueOf(key); + String text = String.valueOf(value); + // A CR or LF here ENDS the field and starts another, so a value + // built from request data -- a decoded query parameter reaches a + // handler with real CRLF in it if the client sent %0d%0a -- lets + // the client write its own headers, or a second response. That is + // response splitting, and it is a cache-poisoning primitive. + // Dropped rather than escaped: there is no correct escaping, and a + // header the handler could not have meant is not worth sending. + if (isServerOwnedHeader(name)) { + System.err.println("dropped a response header the server owns: " + + sanitizeForLog(name)); + } else if (isHeaderName(name) && isHeaderSafe(text)) { + conn.put("\r\n"); + conn.put(name); + conn.put(": "); + conn.put(text); + } else { + System.err.println("dropped a response header whose name is " + + "not a token or whose value carries a control character: " + + sanitizeForLog(name)); + } } } } diff --git a/vm/backend/src/com/codename1/backend/HttpSession.java b/vm/backend/src/com/codename1/backend/HttpSession.java new file mode 100644 index 00000000000..c2f89837549 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/HttpSession.java @@ -0,0 +1,332 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// State kept for one client across requests, found again through a cookie. +/// +/// ```java +/// @PostMapping("/login") +/// public void login(HttpServer.Request request, @RequestBody Map body) { +/// ... +/// HttpSession session = request.getSession(true); +/// session.changeSessionId(); // never keep a pre-login id +/// session.setAttribute("user", userId); +/// } +/// ``` +/// +/// A session is created only when something asks for one with +/// `getSession(true)`, so a server that never does sets no cookie and keeps +/// nothing. The cookie is `HttpOnly`, `SameSite=Lax` and, on a TLS +/// server, `Secure`; its name, lifetime and store are configured under +/// `cn1.session.*`. See [Sessions]. +/// +/// Attributes live in the [SessionStore]. The in-memory store keeps any +/// object; the database store keeps what [Json] can write -- strings, +/// numbers, booleans, and maps and lists of those -- because it has to read them +/// back in another process. +/// +/// A `@SessionScope` bean is never written to a store: the server that +/// built it keeps it in memory for as long as the session lives, and runs its +/// destroy methods when the session is invalidated, expires or the server stops. +/// Another instance behind a load balancer builds its own. +public final class HttpSession { + private String id; + private final long created; + private long lastAccessed; + private int maxInactiveSeconds; + private final Map attributes = new LinkedHashMap(); + private boolean invalid; + private boolean fresh; + private boolean dirty; + private String previousId; + /// Set by a store whose rotation found the old row already gone -- another + /// request of the same client rotated or invalidated it first -- so the new + /// id has nothing stored under it and must not be sent to the client. + private boolean rotationLost; + /// The attribute names this request set or removed, so a store that loads a + /// copy per request can apply just these onto what another request saved in + /// the meantime, instead of replacing it with this copy's stale whole map. + private final java.util.Set changed = new java.util.HashSet(); + private boolean maxInactiveChanged; + private Object[] beans; + /// The last-access time the store holds, which lags the live one. + long storedAccessed; + + HttpSession(String id, long created, long lastAccessed, int maxInactiveSeconds) { + this.id = id; + this.created = created; + this.lastAccessed = lastAccessed; + this.storedAccessed = lastAccessed; + this.maxInactiveSeconds = maxInactiveSeconds; + } + + /// The id the cookie carries. Secret: never log it. + public synchronized String getId() { + return id; + } + + /// Whether this session was created by the current request. + public synchronized boolean isNew() { + return fresh; + } + + public long getCreationTime() { + return created; + } + + public synchronized long getLastAccessedTime() { + return lastAccessed; + } + + /// Seconds of inactivity after which the session is discarded. + public synchronized int getMaxInactiveInterval() { + return maxInactiveSeconds; + } + + public synchronized void setMaxInactiveInterval(int seconds) { + maxInactiveSeconds = seconds; + maxInactiveChanged = true; + dirty = true; + } + + public synchronized Object getAttribute(String name) { + checkValid(); + return attributes.get(name); + } + + public synchronized void setAttribute(String name, Object value) { + checkValid(); + if (value == null) { + attributes.remove(name); + } else { + attributes.put(name, value); + } + changed.add(name); + dirty = true; + } + + public synchronized void removeAttribute(String name) { + checkValid(); + if (attributes.remove(name) != null) { + changed.add(name); + dirty = true; + } + } + + /// The attribute names, as a copy. + public synchronized List getAttributeNames() { + return new ArrayList(attributes.keySet()); + } + + /// Ends the session: its attributes are dropped and the client's cookie cleared. + public synchronized void invalidate() { + checkValid(); + invalid = true; + attributes.clear(); + // The @SessionScope beans stay until the request ends, when the server + // runs their destroy methods; dropping them here would skip those. + dirty = true; + } + + public synchronized boolean isValid() { + return !invalid; + } + + /// Gives the session a new id, keeping its attributes, and sends the client the + /// new cookie. Call it when a user signs in: an id a client held before + /// authenticating is one an attacker may have planted. + /// + /// #### Returns + /// + /// the new id + public String changeSessionId() { + String next = Sessions.newId(); + synchronized (this) { + checkValid(); + if (previousId == null) { + previousId = id; + } + id = next; + dirty = true; + rotatedBy = SERVING.get(); + } + return next; + } + + /// The request the server is serving on this thread, for [#changeSessionId] + /// to record who rotated. + private static final ThreadLocal SERVING = new ThreadLocal(); + + /// The request that rotated this session, or null outside a request. + private Object rotatedBy; + + /// Marks `request` as the one this thread serves; answers what to restore. + static Object enterRequest(Object request) { + Object previous = SERVING.get(); + SERVING.set(request); + return previous; + } + + static void leaveRequest(Object previous) { + SERVING.set(previous); + } + + /// Whether a pending rotation is `request`'s own to store and announce. + /// The memory store hands every request one shared object, so a request can + /// find another's login halfway through: finishing THAT rotation stored it + /// and sent the new id in its own response -- to whoever presented the old + /// cookie, the attacker in a fixation -- and the login announced nothing. + synchronized boolean rotatedFor(Object request) { + return rotatedBy == null || rotatedBy == request; //NOPMD CompareObjectsWithEquals - the request itself, by identity + } + + private void checkValid() { + if (invalid) { + throw new IllegalStateException("This session has been invalidated"); + } + } + + // ---------------------------------------------------------------- store use + + synchronized void touch(long now) { + lastAccessed = now; + } + + /// Extra time before expiry, for a store whose stored last use lags. + private long expiryGraceMillis; + + /// Allows `millis` past the timeout before expiry; see Sessions.Db. + synchronized void setExpiryGrace(long millis) { + expiryGraceMillis = millis; + } + + synchronized boolean isExpired(long now) { + return maxInactiveSeconds > 0 + && now - lastAccessed > maxInactiveSeconds * 1000L + expiryGraceMillis; + } + + synchronized void markNew() { + fresh = true; + dirty = true; + } + + synchronized boolean isDirty() { + return dirty; + } + + synchronized void clean() { + dirty = false; + fresh = false; + previousId = null; + rotatedBy = null; + rotationLost = false; + changed.clear(); + maxInactiveChanged = false; + } + + /// The names this request set or removed, as a copy. + synchronized java.util.Set changedNames() { + return new java.util.HashSet(changed); + } + + synchronized boolean isMaxInactiveChanged() { + return maxInactiveChanged; + } + + /// Takes the session back to `previous`, the id another request's + /// rotation already announced; see Sessions.finish. + synchronized void undoRotation(String previous) { + id = previous; + previousId = null; + rotatedBy = null; + } + + synchronized void markRotationLost() { + rotationLost = true; + } + + synchronized boolean isRotationLost() { + return rotationLost; + } + + /// The id the client held before [#changeSessionId], or null. + synchronized String previousId() { + return previousId; + } + + /// A copy of the attributes, for a store to write. + synchronized Map attributesCopy() { + return new LinkedHashMap(attributes); + } + + synchronized void loadAttributes(Map values) { + attributes.clear(); + if (values != null) { + attributes.putAll(values); + } + } + + /// The server whose sessions this belongs to, which keeps the session-scoped + /// beans; null for a session made outside one. + Sessions owner; + + /// The beans held on this object itself, when it has no owner; taken once. + synchronized Object[] takeLocalBeans() { + Object[] out = beans; + beans = null; + return out; + } + + /// What generated code locks while it builds one of this session's beans: an + /// object every loaded copy of the session shares. + public Object beanLock() { + Sessions o = owner; + return o == null ? this : o.beanLock(this); + } + + /// The `@SessionScope` beans of this session, by the slot the build gave + /// each. Called by generated code. + public Object[] scopedBeans(int count) { + Sessions o = owner; + if (o != null) { + return o.sharedBeans(this, count); + } + return localBeans(count); + } + + private synchronized Object[] localBeans(int count) { + if (beans == null || beans.length < count) { + Object[] grown = new Object[count]; + if (beans != null) { + System.arraycopy(beans, 0, grown, 0, beans.length); + } + beans = grown; + } + return beans; + } +} diff --git a/vm/backend/src/com/codename1/backend/JsonCodec.java b/vm/backend/src/com/codename1/backend/JsonCodec.java new file mode 100644 index 00000000000..fc082e5e36c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/JsonCodec.java @@ -0,0 +1,365 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.util.Date; + +/// What the JSON codecs the build generates call: typed reads that refuse a +/// value with a message naming where it was, and the date form. +/// +/// A controller that returns or accepts one of the application's own classes -- +/// an entity, a DTO -- gets a codec written for that class at build time, the way +/// Jackson would map it at run time: its fields by name, `@JsonProperty` renaming +/// one and `@JsonIgnore` leaving one out. The codec calls these methods, so +/// nothing here is looked up by name or reflected on, and a server with no such +/// route never links this class. +/// +/// A refused value is an `IllegalArgumentException` whose message says where in +/// the body it was and what was expected -- `$.items[2].due: expected a number or +/// an ISO-8601 date, got true` -- which the generated router answers with 400. +public final class JsonCodec { + /// How deep a codec follows one object into another before it refuses: past + /// any real document, and short of the stack a cycle between two objects + /// would otherwise exhaust. + public static final int MAX_DEPTH = 64; + + private JsonCodec() { + } + + /// Where a value sits in a body. Built one level per nested object or array + /// the codec enters, and turned into text only for a message. + public static final class Path { + /// The document itself, `$`. + public static final Path ROOT = new Path(null, null, -1); + + private final Path parent; + private final String name; + private final int index; + + private Path(Path parent, String name, int index) { + this.parent = parent; + this.name = name; + this.index = index; + } + + /// The member `name` of this object. + public Path child(String name) { + return new Path(this, name, -1); + } + + /// The element `index` of this array. + public Path child(int index) { + return new Path(this, null, index); + } + + @Override + public String toString() { + StringBuilder out = new StringBuilder(); + append(out); + return out.toString(); + } + + private void append(StringBuilder out) { + if (parent == null) { + out.append('$'); + return; + } + parent.append(out); + segment(out, name, index); + } + } + + /// The path of member `name` or element `index` of `at`, or `at` itself when + /// neither is given -- where a codec is when it starts reading an object or + /// an array. + public static Path enter(Path at, String name, int index) { + if (name != null) { + return at.child(name); + } + return index >= 0 ? at.child(index) : at; + } + + private static void segment(StringBuilder out, String name, int index) { + if (name != null) { + out.append('.').append(name); + } else if (index >= 0) { + out.append('[').append(index).append(']'); + } + } + + /// The refusal of `got` at member `name` or element `index` of `at`; pass + /// null and -1 for `at` itself. + public static IllegalArgumentException mismatch(Path at, String name, int index, + String expected, Object got) { + StringBuilder out = new StringBuilder(); + at.append(out); + segment(out, name, index); + out.append(": expected ").append(expected).append(", got ").append(describe(got)); + return new IllegalArgumentException(out.toString()); + } + + /// Refuses an object nested past [#MAX_DEPTH], which in a response means a + /// cycle: two objects that reach each other through their fields. + public static IllegalStateException tooDeep(String type) { + return new IllegalStateException("A " + type + " nests more than " + MAX_DEPTH + + " objects deep while being written as JSON, which is a cycle between " + + "objects that refer to each other. Mark the field that points back " + + "with @JsonIgnore."); + } + + private static String describe(Object got) { + if (got == null) { + return "null"; + } + if (got instanceof String) { + return "a string"; + } + if (got instanceof Boolean) { + return String.valueOf(got); + } + if (got instanceof Number) { + return "the number " + got; + } + if (got instanceof java.util.Map) { + return "an object"; + } + if (got instanceof java.util.List) { + return "an array"; + } + return "a value"; + } + + /// A whole number between `min` and `max`. A number with a fraction is + /// refused rather than cut short. + public static long readLong(Object json, Path at, String name, int index, + long min, long max) { + if (json instanceof Long) { + long v = ((Long) json).longValue(); + if (v >= min && v <= max) { + return v; + } + } else if (json instanceof Double) { + double d = ((Double) json).doubleValue(); + // Bounded by 2^63 BEFORE the conversion, and compared as a long after + // it. As a double, Long.MAX_VALUE rounds up to 2^63, so a direct + // "d <= max" passed 9223372036854775808.0 and the cast clamped it. + if (d == Math.floor(d) && d >= -9.223372036854775808E18 + && d < 9.223372036854775808E18) { + long v = (long) d; + if (v >= min && v <= max) { + return v; + } + } + } + throw mismatch(at, name, index, "a whole number from " + min + " to " + max, json); + } + + /// Any number. + public static double readDouble(Object json, Path at, String name, int index) { + if (json instanceof Number) { + return ((Number) json).doubleValue(); + } + throw mismatch(at, name, index, "a number", json); + } + + /// `true` or `false`. + public static boolean readBoolean(Object json, Path at, String name, int index) { + if (json instanceof Boolean) { + return ((Boolean) json).booleanValue(); + } + throw mismatch(at, name, index, "true or false", json); + } + + /// A string of one character. + public static char readChar(Object json, Path at, String name, int index) { + if (json instanceof String && ((String) json).length() == 1) { + return ((String) json).charAt(0); + } + throw mismatch(at, name, index, "a one-character string", json); + } + + /// A string, or null. + public static String readString(Object json, Path at, String name, int index) { + if (json == null || json instanceof String) { + return (String) json; + } + throw mismatch(at, name, index, "a string", json); + } + + /// Base64 text, or null. + public static byte[] readBytes(Object json, Path at, String name, int index) { + if (json == null) { + return null; + } + if (json instanceof String) { + byte[] out = Base64.decode((String) json); + if (out != null) { + return out; + } + } + throw mismatch(at, name, index, "base64 text", json); + } + + /// Milliseconds since the epoch -- the form [#writeDate] writes and the + /// app's mapper reads -- or an ISO-8601 date, with or without a time and an + /// offset; one without an offset is UTC. Null stays null. + public static Date readDate(Object json, Path at, String name, int index) { + if (json == null) { + return null; + } + if (json instanceof Long) { + return new Date(((Long) json).longValue()); + } + if (json instanceof String) { + long millis = parseIso8601((String) json); + if (millis != Long.MIN_VALUE) { + return new Date(millis); + } + } + throw mismatch(at, name, index, "a number or an ISO-8601 date", json); + } + + /// A date as milliseconds since the epoch, as Jackson writes one by default + /// and as the app's generated mapper reads it back. + public static void writeDate(Date value, ByteSink out) { + if (value == null) { + out.putAscii("null"); + } else { + out.putNumber(value.getTime()); + } + } + + /// Milliseconds since the epoch, or Long.MIN_VALUE when `s` isn't one of + /// `yyyy-MM-dd`, `yyyy-MM-ddTHH:mm`, `yyyy-MM-ddTHH:mm:ss` and the last with + /// a fraction, each optionally followed by `Z` or an offset such as + /// `+02:00` or `+0200`. + static long parseIso8601(String s) { + int n = s.length(); + if (n < 10 || s.charAt(4) != '-' || s.charAt(7) != '-') { + return Long.MIN_VALUE; + } + int year = digits(s, 0, 4); + int month = digits(s, 5, 2); + int day = digits(s, 8, 2); + if (year < 0 || month < 1 || month > 12 || day < 1 || day > daysIn(year, month)) { + return Long.MIN_VALUE; + } + int hour = 0; + int minute = 0; + int second = 0; + int millis = 0; + int pos = 10; + if (pos < n && (s.charAt(pos) == 'T' || s.charAt(pos) == 't' || s.charAt(pos) == ' ')) { + if (pos + 6 > n || s.charAt(pos + 3) != ':') { + return Long.MIN_VALUE; + } + hour = digits(s, pos + 1, 2); + minute = digits(s, pos + 4, 2); + pos += 6; + if (pos < n && s.charAt(pos) == ':') { + second = digits(s, pos + 1, 2); + pos += 3; + if (pos < n && s.charAt(pos) == '.') { + pos++; + int start = pos; + int scale = 100; + while (pos < n && s.charAt(pos) >= '0' && s.charAt(pos) <= '9') { + millis += (s.charAt(pos) - '0') * scale; + scale /= 10; + pos++; + } + if (pos == start) { + return Long.MIN_VALUE; + } + } + } + if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 + || second > 59) { + return Long.MIN_VALUE; + } + } + int offsetMinutes = 0; + if (pos < n) { + char c = s.charAt(pos); + if ((c == 'Z' || c == 'z') && pos + 1 == n) { + offsetMinutes = 0; + } else if (c == '+' || c == '-') { + int oh = digits(s, pos + 1, 2); + int om; + if (pos + 6 == n && s.charAt(pos + 3) == ':') { + om = digits(s, pos + 4, 2); + } else if (pos + 5 == n) { + om = digits(s, pos + 3, 2); + } else if (pos + 3 == n) { + om = 0; + } else { + return Long.MIN_VALUE; + } + if (oh < 0 || oh > 23 || om < 0 || om > 59) { + return Long.MIN_VALUE; + } + offsetMinutes = (c == '+' ? 1 : -1) * (oh * 60 + om); + } else { + return Long.MIN_VALUE; + } + } + long days = daysFromCivil(year, month, day); + return ((days * 24 + hour) * 60 + minute - offsetMinutes) * 60000L + + second * 1000L + millis; + } + + /// The decimal value of `count` digits at `from`, or -1. + private static int digits(String s, int from, int count) { + if (from < 0 || from + count > s.length()) { + return -1; + } + int v = 0; + for (int iter = from ; iter < from + count ; iter++) { + char c = s.charAt(iter); + if (c < '0' || c > '9') { + return -1; + } + v = v * 10 + (c - '0'); + } + return v; + } + + private static int daysIn(int year, int month) { + if (month == 2) { + boolean leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + return leap ? 29 : 28; + } + return month == 4 || month == 6 || month == 9 || month == 11 ? 30 : 31; + } + + /// Days from 1970-01-01 to the given proleptic Gregorian date; Howard + /// Hinnant's algorithm, which needs no calendar class. + private static long daysFromCivil(int year, int month, int day) { + int y = month <= 2 ? year - 1 : year; + int era = (y >= 0 ? y : y - 399) / 400; + int yoe = y - era * 400; + int doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; + int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097L + doe - 719468; + } +} diff --git a/vm/backend/src/com/codename1/backend/ManagedBean.java b/vm/backend/src/com/codename1/backend/ManagedBean.java new file mode 100644 index 00000000000..f3792e65450 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/ManagedBean.java @@ -0,0 +1,54 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.util.Map; + +/// A `@ManagedResource` bean as the build describes it: its attributes and +/// operations by index, read and invoked through direct calls the build +/// generated. The JMX model -- named attributes you can watch, operations you can +/// call -- with nothing looked up reflectively. +public interface ManagedBean { + /// The name its metrics are prefixed with. + String getObjectName(); + + String getDescription(); + + String[] attributeNames(); + + String[] attributeDescriptions(); + + /// The attribute's current value. + Object readAttribute(int index) throws Exception; + + String[] operationNames(); + + String[] operationDescriptions(); + + /// Each operation's parameter names, in order. + String[][] operationParameters(); + + /// Calls an operation. Arguments arrive by parameter name, as strings, + /// numbers or booleans, and are converted by the generated code. + Object invoke(int index, Map arguments) throws Exception; +} diff --git a/vm/backend/src/com/codename1/backend/Management.java b/vm/backend/src/com/codename1/backend/Management.java new file mode 100644 index 00000000000..a9d3f0690d3 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Management.java @@ -0,0 +1,338 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.codename1.backend.metrics.Metrics; + +/// The server's management endpoints: health for the load balancer, metrics for +/// a scraper, and the jobs and managed beans for an operator. +/// +/// ```java +/// GET /manage/health public; 503 while draining +/// GET /manage/metrics every metric, as JSON +/// GET /manage/prometheus every metric, in the Prometheus text format +/// GET /manage/jobs the scheduled jobs and their last runs +/// GET /manage/managed the managed beans, with their attributes' values +/// POST /manage/managed/{bean}/{operation} calls an operation; body: arguments +/// ``` +/// +/// Off by default outside a development profile; `cn1.management.enabled` +/// turns it on or off explicitly, and `cn1.management.path` moves it. +/// Everything but health needs `Authorization: Bearer `. +/// Without a token, a development profile serves the read-only views to anyone +/// who can reach the port -- a laptop -- and no profile serves an operation, +/// since an operation changes the running server. +public final class Management implements HttpServer.Handler { + public static final String ENABLED = "cn1.management.enabled"; + public static final String PATH = "cn1.management.path"; + public static final String TOKEN = "cn1.management.token"; + + private final String path; + private final byte[] token; + private final boolean development; + /// Volatile: attached after the listener's workers are running, and a plain + /// write need never reach them -- health would read STARTING for good. + private volatile Backend backend; //NOPMD AvoidUsingVolatile - attached after the workers start + + private Management(String path, String token, boolean development) { + this.path = path; + this.token = token == null || token.length() == 0 ? null : utf8(token); + this.development = development; + } + + /// The endpoints this configuration asks for, or null when they are off. + /// + /// #### Throws + /// + /// - `IOException`: when they are on outside development with no token, + /// which would publish the server's internals to anyone + public static Management fromConfig(Config config) throws IOException { + boolean development = config.isDevelopmentProfile(); + if (!config.getBoolean(ENABLED, development)) { + return null; + } + String token = config.getHeaderSecret(TOKEN); + if (!development && (token == null || token.length() == 0)) { + throw new IOException(ENABLED + " is on outside a development profile and " + + TOKEN + " is not set. The metrics and managed beans would be readable " + + "by anyone who can reach the port; set a token, or leave the " + + "endpoints off."); + } + String path = config.getRoutePath(PATH, "/manage"); + while (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + if (!path.startsWith("/")) { + throw new IOException(PATH + " must start with /"); + } + return new Management(path, token, development); + } + + /// Set once the server is running. + void attach(Backend running) { + this.backend = running; + } + + /// The managed beans of the server this endpoint belongs to. + private List beans() { + return backend == null ? new ArrayList() : backend.getManagedBeans(); + } + + /// Each of `all` -- a server's managed beans, from + /// [Backend#getManagedBeans] -- with its attributes' current values and + /// its operations. + public static List describeBeans(List all) { + List out = new ArrayList(); + for (Object element : all) { + ManagedBean bean = (ManagedBean) element; + Map m = new LinkedHashMap(); + m.put("objectName", bean.getObjectName()); + if (bean.getDescription().length() > 0) { + m.put("description", bean.getDescription()); + } + Map attributes = new LinkedHashMap(); + String[] names = bean.attributeNames(); + for (int a = 0 ; a < names.length ; a++) { + Object value; + try { + value = bean.readAttribute(a); + } catch (Exception err) { + value = "<" + err + ">"; + } + attributes.put(names[a], value); + } + m.put("attributes", attributes); + List operations = new ArrayList(); + String[] ops = bean.operationNames(); + String[] descriptions = bean.operationDescriptions(); + String[][] params = bean.operationParameters(); + for (int o = 0 ; o < ops.length ; o++) { + Map op = new LinkedHashMap(); + op.put("name", ops[o]); + if (descriptions[o].length() > 0) { + op.put("description", descriptions[o]); + } + List p = new ArrayList(); + for (int q = 0 ; q < params[o].length ; q++) { + p.add(params[o][q]); + } + op.put("parameters", p); + operations.add(op); + } + m.put("operations", operations); + out.add(m); + } + return out; + } + + /// Calls an operation of a managed bean by name. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when there is no such bean or operation + private static ManagedBean find(List all, String objectName) { + for (Object element : all) { + ManagedBean bean = (ManagedBean) element; + if (bean.getObjectName().equals(objectName)) { + return bean; + } + } + return null; + } + + private static int operationIndex(ManagedBean bean, String operation) { + String[] ops = bean.operationNames(); + for (int o = 0 ; o < ops.length ; o++) { + if (ops[o].equals(operation)) { + return o; + } + } + return -1; + } + + public static Object invoke(List all, String objectName, String operation, + Map arguments) throws Exception { + for (Object element : all) { + ManagedBean bean = (ManagedBean) element; + if (!bean.getObjectName().equals(objectName)) { + continue; + } + String[] ops = bean.operationNames(); + for (int o = 0 ; o < ops.length ; o++) { + if (ops[o].equals(operation)) { + return bean.invoke(o, arguments == null ? new LinkedHashMap() : arguments); + } + } + throw new IllegalArgumentException(objectName + " has no operation " + operation); + } + throw new IllegalArgumentException("No managed bean " + objectName); + } + + @Override + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + // The CANONICAL path, as the routers compare it; see McpServer.handle. + String canonical = request.getTarget() == null ? null : request.pathFrom(0); + if (canonical == null || !canonical.startsWith(path)) { + return null; + } + String rest = canonical.substring(path.length()); + if (rest.length() > 0 && rest.charAt(0) != '/') { + return null; + } + String method = request.getMethod(); + if ("/health".equals(rest) && ("GET".equals(method) || "HEAD".equals(method))) { + return health(request); + } + if (rest.length() == 0) { + return null; + } + boolean operation = "POST".equals(method) && rest.startsWith("/managed/"); + if (!authorized(request, operation)) { + return request.respondJson(token == null ? 403 : 401, error(token == null + ? "Set " + TOKEN + " to use this endpoint" + : "A bearer token is required")); + } + if ("GET".equals(method) || "HEAD".equals(method)) { + if ("/metrics".equals(rest)) { + return request.respondJson(200, Metrics.snapshot()); + } + if ("/prometheus".equals(rest)) { + return request.respond(200, "text/plain; version=0.0.4; charset=utf-8", + utf8(Metrics.prometheus())); + } + if ("/jobs".equals(rest)) { + Scheduler scheduler = backend == null || backend.getApplication() == null + ? null : backend.getApplication().getScheduler(); + return request.respondJson(200, scheduler == null ? new ArrayList() + : scheduler.describe()); + } + if ("/managed".equals(rest)) { + return request.respondJson(200, describeBeans(beans())); + } + return null; + } + if (operation) { + String[] parts = split(rest.substring("/managed/".length())); + if (parts == null) { + return request.respondJson(404, error("Expected /managed/{bean}/{operation}")); + } + // Bean and operation names are Java identifiers, so there is nothing + // to percent-decode: an escaped one matches nothing. Looked up FIRST + // and on its own, so 404 means "no such operation" and nothing else -- + // an operation that rejects its arguments is the caller's mistake to + // fix, a 400, not an endpoint that does not exist. + ManagedBean bean = find(beans(), parts[0]); + int op = bean == null ? -1 : operationIndex(bean, parts[1]); + if (bean == null) { + return request.respondJson(404, error("No managed bean " + parts[0])); + } + if (op < 0) { + return request.respondJson(404, error(parts[0] + " has no operation " + + parts[1])); + } + Map arguments = new LinkedHashMap(); + String body = request.getBody(); + if (body != null && body.trim().length() > 0) { + try { + arguments = Json.parseObject(body); + } catch (Exception err) { + return request.respondJson(400, error("The body must be a JSON object " + + "of the operation's arguments: " + err.getMessage())); + } + } + try { + Map result = new LinkedHashMap(); + result.put("result", bean.invoke(op, arguments)); + return request.respondJson(200, result); + } catch (IllegalArgumentException err) { + return request.respondJson(400, error(err.getMessage())); + } + } + return null; + } + + private HttpServer.Response health(HttpServer.Request request) { + Map out = new LinkedHashMap(); + boolean up = true; + if (backend != null) { + Map server = backend.getServer().getMetrics(); + boolean serving = "ok".equals(server.get("status")); + // STARTING until the application's start-up hook has returned: the + // listener accepts before it runs, and a load balancer that read UP + // then would send traffic to a server that may still fail to start. + up = serving && backend.isReady(); + out.put("status", !serving ? "DRAINING" : up ? "UP" : "STARTING"); + out.put("uptimeSeconds", server.get("uptimeSeconds")); + if (backend.getDataSource() != null) { + Map db = new LinkedHashMap(); + db.put("open", Integer.valueOf(backend.getDataSource().getOpenCount())); + db.put("idle", Integer.valueOf(backend.getDataSource().getIdleCount())); + out.put("database", db); + } + } else { + // Not attached yet: the server is still being put together. + up = false; + out.put("status", "STARTING"); + } + return request.respondJson(up ? 200 : 503, out); + } + + private boolean authorized(HttpServer.Request request, boolean operation) { + if (token == null) { + return development && !operation; + } + String header = request.getHeader("authorization"); + if (header == null || !header.regionMatches(true, 0, "Bearer ", 0, 7)) { + return false; + } + return Crypto.equalsConstantTime(token, utf8(header.substring(7).trim())); + } + + private static String[] split(String rest) { + int slash = rest.indexOf('/'); + if (slash <= 0 || slash == rest.length() - 1 || rest.indexOf('/', slash + 1) >= 0) { + return null; + } + return new String[] {rest.substring(0, slash), rest.substring(slash + 1)}; + } + + private static Map error(String message) { + Map m = new LinkedHashMap(); + m.put("error", message); + return m; + } + + private static byte[] utf8(String s) { + try { + return s.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException("UTF-8 is required", err); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/RequestLog.java b/vm/backend/src/com/codename1/backend/RequestLog.java new file mode 100644 index 00000000000..c03206a588a --- /dev/null +++ b/vm/backend/src/com/codename1/backend/RequestLog.java @@ -0,0 +1,113 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The last requests the server answered, kept for the development MCP tools: +/// what came in, what went out, how long it took and what was thrown. +/// +/// Off unless the development tools turn it on. Off, recording is a static +/// read per request and nothing else. +public final class RequestLog { + /// Per server, never per process: with two servers in one process, the one + /// running the development tools must not show the other's request targets + /// and exceptions. Volatile: the development tools switch it on after the + /// workers are running, and a plain write need never become visible to them. + volatile boolean enabled; //NOPMD AvoidUsingVolatile - switched on while the workers run + private Map[] ring = new Map[0]; + private int next; + private int size; + + RequestLog() { + } + + /// Starts keeping the last `capacity` requests. + public synchronized void enable(int capacity) { + ring = new Map[capacity < 1 ? 1 : capacity]; + next = 0; + size = 0; + enabled = true; + } + + void record(HttpServer.Request request, int status, long startedMillis, + Throwable error) { + if (!enabled) { + return; + } + Map entry = new LinkedHashMap(); + entry.put("time", Long.valueOf(startedMillis)); + entry.put("method", request.getMethod()); + entry.put("target", request.getTarget()); + entry.put("status", Integer.valueOf(status)); + entry.put("millis", Long.valueOf(System.currentTimeMillis() - startedMillis)); + if (error != null) { + entry.put("error", String.valueOf(error)); + StringBuilder where = new StringBuilder(); + Throwable cause = error; + int depth = 0; + while (cause != null && depth < 4) { + if (depth > 0) { + where.append(" <- caused by ").append(cause); + } + cause = cause.getCause(); + depth++; + } + if (where.length() > 0) { + entry.put("causes", where.toString()); + } + } + synchronized (this) { + ring[next] = entry; + next = (next + 1) % ring.length; + if (size < ring.length) { + size++; + } + } + } + + /// The newest `limit` requests, newest first; only failures when asked. + public synchronized List recent(int limit, boolean failuresOnly) { + List out = new ArrayList(); + for (int iter = 0 ; iter < size && out.size() < limit ; iter++) { + int at = (next - 1 - iter + ring.length) % ring.length; + Map entry = ring[at]; + if (entry == null) { + continue; + } + if (failuresOnly) { + Object status = entry.get("status"); + boolean failed = entry.get("error") != null + || (status instanceof Integer && ((Integer) status).intValue() >= 500); + if (!failed) { + continue; + } + } + out.add(entry); + } + return out; + } +} diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/backend/Scheduler.java new file mode 100644 index 00000000000..1fb7495716c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -0,0 +1,606 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.codename1.backend.sql.Dialect; + +/// Runs the `@Scheduled` methods of a server. +/// +/// The build registers each job from the entry point it generates -- the cron +/// masks already computed, the executor and thread kind already chosen -- and +/// starts this once the server is accepting. One platform thread keeps the +/// timetable; the jobs themselves run on their executors, so a slow job delays +/// nothing but its own next run. +/// +/// A run never overlaps the previous run of the same job. A fire that finds +/// the previous run still going is skipped, and the job is next due at its +/// following time, which is what keeps a job that has fallen behind from +/// stacking up copies of itself. +/// +/// With a `lock`, a run first claims a row in `cn1_scheduler_lock` +/// and is skipped when another instance holds it, so a job runs on one replica +/// at a time. The claim expires after `lockAtMostFor` milliseconds, so a +/// replica that dies mid-run does not keep it. +public final class Scheduler { + public static final int CRON = 0; + public static final int FIXED_RATE = 1; + public static final int FIXED_DELAY = 2; + + private static final String LOCK_TABLE = "cn1_scheduler_lock"; + private static final long DEFAULT_LOCK_MILLIS = 10 * 60 * 1000L; + + /// One registered job and what has happened to it. + public static final class Job { + final String name; + final int kind; + final CronSchedule cron; + final long period; + final long initialDelay; + final String executorName; + final int threadKind; + final String lock; + final long lockAtMostFor; + final Runnable body; + TaskExecutor executor; + long next = Long.MAX_VALUE; + boolean running; + long runs; + long failures; + long skipped; + long lastStart; + long lastDurationMillis = -1; + String lastError; + + Job(String name, int kind, CronSchedule cron, long period, long initialDelay, + String executorName, int threadKind, String lock, long lockAtMostFor, + Runnable body) { + this.name = name; + this.kind = kind; + this.cron = cron; + this.period = period; + this.initialDelay = initialDelay; + // Named by kind when unnamed, for the reason Tasks.executor gives. + this.executorName = executorName == null || executorName.length() == 0 + ? (threadKind == Tasks.VIRTUAL ? Tasks.SCHEDULING + "-virtual" + : Tasks.SCHEDULING) : executorName; + this.threadKind = threadKind; + this.lock = lock == null || lock.length() == 0 ? null : lock; + this.lockAtMostFor = lockAtMostFor > 0 ? lockAtMostFor : DEFAULT_LOCK_MILLIS; + this.body = body; + } + + public String getName() { + return name; + } + + /// What the schedule is, for a listing. + public String describeSchedule() { + if (kind == CRON) { + return "cron " + cron; + } + return (kind == FIXED_RATE ? "every " : "delay ") + period + "ms"; + } + } + + private final List jobs = new ArrayList(); + private final DataSource locks; + private final String instance; + /// Numbers each claim this scheduler makes, so the row records WHICH run holds + /// it and not just which instance: two jobs sharing a lock name run in one + /// process, and a run that outlived its lease must not release the claim + /// the next run took after it expired. + private long claims; + private boolean started; + private boolean stopped; + /// Whether this scheduler's server records metrics. Per scheduler: the job + /// histogram is the process's, and a second server that turned metrics off + /// must not add its jobs to the first one's. + private boolean measured; + + /// The tracer this scheduler's runs report to: its server's, so with two + /// traced servers in one process a job is exported under its own server's + /// name rather than the one installed last. Null for the installed one. + private Tracer tracer; + + /// Set by the server once it knows whether it records metrics. + public void setMeasured(boolean measured) { + this.measured = measured; + } + + /// Takes the measuring and tracing of the server that runs this scheduler. + /// Called by the generated application before it starts the scheduler. + public synchronized void bind(Backend backend) { + this.measured = backend.isMeasured(); + // Tracing off is the untraced marker, not null: null would hand the runs + // to whichever tracer another server in the process installed. + Tracer own = backend.getTracer(); + this.tracer = own != null ? own : Tracing.NONE; + } + private boolean lockTableReady; + + /// #### Parameters + /// + /// - `locks`: @param locks the pool locked jobs claim their row in, or null when this + /// server has no database -- the build refuses a lock without one + public Scheduler(DataSource locks) { + this.locks = locks; + String id; + try { + id = hex(Crypto.randomBytes(6)); + } catch (IOException err) { + id = Long.toString(System.currentTimeMillis(), 16); + } + this.instance = id; + } + + private static String hex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte element : bytes) { + sb.append("0123456789abcdef".charAt((element >> 4) & 15)); + sb.append("0123456789abcdef".charAt(element & 15)); + } + return sb.toString(); + } + + /// Registers a cron job. Generated code calls this before [#start]. + public synchronized void cron(String name, CronSchedule schedule, String executor, + int threadKind, String lock, long lockAtMostFor, + Runnable body) { + add(new Job(name, CRON, schedule, 0, 0, executor, threadKind, lock, lockAtMostFor, + body)); + } + + /// Registers a job that starts every `period` milliseconds. + public synchronized void fixedRate(String name, long initialDelay, long period, + String executor, int threadKind, String lock, + long lockAtMostFor, Runnable body) { + checkPeriod(name, period); + add(new Job(name, FIXED_RATE, null, period, initialDelay, executor, threadKind, lock, + lockAtMostFor, body)); + } + + /// Registers a job that starts `period` milliseconds after the last run ended. + public synchronized void fixedDelay(String name, long initialDelay, long period, + String executor, int threadKind, String lock, + long lockAtMostFor, Runnable body) { + checkPeriod(name, period); + add(new Job(name, FIXED_DELAY, null, period, initialDelay, executor, threadKind, lock, + lockAtMostFor, body)); + } + + private static void checkPeriod(String name, long period) { + if (period <= 0) { + // Only a value read from configuration can be wrong here: the build + // checks the literal ones. + throw new IllegalArgumentException("Scheduled job " + name + " has period " + + period + "ms; it must be positive"); + } + } + + private void add(Job job) { + if (started) { + throw new IllegalStateException("Jobs are registered before the scheduler starts"); + } + if (job.lock != null && locks == null) { + throw new IllegalStateException("Scheduled job " + job.name + " takes lock \"" + + job.lock + "\", which needs a database, and this server has none"); + } + for (Object element : jobs) { + if (((Job) element).name.equals(job.name)) { + // A job is triggered, listed and measured by name: a second one + // with it could not be triggered, and the two could overlap while + // reported as one. + throw new IllegalArgumentException("A scheduled job named \"" + job.name + + "\" is already registered"); + } + } + jobs.add(job); + } + + /// Starts the timetable. + public synchronized void start() { + if (started) { + return; + } + started = true; + long now = System.currentTimeMillis(); + for (Object element : jobs) { + Job job = (Job) element; + job.executor = Tasks.executor(job.executorName, job.threadKind); + if (job.kind == CRON) { + job.next = job.cron.next(now); + if (job.next < 0) { + job.next = Long.MAX_VALUE; + System.err.println("Scheduled job " + job.name + " never fires: " + + job.cron); + } + } else { + job.next = AsyncTask.deadline(now, Math.max(0, job.initialDelay)); + } + } + if (jobs.isEmpty()) { + return; + } + Thread thread = new Thread(new Runnable() { + @Override + public void run() { + loop(); + } + }, "cn1-scheduler"); + thread.setDaemon(true); + thread.start(); + } + + /// Stops starting runs and waits up to `waitMillis` for the ones in + /// progress. + public void stop(long waitMillis) { + synchronized (this) { + stopped = true; + notifyAll(); + } + long deadline = AsyncTask.deadline(System.currentTimeMillis(), Math.max(0, waitMillis)); + synchronized (this) { + while (anyRunning()) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + break; + } + try { + wait(left); + } catch (InterruptedException err) { + break; + } + } + } + } + + private boolean anyRunning() { + for (Object element : jobs) { + if (((Job) element).running) { + return true; + } + } + return false; + } + + private void loop() { + synchronized (this) { + while (!stopped) { + long now = System.currentTimeMillis(); + Job due = null; + long soonest = Long.MAX_VALUE; + for (Object element : jobs) { + Job job = (Job) element; + if (job.next < soonest) { + soonest = job.next; + due = job; + } + } + if (due == null || soonest > now) { + try { + if (due == null) { + wait(); + } else { + wait(Math.min(soonest - now, 60000L)); + } + } catch (InterruptedException err) { + return; + } + continue; + } + fire(due, now); + } + } + } + + /// Called holding the monitor. + private void fire(final Job job, long now) { + if (job.running) { + job.skipped++; + job.next = following(job, now); + return; + } + TaskExecutor executor = job.executor; + if (executor == null) { + // Not started: start() gives every job its executor before any fires. + return; + } + job.running = true; + job.next = job.kind == FIXED_DELAY ? Long.MAX_VALUE : following(job, now); + try { + executor.execute(new Runnable() { + @Override + public void run() { + runJob(job); + } + }); + } catch (RuntimeException err) { + job.running = false; + job.failures++; + job.lastError = String.valueOf(err); + if (job.kind == FIXED_DELAY) { + job.next = AsyncTask.deadline(now, job.period); + } + } + } + + private long following(Job job, long now) { + if (job.kind == CRON) { + long next = job.cron.next(now); + return next < 0 ? Long.MAX_VALUE : next; + } + if (job.kind == FIXED_RATE) { + long next = job.next; + if (next == Long.MAX_VALUE) { + next = now; + } + // Catch up without a burst: the next START after now on the rate's + // own grid -- in one step, never one period at a time, which after a + // suspended VM or a clock jumped forward is millions of iterations + // under the monitor every other job and management call waits on. + if (next <= now) { + long missed = (now - next) / job.period + 1; + // One period due: it may be longer than the clock has room for. + // More than one: each is shorter than the gap, so no overflow. + next = missed == 1 ? AsyncTask.deadline(next, job.period) + : next + missed * job.period; + } + return next; + } + // Saturated: every time here is the epoch clock plus a period the + // application chose, and fixedDelay = Long.MAX_VALUE -- "once, then + // never" -- wrapped to a moment in the past and ran the job back to back. + return AsyncTask.deadline(now, job.period); + } + + /// A job's body as a traced unit of work. + private static final class RunBody implements Tracing.Work { + private final Runnable body; + + RunBody(Runnable body) { + this.body = body; + } + + @Override + public Object run(Span span) throws Exception { + body.run(); + return null; + } + } + + private void runJob(final Job job) { + long start = System.currentTimeMillis(); + String error = null; + boolean ran = false; + String lease = null; + Tracer runTracer; + synchronized (this) { + runTracer = tracer; + } + try { + if (job.lock != null) { + lease = claim(job); + } + if (job.lock == null || lease != null) { + ran = true; + Tracing.inBackground("scheduled " + job.name, null, runTracer, + new RunBody(job.body)); + } + } catch (Throwable err) { + error = String.valueOf(err); + System.err.println("Scheduled job " + job.name + " failed: " + err); + } finally { + if (lease != null) { + release(job, lease); + } + long end = System.currentTimeMillis(); + if (ran && measured) { + com.codename1.backend.metrics.Metrics.jobRan(job.name, end - start, + error != null); + } + synchronized (this) { + job.running = false; + if (ran) { + job.runs++; + job.lastStart = start; + job.lastDurationMillis = end - start; + } else if (error == null) { + job.skipped++; + } + if (error != null) { + job.failures++; + job.lastError = error; + } + if (job.kind == FIXED_DELAY && !stopped) { + job.next = AsyncTask.deadline(end, job.period); + } + notifyAll(); + } + } + } + + /// Claims the job's lock row; the lease that now holds it, or null when another + /// run does. + String claim(Job job) throws IOException { + prepareLockTable(); + String lease; + synchronized (this) { + claims++; + lease = instance + "#" + claims; + } + // The DATABASE's clock, which every replica shares: with each replica's + // own, one running ahead by more than the remaining lease would take a + // lock another still legitimately holds, and run the job beside it. + long now = databaseNow(); + long until = AsyncTask.deadline(now, job.lockAtMostFor); + int updated = locks.execute("UPDATE " + LOCK_TABLE + " SET lock_until = ?, locked_at = ?, " + + "locked_by = ? WHERE name = ? AND lock_until <= ?", + new Object[] {Long.valueOf(until), Long.valueOf(now), lease, job.lock, Long.valueOf(now)}); + if (updated > 0) { + return lease; + } + try { + locks.execute("INSERT INTO " + LOCK_TABLE + " (name, lock_until, locked_at, " + + "locked_by) VALUES (?, ?, ?, ?)", + new Object[] {job.lock, Long.valueOf(until), Long.valueOf(now), lease}); + return lease; + } catch (IOException failed) { + // Only a row that exists means another instance holds the claim. + // Anything else -- a dropped connection, a missing permission -- is + // rethrown so runJob records it as a failure; read as "held", it + // would be counted as a skip and the job would silently never run. + Map row; + try { + row = locks.queryOne("SELECT locked_by FROM " + LOCK_TABLE + " WHERE name = ?", + new Object[] {job.lock}); + } catch (IOException err) { + throw failed; + } + if (row != null) { + return null; + } + throw failed; + } + } + + /// Gives up `lease`'s claim -- and only that one: after an expiry the row + /// may belong to a later run, of this job or another sharing its lock name. + void release(Job job, String lease) { + try { + locks.execute("UPDATE " + LOCK_TABLE + " SET lock_until = ? WHERE name = ? AND " + + "locked_by = ?", new Object[] {Long.valueOf(databaseNow()), + job.lock, lease}); + } catch (IOException err) { + // The claim expires by itself; the only cost is that the next run on + // another instance waits for it. + System.err.println("Could not release scheduler lock " + job.lock + ": " + err); + } + } + + /// Milliseconds since the epoch by the database server's clock. + long databaseNow() throws IOException { + String name = locks.dialect().getName(); + String sql; + if ("postgresql".equals(name)) { + sql = "SELECT CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS db_now"; + } else if ("mysql".equals(name)) { + sql = "SELECT CAST(ROUND(UNIX_TIMESTAMP(NOW(3)) * 1000) AS SIGNED) AS db_now"; + } else { + // SQLite runs in this process: its clock is this host's anyway. + return System.currentTimeMillis(); + } + Map row = locks.queryOne(sql, null); + Object value = row == null ? null : row.get("db_now"); + if (!(value instanceof Number)) { + throw new IOException("The database did not report its time: " + value); + } + return ((Number) value).longValue(); + } + + private synchronized void prepareLockTable() throws IOException { + if (lockTableReady) { + return; + } + Dialect d = locks.dialect(); + locks.execute("CREATE TABLE IF NOT EXISTS " + LOCK_TABLE + " (name " + + d.assignedKeyColumn(Dialect.TEXT) + ", lock_until " + + d.columnType(Dialect.BIGINT) + " NOT NULL, locked_at " + + d.columnType(Dialect.BIGINT) + " NOT NULL, locked_by " + + d.columnType(Dialect.TEXT) + ")", null); + lockTableReady = true; + } + + /// Runs a job now, on its executor, whatever its schedule says. Answers + /// false when no job has that name or its previous run is still going. + public synchronized boolean trigger(String name) { + for (Object element : jobs) { + final Job job = (Job) element; + if (job.name.equals(name)) { + // Refused once stopped: stop() promises no run starts after it, + // and a management or MCP request still in flight during the + // drain must not start one. + if (stopped || job.running || job.executor == null) { + return false; + } + job.running = true; + try { + job.executor.execute(new Runnable() { + @Override + public void run() { + runJob(job); + } + }); + } catch (RuntimeException err) { + // The executor is shutting down. Left set, running would + // report the job busy forever and refuse every later trigger. + job.running = false; + return false; + } + return true; + } + } + return false; + } + + /// Every job and what has happened to it, for the management endpoint and MCP. + public synchronized List describe() { + List out = new ArrayList(); + for (Object element : jobs) { + Job job = (Job) element; + Map m = new LinkedHashMap(); + m.put("name", job.name); + m.put("schedule", job.describeSchedule()); + m.put("executor", job.executorName); + if (job.lock != null) { + m.put("lock", job.lock); + } + m.put("running", Boolean.valueOf(job.running)); + m.put("runs", Long.valueOf(job.runs)); + m.put("failures", Long.valueOf(job.failures)); + m.put("skipped", Long.valueOf(job.skipped)); + if (job.next != Long.MAX_VALUE) { + m.put("nextRunEpochMillis", Long.valueOf(job.next)); + } + if (job.lastStart != 0) { + m.put("lastRunEpochMillis", Long.valueOf(job.lastStart)); + m.put("lastDurationMillis", Long.valueOf(job.lastDurationMillis)); + } + if (job.lastError != null) { + m.put("lastError", job.lastError); + } + out.add(m); + } + return out; + } + + /// The registered jobs. + public synchronized List getJobs() { + return new ArrayList(jobs); + } +} diff --git a/vm/backend/src/com/codename1/backend/SessionStore.java b/vm/backend/src/com/codename1/backend/SessionStore.java new file mode 100644 index 00000000000..c7ec285b193 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/SessionStore.java @@ -0,0 +1,49 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.io.IOException; + +/// Where [HttpSession]s are kept between requests. +/// +/// Two are provided, chosen by `cn1.session.store`: `memory` (the +/// default) and `db`, which keeps them in the server's database so any +/// instance behind a load balancer can serve any client. Implement this for +/// another -- a cache server -- and pass it to [Sessions#setStore]. +public interface SessionStore { + /// The session with this id, or null when there is none or it expired. + HttpSession load(String id) throws IOException; + + /// Records a session after a request that created or changed it. When its id + /// changed, `previousId` names the entry to drop; otherwise it is null. + void save(HttpSession session, String previousId) throws IOException; + + /// Forgets a session. + void delete(String id) throws IOException; + + /// Drops every session that has expired by `now`; answers how many. + int purgeExpired(long now) throws IOException; + + /// How many sessions are kept, or -1 when that is expensive to know. + int size(); +} diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java new file mode 100644 index 00000000000..ba29814690f --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -0,0 +1,1217 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.codename1.backend.sql.Dialect; + +/// How a server keeps [HttpSession]s: the cookie, the lifetime and the +/// store, read from configuration when the server starts. +/// +/// ```java +/// cn1.session.cookie=CN1SESSION # the cookie's name +/// cn1.session.timeout=1800 # seconds of inactivity, 0 for never +/// cn1.session.store=memory # or db +/// cn1.session.same-site=Lax # Lax, Strict or None +/// cn1.session.secure=auto # true, false, or auto (on under TLS) +/// ``` +/// +/// Nothing here runs for a request that does not ask for its session: the +/// cookie is parsed on the first `getSession`, and a request that never +/// calls it costs one field check when it ends. +public final class Sessions { + private String cookieName = "CN1SESSION"; + private int timeoutSeconds = 1800; + private String sameSite = "Lax"; + private boolean secure; + private SessionStore store = new Memory(); + private long lastPurge; + private static final long PURGE_INTERVAL = 60000L; + /// Runs the destroy methods of @SessionScope beans; null without an application. + private final Backend.Application application; + /// The @SessionScope beans of every session that has any, by session id. + /// Kept here rather than trusted to the store: a database store hands back a + /// NEW HttpSession on every request, so beans living only on that object + /// would be built again per request and never destroyed. + private final Map beans = new HashMap(); + private boolean closed; + /// Set by the first lookup: from then on sessions live in the store, and + /// replacing it would lose them. + private boolean used; + + /// Default settings and an in-memory store, for a server with no generated + /// application. + public Sessions() { + this(null); + } + + Sessions(Backend.Application application) { + this.application = application; + } + + /// Reads `cn1.session.*` into a server's session settings. Called by the + /// server when it starts; every server has its own, because cookies are not + /// scoped by port and a client of two servers on one host would otherwise + /// present one server's session to the other. + /// + /// #### Parameters + /// + /// - `tls`: whether the server terminates TLS, for `secure=auto` + /// + /// - `pool`: the database, for `store=db` + /// + /// - `application`: destroys the session-scoped beans, or null + public static Sessions configure(Config config, boolean tls, DataSource pool, + Backend.Application application) throws IOException { + Sessions out = new Sessions(application); + out.cookieName = config.get("cn1.session.cookie", "CN1SESSION"); + if (!isToken(out.cookieName)) { + // Emitted verbatim in Set-Cookie: an empty name, a space, a ';' or a + // control character makes a header the browser ignores or the writer + // drops, and every request then starts another session nobody can + // come back to. + throw new IOException("cn1.session.cookie is \"" + out.cookieName + "\"; a cookie " + + "name is one or more letters, digits and !#$%&'*+-.^_`|~"); + } + out.timeoutSeconds = config.getInt("cn1.session.timeout", 1800); + if (out.timeoutSeconds < 0) { + // Zero is the documented "never"; a negative one is a typo that + // isExpired() would also read as never, silently making every + // sign-in permanent. + throw new IOException("cn1.session.timeout is " + out.timeoutSeconds + + "; it must be a number of seconds, or 0 for sessions that never " + + "expire"); + } + String site = config.get("cn1.session.same-site", "Lax"); + if (!"Lax".equalsIgnoreCase(site) && !"Strict".equalsIgnoreCase(site) + && !"None".equalsIgnoreCase(site)) { + throw new IOException("cn1.session.same-site is \"" + site + + "\"; it must be Lax, Strict or None"); + } + out.sameSite = site; + String secureSetting = config.get("cn1.session.secure", "auto").trim(); + if ("auto".equalsIgnoreCase(secureSetting)) { + out.secure = tls; + } else if ("true".equalsIgnoreCase(secureSetting)) { + out.secure = true; + } else if ("false".equalsIgnoreCase(secureSetting)) { + out.secure = false; + } else { + // Refused rather than read as false: a typo such as "tru" would + // otherwise start a TLS server whose session cookies a browser also + // sends over plain HTTP. + throw new IOException("cn1.session.secure is \"" + secureSetting + + "\"; it must be auto, true or false"); + } + if ("None".equalsIgnoreCase(out.sameSite) && !out.secure) { + // Browsers drop a SameSite=None cookie that is not Secure, so the + // session would silently never come back. + throw new IOException("cn1.session.same-site=None needs a Secure cookie; " + + "set cn1.session.secure=true or serve TLS"); + } + String kind = config.get("cn1.session.store", "memory"); + if ("db".equalsIgnoreCase(kind)) { + if (pool == null) { + throw new IOException("cn1.session.store=db needs a database, and this " + + "server has none"); + } + out.setStore(new Db(pool, config.get("cn1.session.namespace", ""))); + } else if (!"memory".equalsIgnoreCase(kind)) { + throw new IOException("cn1.session.store is \"" + kind + + "\"; it must be memory or db"); + } + return out; + } + + /// Whether `name` is an RFC 6265 cookie-name: an HTTP token. + static boolean isToken(String name) { + if (name == null || name.length() == 0) { + return false; + } + for (int iter = 0 ; iter < name.length() ; iter++) { + char c = name.charAt(iter); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || "!#$%&'*+-.^_`|~".indexOf(c) >= 0; + if (!ok) { + return false; + } + } + return true; + } + + /// Replaces the store, for one of the application's own -- before the first + /// request uses a session. A server hands out sessions from the moment it + /// listens, so a store set later would lose the ones already in the old + /// store, cookies and beans alike; `Backend.Builder.sessionStore` installs one + /// before the listener binds. + /// + /// #### Throws + /// + /// - `IllegalStateException`: once a session has been looked up + public synchronized void setStore(SessionStore replacement) { + if (replacement == null) { + throw new IllegalArgumentException("No store"); + } + if (used) { + throw new IllegalStateException("Sessions are already in use, and replacing their " + + "store now would lose them; set it with Backend.builder().sessionStore(...), " + + "which installs it before the server listens"); + } + store = replacement; + } + + /// The store sessions are kept in. + public synchronized SessionStore getStore() { + return store; + } + + /// The name of the session cookie. + public synchronized String getCookieName() { + return cookieName; + } + + /// A new, unguessable session id: 192 random bits. + static String newId() { + try { + return Base64Url.encode(Crypto.randomBytes(24)); + } catch (IOException err) { + // A session id from anything weaker is a session anyone can guess, + // so there is no fallback to fall back to. + throw new IllegalStateException("No secure random source for a session id: " + + err.getMessage(), err); + } + } + + /// The request's session, creating one when `create` is set. What + /// `Request.getSession` calls. + HttpSession find(String cookieValue, boolean create) throws IOException { + SessionStore s; + int timeout; + synchronized (this) { + s = store; + timeout = timeoutSeconds; + used = true; + } + long now = System.currentTimeMillis(); + purgeIfDue(s, now); + if (cookieValue != null && cookieValue.length() > 0) { + HttpSession found = s.load(cookieValue); + // A request still using the session counts as use: the database store + // writes the last use only when that request ends, so a long request + // makes the stored time look expired to the next one. Expiring it here + // would pull the row and the beans out from under the first request -- + // the purge spares these ids for the same reason. + if (found != null && found.isValid() + && (!found.isExpired(now) || isInUse(cookieValue))) { + found.touch(now); + found.owner = this; + synchronized (this) { + // The beans are in use from now until this request ends: a + // purge by another request during a long one -- even one that + // outlasts the timeout -- must not destroy what it is using. + Held held = (Held) beans.get(cookieValue); + if (held != null) { + held.lastAccessed = now; + } + } + return found; + } + if (found != null) { + s.delete(cookieValue); + destroy(take(cookieValue)); + } + } + if (!create) { + return null; + } + HttpSession created = new HttpSession(newId(), now, now, timeout); + created.markNew(); + created.owner = this; + return created; + } + + private synchronized boolean isInUse(String id) { + return idsInUse().contains(id); + } + + /// Expires what is due, at most once a minute. Package-private so a test can + /// run it at a chosen `now` instead of waiting for the next lookup. + void purgeIfDue(SessionStore s, long now) { + List expired = null; + java.util.Set busy; + synchronized (this) { + if (now - lastPurge < PURGE_INTERVAL) { + return; + } + lastPurge = now; + busy = idsInUse(); + Iterator it = beans.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + Held h = (Held) e.getValue(); + // The database store accepts a row for a touch interval past the + // timeout, because the last use it has stored lags; the beans get + // the same grace, or a request in that window would find the row + // still valid after its beans were destroyed, and build a second set. + long grace = s instanceof Db ? Db.touchInterval(h.maxInactiveSeconds) : 0; + if (h.maxInactiveSeconds > 0 + && now - h.lastAccessed > h.maxInactiveSeconds * 1000L + grace + && !busy.contains(e.getKey())) { + it.remove(); + if (expired == null) { + expired = new ArrayList(); + } + expired.add(h.beans); + } + } + } + if (expired != null) { + for (Object element : expired) { + destroy((Object[]) element); + } + } + try { + // The store's own entries too spare the sessions in use: a request + // that outlasts the timeout would otherwise lose its session -- the + // memory store does not save it again unless it changed, and the + // database store's touch and save find no row. A store of the + // application's own sees the plain call. + if (s instanceof Memory) { + ((Memory) s).purgeExpired(now, busy); + } else if (s instanceof Db) { + ((Db) s).purgeExpired(now, busy); + } else { + s.purgeExpired(now); + } + } catch (IOException err) { + System.err.println("Could not purge expired sessions: " + err.getMessage()); + } + } + + /// Stores what the request did to its session and adds the cookie the client + /// needs. Called by the server after the handler returns. + HttpServer.Response finish(HttpSession session, HttpServer.Response response) + throws IOException { + return finish(null, session, response); + } + + /// [#finish(HttpSession,HttpServer.Response)] for the request that used the + /// session, which is what tells its own rotation from another request's. + HttpServer.Response finish(HttpServer.Request request, HttpSession session, + HttpServer.Response response) throws IOException { + if (session == null) { + return response; + } + SessionStore s = getStore(); + String cookie = null; + String previous = session.previousId(); + String found = request == null || request.sessionIdsFound == null ? null + : (String) request.sessionIdsFound.get(session); + if (previous != null && found != null && !found.equals(previous) + && !session.isNew()) { + // The id this rotation replaced is not the one this request found the + // session under: another request of the client rotated the SAME object + // -- the memory store shares one per session -- and has already sent + // that id in its Set-Cookie. Moving on from it would delete the id the + // other response hands the client, and if that response arrived last + // the client would be signed out. So this rotation is undone: the + // session keeps the id already announced, and this request sends none. + // The database store's copies never get here; its conditional move + // settles the same race. + session.undoRotation(previous); + previous = null; + } + if (previous != null && request != null && !session.isNew() + && !session.rotatedFor(request)) { + // Another request's rotation, still in flight on the shared object: + // that request stores it and sends the new id when it finishes. This + // one announces nothing, and leaves the rotation pending for it. + keep(session, null); + return response; + } + if (response == null && session.isValid() && (session.isNew() || previous != null)) { + // No response of the handler's to carry a cookie -- it returned + // nothing, or threw, and the server answers 404 or 500 itself. A new + // id stored now would be one the client never learns: kept until it + // expired, with its beans. So a new session is dropped, and a + // rotation is not made: the client keeps the id it has. + if (session.isNew()) { + destroy(take(session.getId())); + } else { + // The rotation is undone on the session itself too, and what else + // the request changed is kept, under the id the client still has -- + // as a failed request's changes are when there was no rotation. The + // new id was never stored, and leaving it on the object let a later + // request of the memory store move the beans to it. + revertRotation(session, previous); + if (session.isDirty()) { + s.save(session, null); + session.clean(); + } + keep(session, null); + } + return null; + } + if (!session.isValid()) { + s.delete(session.getId()); + // The beans end with the session, not with whatever next finds it gone + // -- but not under a request still using them: see retire(). + destroy(retire(session.getId(), session)); + if (previous != null) { + s.delete(previous); + destroy(retire(previous, session)); + } + destroy(session.takeLocalBeans()); + cookie = cookie("", 0); + } else { + if (session.isDirty()) { + boolean announce = session.isNew() || previous != null; + try { + s.save(session, previous); + } catch (IOException err) { + revertFailedRotation(session, previous); + throw err; + } catch (RuntimeException err) { + revertFailedRotation(session, previous); + throw err; + } + if (session.isRotationLost()) { + // Another request of this client moved the session first, and + // its response carries the id that has a row. Announcing this + // one could replace that cookie, if it arrived last, with an id + // nothing stores: the client would be signed out. The beans stay + // where they are for the request that won to move. + announce = false; + previous = null; + } + if (announce) { + cookie = cookie(session.getId(), -1); + } + session.clean(); + } else if (s instanceof Db) { + ((Db) s).touchIfStale(session); + } + keep(session, previous); + } + if (cookie == null || response == null) { + return response; + } + return withHeader(response, "Set-Cookie", cookie); + } + + /// Puts a rotated session back under `previous`: its id, and the beans a + /// lookup after the rotation already moved to the new one. + private void revertRotation(HttpSession session, String previous) { + synchronized (this) { + Held moved = (Held) beans.remove(session.getId()); + if (moved != null) { + beans.put(previous, moved); + } + } + session.undoRotation(previous); + } + + /// A rotation whose save failed: the response carries no Set-Cookie, so the + /// client keeps the old id, and everything must stay under it. Left moved, + /// the beans sat under an id nobody would present -- the next request built + /// a second set while the first waited for expiry -- and the memory store + /// kept a session whose id no longer matched its key. + private void revertFailedRotation(HttpSession session, String previous) { + if (previous != null && !session.isNew()) { + revertRotation(session, previous); + } + } + + /// After a request used the session: moves its beans to its new id when it + /// was rotated, and notes the use, which is what keeps them from expiring. + private void keep(HttpSession session, String previousId) { + Object[] late = null; + synchronized (this) { + Held held = previousId == null ? null : (Held) beans.remove(previousId); + if (held != null) { + beans.put(session.getId(), held); + } else { + held = (Held) beans.get(session.getId()); + } + if (held == null) { + return; + } + held.lastAccessed = session.getLastAccessedTime(); + held.maxInactiveSeconds = session.getMaxInactiveInterval(); + if (closed) { + // The server stopped while this request was in flight; nothing + // will destroy what is kept after close(). + beans.remove(session.getId()); + late = held.beans; + } + } + destroy(late); + } + + /// The session-scoped beans of the session with this id, shared by every + /// copy of it a request loads -- a database store hands each request its own + /// HttpSession, so beans kept on that object would be built once per copy. + /// Created atomically under this lock, the first time any copy asks. + synchronized Object[] sharedBeans(HttpSession session, int count) { + Held held = holderFor(session); + if (held.beans == null || held.beans.length < count) { + Object[] grown = new Object[count]; + if (held.beans != null) { + System.arraycopy(held.beans, 0, grown, 0, held.beans.length); + } + held.beans = grown; + } + return held.beans; + } + + /// The object generated code locks while it builds one session's bean. + synchronized Object beanLock(HttpSession session) { + return holderFor(session); + } + + /// Sessions requests are using right now -- the HttpSession object, with how + /// many requests hold it: the in-memory store hands concurrent requests one + /// object, the database store each its own copy. Their beans are never + /// expired while any is in use: a request can outlast the inactivity + /// timeout, and destroying its session's beans under it would hand it closed + /// resources. By object, not id, so a session rotated or replaced during the + /// request is still covered: the purge reads each one's CURRENT id. + private final Map inUse = new HashMap(); + /// The beans of sessions invalidated while another request still held a copy + /// of them, by id. That request goes on using them until it ends -- its own + /// proxies look here rather than building a fresh set under a deleted id -- + /// and the last one to leave destroys them. + private final Map retired = new HashMap(); + + /// A request has resolved `session`; counted until [#leave]. + void enter(HttpServer.Request request, HttpSession session) { + enter(request, session, session.getId()); + } + + /// [#enter(HttpServer.Request, HttpSession)], recording `foundUnder` -- the + /// id the request looked the session up by, its cookie -- as the one it found + /// it under. Not the object's current id: the memory store hands every request + /// the SAME object, and one arriving while a login is rotating it read the new + /// id there. finish() then took the login's own rotation for one already + /// announced by another request and undid it -- the authenticated attributes + /// stayed under the old, attacker-known id and no response sent the new one, + /// which is exactly what the rotation exists to prevent. + synchronized void enter(HttpServer.Request request, HttpSession session, + String foundUnder) { + if (request.sessionsInUse == null) { + request.sessionsInUse = new ArrayList(1); + } else if (request.sessionsInUse.contains(session)) { + return; + } + request.sessionsInUse.add(session); + if (request.sessionIdsFound == null) { + request.sessionIdsFound = new HashMap(); + } + request.sessionIdsFound.put(session, foundUnder); + int[] count = (int[]) inUse.get(session); + if (count == null) { + count = new int[1]; + inUse.put(session, count); + } + count[0]++; + } + + /// The request is over; the sessions it used may expire again. + void leave(HttpServer.Request request) { + List done = null; + synchronized (this) { + if (!release(request)) { + return; + } + // Retired beans whose last user this was end now, outside the lock. + Iterator it = retired.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + if (!usedByOthers((String) e.getKey(), null)) { + if (done == null) { + done = new ArrayList(1); + } + done.add(((Held) e.getValue()).beans); + it.remove(); + } + } + } + for (int iter = 0 ; done != null && iter < done.size() ; iter++) { + destroy((Object[]) done.get(iter)); + } + } + + /// The bookkeeping half of [#leave]; false when the request held nothing. + private boolean release(HttpServer.Request request) { + List used = request.sessionsInUse; + if (used == null) { + return false; + } + request.sessionsInUse = null; + // With the list: a pooled request would otherwise hold every session a + // keep-alive connection ever used, and its attributes with it. + request.sessionIdsFound = null; + long now = System.currentTimeMillis(); + for (Object element : used) { + HttpSession session = (HttpSession) element; + int[] count = (int[]) inUse.get(session); + if (count != null) { + count[0]--; + if (count[0] <= 0) { + inUse.remove(session); + } + } + Held held = (Held) beans.get(session.getId()); + if (held != null) { + held.lastAccessed = now; + } + } + return true; + } + + /// The beans held under `id`, taken out: to destroy now, or null when + /// there are none -- or when a request other than the one finishing + /// `finishing` still uses the session, in which case they are retired and + /// the last request to leave destroys them. Two requests load separate copies + /// from a database store, and the one that invalidates must not tear down a + /// bean the other is in the middle of calling. + private synchronized Object[] retire(String id, HttpSession finishing) { + Held held = (Held) beans.remove(id); + if (held == null) { + return null; + } + if (usedByOthers(id, finishing)) { + retired.put(id, held); + return null; + } + return held.beans; + } + + /// Whether a request is using a session known by `id` -- as its id, or as + /// the one it had before a rotation not yet stored -- other than through + /// `except`. Under this lock. + private boolean usedByOthers(String id, HttpSession except) { + Iterator it = inUse.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + HttpSession s = (HttpSession) e.getKey(); + if (!id.equals(s.getId()) && !id.equals(s.previousId())) { + continue; + } + // The memory store hands concurrent requests ONE object, so for the + // finishing request's own object it is its count that says whether + // another request holds it too. + int users = ((int[]) e.getValue())[0]; + if (s != except || users > 1) { //NOPMD CompareObjectsWithEquals - session copies are told apart by identity + return true; + } + } + return false; + } + + /// The current ids of the sessions in use. Under this lock. + private java.util.Set idsInUse() { + java.util.Set ids = new java.util.HashSet(); + Iterator it = inUse.keySet().iterator(); + while (it.hasNext()) { + HttpSession session = (HttpSession) it.next(); + ids.add(session.getId()); + // And the id it had before a rotation this request has not stored yet: + // changeSessionId() moves the object's id at once, but the row, and the + // client's cookie, stay under the old id until the request ends. A + // purge or lookup meanwhile must spare that one too. + String previous = session.previousId(); + if (previous != null) { + ids.add(previous); + } + } + return ids; + } + + private Held holderFor(HttpSession session) { + String id = session.getId(); + Held held = (Held) beans.get(id); + if (held == null) { + // Invalidated by another request while this one still runs: it keeps + // the beans it had, rather than building new ones under a dead id. + Held parting = (Held) retired.get(id); + if (parting != null) { + return parting; + } + } + String previous = held == null ? session.previousId() : null; + if (previous != null) { + // Rotated by changeSessionId() earlier in this request: the beans + // built under the old id are this session's still. + held = (Held) beans.remove(previous); + if (held != null) { + beans.put(id, held); + } else { + // Unless another request invalidated that old id meanwhile and + // its beans were retired: this copy's rotation will lose, so it + // uses those rather than building a set under the new id. + Held parting = (Held) retired.get(previous); + if (parting != null) { + return parting; + } + } + } + if (held == null) { + held = new Held(); + held.lastAccessed = session.getLastAccessedTime(); + held.maxInactiveSeconds = session.getMaxInactiveInterval(); + beans.put(id, held); + } + return held; + } + + private synchronized Object[] take(String id) { + Held held = (Held) beans.remove(id); + return held == null ? null : held.beans; + } + + /// A NEW session whose first save threw: the request fails without its + /// cookie, so no request can ever present its id. Its session-scoped beans + /// are destroyed and the row a failure part-way through may have written is + /// removed, instead of both waiting for an expiry that a zero timeout never + /// brings. An existing session is left alone: its client still holds the id. + void discardUnsaved(HttpSession session) { + if (session == null || !session.isNew()) { + return; + } + destroy(take(session.getId())); + try { + getStore().delete(session.getId()); + } catch (IOException err) { + System.err.println("Could not remove a session whose first save failed: " + + err.getMessage()); + } catch (RuntimeException err) { + System.err.println("Could not remove a session whose first save failed: " + err); + } + } + + /// Runs the destroy methods of one session's beans. + private void destroy(Object[] sessionBeans) { + if (application != null && sessionBeans != null) { + ended(sessionBeans); + } + } + + private void ended(Object[] sessionBeans) { + try { + application.sessionEnded(sessionBeans); + } catch (Throwable err) { + System.err.println("Destroying a session's beans failed: " + err); + } + } + + /// Destroys the beans of every session still open. Called when the server + /// stops, after its requests have drained, as Spring closes its session + /// scope with the context. + void close() { + List all; + synchronized (this) { + closed = true; + all = new ArrayList(beans.values()); + all.addAll(retired.values()); + beans.clear(); + retired.clear(); + } + for (Object element : all) { + destroy(((Held) element).beans); + } + } + + private synchronized String cookie(String value, int maxAge) { + StringBuilder sb = new StringBuilder(cookieName).append('=').append(value) + .append("; Path=/; HttpOnly; SameSite=").append(sameSite); + if (secure) { + sb.append("; Secure"); + } + if (maxAge >= 0) { + sb.append("; Max-Age=").append(maxAge); + } + return sb.toString(); + } + + /// One session's beans and when it was last used, for expiring them. + private static final class Held { + Object[] beans; + long lastAccessed; + int maxInactiveSeconds; + } + + /// `response` with one more header, as a NEW Response. Neither the + /// handler's Response nor its header map is modified: either may be a constant + /// shared by every request, and a session cookie written into one would be + /// sent to the next client that got it -- a session handed to a stranger. A + /// header the handler already sets under the same name is kept beside this one. + static HttpServer.Response withHeader(HttpServer.Response response, String name, + String value) { + Map copy = response.extraHeaders == null ? new LinkedHashMap() + : new LinkedHashMap(response.extraHeaders); + Iterator entries = copy.entrySet().iterator(); + while (entries.hasNext()) { + Map.Entry entry = (Map.Entry) entries.next(); + Object key = entry.getKey(); + if (key != null && name.equalsIgnoreCase(String.valueOf(key))) { + Object existing = entry.getValue(); + List both = new ArrayList(); + if (existing instanceof List) { + both.addAll((List) existing); + } else if (existing != null) { + both.add(existing); + } + both.add(value); + copy.put(key, both); + return response.withHeaders(copy); + } + } + copy.put(name, value); + return response.withHeaders(copy); + } + + /// The value of the cookie called `name` in a Cookie header, or null. + static String cookieValue(String header, String name) { + if (header == null) { + return null; + } + int at = 0; + int n = header.length(); + while (at < n) { + while (at < n && (header.charAt(at) == ' ' || header.charAt(at) == ';')) { + at++; + } + int end = header.indexOf(';', at); + if (end < 0) { + end = n; + } + int eq = header.indexOf('=', at); + if (eq > at && eq < end) { + String key = header.substring(at, eq).trim(); + if (key.equals(name)) { + String value = header.substring(eq + 1, end).trim(); + if (value.length() >= 2 && value.charAt(0) == '"' + && value.charAt(value.length() - 1) == '"') { + value = value.substring(1, value.length() - 1); + } + return value; + } + } + at = end + 1; + } + return null; + } + + /// Sessions in this process's memory. The default. + public static final class Memory implements SessionStore { + private final Map sessions = new LinkedHashMap(); + + @Override + public synchronized HttpSession load(String id) { + HttpSession found = (HttpSession) sessions.get(id); + // Stored under the id it had; a login's changeSessionId() moves the + // key only when that request saves. Until then the OLD id -- the one + // an attacker may have planted -- must no longer find the session it + // just authenticated, as a servlet container's changeSessionId() + // retires it at once. A rotation that is undone restores the id, and + // the entry answers again. + if (found != null && !id.equals(found.getId())) { + return null; + } + return found; + } + + @Override + public synchronized void save(HttpSession session, String previousId) { + if (previousId != null) { + sessions.remove(previousId); + } + sessions.put(session.getId(), session); + } + + @Override + public synchronized void delete(String id) { + sessions.remove(id); + } + + @Override + public int purgeExpired(long now) { + return purgeExpired(now, java.util.Collections.EMPTY_SET); + } + + /// [#purgeExpired(long)], sparing the sessions whose ids are in `busy`. + synchronized int purgeExpired(long now, java.util.Set busy) { + int purged = 0; + Iterator it = sessions.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + HttpSession s = (HttpSession) e.getValue(); + if (busy.contains(e.getKey()) && s.isValid()) { + continue; + } + if (s.isExpired(now) || !s.isValid()) { + it.remove(); + purged++; + } + } + return purged; + } + + @Override + public synchronized int size() { + return sessions.size(); + } + } + + /// Sessions in the server's database, in `cn1_http_session`, so every + /// instance of a server sees every session. Attributes are stored as JSON. + /// + /// Every row carries a namespace, and every query is limited to the store's + /// own. Empty unless `cn1.session.namespace` is set, so by default every + /// server using the database shares its sessions -- as Spring Session's JDBC + /// store shares one table. Browsers do not scope cookies by port, so two + /// DIFFERENT servers on one host sharing a database then accept each other's + /// session, sign-in included; giving each its own namespace keeps them apart, + /// the way Spring's table-name setting does, while replicas of one server + /// keep a shared one. + public static final class Db implements SessionStore { + private static final String TABLE = "cn1_http_session"; + /// How stale the stored last-access time may get before a read refreshes it. + private static final long TOUCH_INTERVAL = 60000L; + private final DataSource pool; + private final String namespace; + private boolean ready; + + /// A store in the empty namespace. + public Db(DataSource pool) { + this(pool, ""); + } + + /// A store whose sessions only servers with the same `namespace` see. + public Db(DataSource pool, String namespace) { + this.pool = pool; + this.namespace = namespace == null ? "" : namespace; + } + + /// The namespace this store reads and writes. + public String getNamespace() { + return namespace; + } + + private synchronized void prepare() throws IOException { + if (ready) { + return; + } + Dialect d = pool.dialect(); + pool.execute("CREATE TABLE IF NOT EXISTS " + TABLE + " (id " + + d.assignedKeyColumn(Dialect.TEXT) + ", created " + + d.columnType(Dialect.BIGINT) + " NOT NULL, last_accessed " + + d.columnType(Dialect.BIGINT) + " NOT NULL, max_inactive " + + d.columnType(Dialect.INTEGER) + " NOT NULL, attributes " + + d.columnType(Dialect.TEXT) + ", version " + + d.columnType(Dialect.BIGINT) + " NOT NULL, namespace " + + d.columnType(Dialect.TEXT) + " NOT NULL)", null); + ready = true; + } + + @Override + public HttpSession load(String id) throws IOException { + prepare(); + Map row = pool.queryOne("SELECT created, last_accessed, max_inactive, attributes " + + "FROM " + TABLE + " WHERE id = ? AND namespace = ?", + new Object[] {id, namespace}); + if (row == null) { + return null; + } + int maxInactive = (int) number(row.get("max_inactive")); + HttpSession s = new HttpSession(id, number(row.get("created")), + number(row.get("last_accessed")), maxInactive); + // The stored time lags the real last use by up to one touch interval, + // so that much more is allowed before calling the session expired -- + // late by at most the interval, never early. + s.setExpiryGrace(touchInterval(maxInactive)); + Object text = row.get("attributes"); + if (text instanceof String && ((String) text).length() > 0) { + s.loadAttributes(Json.parseObject((String) text)); + } + return s; + } + + private static long number(Object value) { + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + + /// Attempts at an optimistic save before two requests' races are reported. + private static final int SAVE_ATTEMPTS = 8; + + /// Two requests of one client load separate copies of the session, and a + /// copy written back whole would replace whatever the other request saved + /// meanwhile -- its attributes, and a newer last use with an older one. + /// So an existing session is saved by re-reading the row, applying only + /// the attributes THIS request set or removed, and writing it back only + /// if its version has not moved; the last use only ever goes forward. + @Override + public void save(HttpSession session, String previousId) throws IOException { + prepare(); + if (previousId != null && !session.isNew()) { + rotate(session, previousId); + return; + } + if (session.isNew()) { + // A new id: nobody else can have written this row yet. + pool.execute("INSERT INTO " + TABLE + " (id, created, last_accessed, " + + "max_inactive, attributes, version, namespace) VALUES (?, ?, ?, ?, ?, " + + "0, ?)", + new Object[] {session.getId(), Long.valueOf(session.getCreationTime()), + Long.valueOf(session.getLastAccessedTime()), + Integer.valueOf(session.getMaxInactiveInterval()), + Json.write(session.attributesCopy()), namespace}); + session.storedAccessed = session.getLastAccessedTime(); + if (previousId != null) { + delete(previousId); + } + return; + } + java.util.Set changed = session.changedNames(); + Map mine = session.attributesCopy(); + Long used = Long.valueOf(session.getLastAccessedTime()); + for (int attempt = 0 ; attempt < SAVE_ATTEMPTS ; attempt++) { + Map row = pool.queryOne("SELECT max_inactive, attributes, version FROM " + + TABLE + " WHERE id = ? AND namespace = ?", + new Object[] {session.getId(), namespace}); + if (row == null) { + // The row is gone: another request invalidated this session, + // or it expired, while this one held its own copy. Writing it + // back would undo a logout with a stale cookie. + return; + } + Map merged = new LinkedHashMap(); + Object text = row.get("attributes"); + if (text instanceof String && ((String) text).length() > 0) { + merged.putAll(Json.parseObject((String) text)); + } + Iterator names = changed.iterator(); + while (names.hasNext()) { + Object name = names.next(); + if (mine.containsKey(name)) { + merged.put(name, mine.get(name)); + } else { + merged.remove(name); + } + } + int maxInactive = session.isMaxInactiveChanged() + ? session.getMaxInactiveInterval() : (int) number(row.get("max_inactive")); + long version = number(row.get("version")); + int updated = pool.execute("UPDATE " + TABLE + " SET attributes = ?, " + + "max_inactive = ?, last_accessed = CASE WHEN last_accessed > ? " + + "THEN last_accessed ELSE ? END, version = ? WHERE id = ? AND " + + "namespace = ? AND version = ?", new Object[] {Json.write(merged), + Integer.valueOf(maxInactive), used, used, Long.valueOf(version + 1), + session.getId(), namespace, Long.valueOf(version)}); + if (updated > 0) { + session.storedAccessed = session.getLastAccessedTime(); + return; + } + // Another request saved between the read and the write: read its + // result and apply this request's changes to that instead. + } + throw new IOException("Session changes could not be saved: other requests " + + "kept changing it (" + SAVE_ATTEMPTS + " attempts)"); + } + + /// Moves an existing session to its new id -- changeSessionId() at sign-in + /// -- in one transaction, and only while the old row still exists. Done + /// as an unconditional insert, a request holding a stale copy would + /// recreate a session another request had just invalidated, under a fresh + /// id it then hands the client: a logout undone. The old row is locked + /// where the engine can, so an invalidation and a rotation of one session + /// happen one after the other, and this request's changes are merged onto + /// the row as it stands, as a plain save does. + private void rotate(final HttpSession session, final String previousId) + throws IOException { + final String lock = "sqlite".equals(pool.dialect().getName()) ? "" : " FOR UPDATE"; + final java.util.Set changed = session.changedNames(); + final Map mine = session.attributesCopy(); + Object moved; + try { + moved = pool.inTransaction(new Rotation(session, previousId, lock, changed, mine, + namespace)); + } catch (IOException err) { + throw err; + } catch (Exception err) { + throw new IOException("Could not rotate the session: " + err.getMessage(), err); + } + if (!Boolean.TRUE.equals(moved)) { + session.markRotationLost(); + return; + } + session.storedAccessed = session.getLastAccessedTime(); + } + + /// [#rotate]'s transaction, as a named class rather than one holding the store. + private static final class Rotation implements DataSource.Work { + private final HttpSession session; + private final String previousId; + private final String lock; + private final java.util.Set changed; + private final Map mine; + private final String namespace; + + Rotation(HttpSession session, String previousId, String lock, java.util.Set changed, + Map mine, String namespace) { + this.session = session; + this.previousId = previousId; + this.lock = lock; + this.changed = changed; + this.mine = mine; + this.namespace = namespace; + } + + @Override + public Object run(Database db) throws Exception { + Map row = db.queryOne("SELECT created, max_inactive, attributes FROM " + + TABLE + " WHERE id = ? AND namespace = ?" + lock, + new Object[] {previousId, namespace}); + if (row == null) { + // Rotated or invalidated meanwhile by another request of this + // client: stays gone, and the caller must not announce the id. + return Boolean.FALSE; + } + Map merged = new LinkedHashMap(); + Object text = row.get("attributes"); + if (text instanceof String && ((String) text).length() > 0) { + merged.putAll(Json.parseObject((String) text)); + } + Iterator names = changed.iterator(); + while (names.hasNext()) { + Object name = names.next(); + if (mine.containsKey(name)) { + merged.put(name, mine.get(name)); + } else { + merged.remove(name); + } + } + int maxInactive = session.isMaxInactiveChanged() + ? session.getMaxInactiveInterval() + : (int) number(row.get("max_inactive")); + db.execute("INSERT INTO " + TABLE + " (id, created, last_accessed, " + + "max_inactive, attributes, version, namespace) VALUES (?, ?, ?, ?, " + + "?, 0, ?)", + new Object[] {session.getId(), Long.valueOf(number(row.get("created"))), + Long.valueOf(session.getLastAccessedTime()), + Integer.valueOf(maxInactive), Json.write(merged), namespace}); + db.execute("DELETE FROM " + TABLE + " WHERE id = ? AND namespace = ?", + new Object[] {previousId, namespace}); + return Boolean.TRUE; + } + } + + void touchIfStale(HttpSession session) throws IOException { + // Reads keep a session alive too, but writing the time on every one + // would turn every request into a database write. Within a minute is + // close enough for a timeout measured in tens of minutes. + long now = System.currentTimeMillis(); + if (now - session.storedAccessed < touchInterval(session.getMaxInactiveInterval())) { + return; + } + // Forward only: a slower request must not move the last use back. + Long at = Long.valueOf(now); + pool.execute("UPDATE " + TABLE + " SET last_accessed = CASE WHEN last_accessed " + + "> ? THEN last_accessed ELSE ? END WHERE id = ? AND namespace = ?", + new Object[] {at, at, session.getId(), namespace}); + session.storedAccessed = now; + } + + /// How stale the stored last use may get: a minute, or a quarter of the + /// timeout when that is shorter. A fixed minute let a ten-second session + /// used every five seconds expire, because its reads were never written. + static long touchInterval(int maxInactiveSeconds) { + if (maxInactiveSeconds <= 0) { + return TOUCH_INTERVAL; + } + return Math.min(TOUCH_INTERVAL, maxInactiveSeconds * 250L); + } + + @Override + public void delete(String id) throws IOException { + prepare(); + pool.execute("DELETE FROM " + TABLE + " WHERE id = ? AND namespace = ?", + new Object[] {id, namespace}); + } + + @Override + public int purgeExpired(long now) throws IOException { + return purgeExpired(now, java.util.Collections.EMPTY_SET); + } + + /// [#purgeExpired(long)], sparing the sessions whose ids are in `busy`. + int purgeExpired(long now, java.util.Set busy) throws IOException { + prepare(); + // The same grace load() allows: the timeout plus the touch interval, + // min(a minute, a quarter of the timeout). + Long at = Long.valueOf(now); + // One placeholder per session in use -- as many as requests running, + // never the table. + StringBuilder spare = new StringBuilder(); + List params = new ArrayList(); + params.add(at); + params.add(at); + // Only this namespace's: which of another server's sessions are in + // use is something only that server knows. + params.add(namespace); + if (!busy.isEmpty()) { + spare.append(" AND id NOT IN ("); + Iterator ids = busy.iterator(); + for (int iter = 0 ; ids.hasNext() ; iter++) { + spare.append(iter == 0 ? "?" : ", ?"); + params.add(ids.next()); + } + spare.append(')'); + } + // + // Decimal multipliers, never integer ones: max_inactive is an INTEGER + // column, and PostgreSQL multiplies INTEGER by an integer literal in + // 32 bits, so a 30-day timeout overflowed and every purge failed. + // CAST(... AS BIGINT) is not MySQL; a decimal literal widens on all + // three engines, exactly on PostgreSQL and MySQL, and within a + // double's exact range on SQLite. + return pool.execute("DELETE FROM " + TABLE + " WHERE max_inactive > 0 AND " + + "(last_accessed + max_inactive * 1250.0 < ? OR " + + "last_accessed + max_inactive * 1000.0 + " + TOUCH_INTERVAL + " < ?) " + + "AND namespace = ?" + spare.toString(), params.toArray()); + } + + @Override + public int size() { + return -1; + } + } +} diff --git a/vm/backend/src/com/codename1/backend/Span.java b/vm/backend/src/com/codename1/backend/Span.java index 2a446cbd651..fb49d3e470d 100644 --- a/vm/backend/src/com/codename1/backend/Span.java +++ b/vm/backend/src/com/codename1/backend/Span.java @@ -46,6 +46,8 @@ public abstract class Span { /// What was current when this span became current, restored when it ends. /// Owned by [Tracing]; a tracer never reads it. Span previous; + /// The tracer that made this span, which the spans started under it use too. + Tracer owner; /// Whether [Tracing] made this span current and must restore on end. boolean entered; /// A tracer to shut down once this server span has ended, set when the diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java new file mode 100644 index 00000000000..fc5f2d13b2f --- /dev/null +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -0,0 +1,422 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.util.LinkedList; + +/// A named place background work runs: a pool of platform threads, or virtual +/// threads on the server's hosts. +/// +/// Configured as `cn1.task.executor..threads` and +/// `cn1.task.executor..kind` (`platform` or `virtual`), +/// and obtained through [Tasks#executor]. The threads of a pool start on the +/// first task, so an executor the configuration names and nothing uses costs a +/// map entry. +/// +/// A virtual executor hands each task to a virtual thread of its own; on a +/// runtime or a server that has none -- the Java SE arm, a TLS server, Windows -- +/// it runs the task on its pool instead, which is why it has a size too. +public final class TaskExecutor { + /// The executor whose task this thread is running, so shutdown() can tell + /// that its caller IS one of the tasks it waits for: an @Async method that + /// stops the server would otherwise wait out the whole timeout on itself. + private static final ThreadLocal RUNNING = new ThreadLocal(); + + /// Whether the calling thread is running a task of any executor. + static boolean runningOnThisThread() { + return RUNNING.get() != null; + } + + private final String name; + /// Not final: an AUTO executor is pinned to the kind a later caller names + /// explicitly -- see [#pin]. Read and written under this executor's lock. + private boolean virtual; + private final int size; + private final LinkedList queue = new LinkedList(); + private Thread[] workers; + private int active; + private long completed; + private long failed; + private boolean shutdown; + /// Tasks dropped unstarted because the shutdown deadline passed. + private long dropped; + /// Whether shutdown ran out of time; nothing may start after that. + private boolean timedOut; + /// The server these executors belong to, which the task threads carry. + private final Tasks.Registry registry; + + TaskExecutor(String name, boolean virtual, int size, Tasks.Registry registry) { + this(name, virtual, false, size, registry); + } + + /// With `auto`, each submission tries a virtual thread and falls back + /// to the pool: decided when the task is submitted, not when the executor is + /// made, because an executor first asked for during start-up -- an @Async + /// call from a @PostConstruct -- exists before the server's hosts do, and + /// deciding then would keep it on platform threads for good. + TaskExecutor(String name, boolean virtual, boolean auto, int size, + Tasks.Registry registry) { + this.name = name; + this.virtual = virtual; + this.auto = auto; + this.size = size < 1 ? 1 : size; + this.registry = registry; + } + + private boolean auto; + + /// Fixes an AUTO executor to one kind of thread, when another caller of the + /// same name asks for that kind explicitly. Otherwise which kind a + /// PLATFORM method got depended on call order: an AUTO call made first + /// created the executor, and the PLATFORM method then ran on virtual threads + /// -- blocking a host on the SQLite or file I/O it asked a platform thread for. + /// Tasks already handed to a virtual thread finish there. + synchronized void pin(boolean toVirtual) { + if (auto) { + auto = false; + virtual = toVirtual; + } + } + + public String getName() { + return name; + } + + /// Whether this executor's tasks go to virtual threads: always for a virtual + /// one, and for an AUTO one while its server has hosts to run them on. + public synchronized boolean isVirtual() { + return virtual || (auto && HttpServer.acceptsVirtualTasks()); + } + + public int getSize() { + return size; + } + + /// Tasks waiting for a thread. + public synchronized int getQueueDepth() { + return queue.size(); + } + + /// Waits until no task of this executor is running, or `deadline` passes. + synchronized void awaitIdle(long deadline) { + while (active > 0) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + return; + } + try { + wait(left); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return; + } + } + } + + /// Tasks running now. + public synchronized int getActiveCount() { + return active; + } + + public synchronized long getCompletedCount() { + return completed; + } + + /// Tasks that ended with an exception nobody received. + public synchronized long getFailedCount() { + return failed; + } + + /// Runs `task` on this executor. + /// + /// #### Throws + /// + /// - `IllegalStateException`: once the server has stopped it + public void execute(Runnable task) { + if (task == null) { + return; + } + boolean tryVirtual; + synchronized (this) { + tryVirtual = virtual || auto; + // Refused and counted BEFORE the hand-off, exactly like a pool task: + // a submission after shutdown must fail rather than run on a server + // that is stopping, and a task already handed to a host must hold + // shutdown()'s drain open from the moment it is accepted -- counting + // it only once it starts let shutdown() return while it still sat in + // a host's inbox. + if (tryVirtual) { + if (shutdown) { + throw new IllegalStateException("Executor " + name + " has been shut down"); + } + active++; + } + } + if (tryVirtual) { + HttpServer host = registry == null ? HttpServer.activeServer() + : registry.virtualHost(); + if (HttpServer.submitVirtualTask(new Counted(this, task), host)) { + return; + } + synchronized (this) { + active--; + // A shutdown() may be waiting on exactly this count; nothing else + // would wake it, and it would sleep out its whole timeout. + notifyAll(); + } + } + synchronized (this) { + if (shutdown) { + throw new IllegalStateException("Executor " + name + " has been shut down"); + } + queue.addLast(task); + if (workers == null) { + startWorkers(); + } + notifyAll(); + } + } + + private void startWorkers() { + workers = new Thread[size]; + for (int iter = 0 ; iter < size ; iter++) { + Thread t = new Thread(new Runnable() { + @Override + public void run() { + work(); + } + }, "cn1-task-" + name + "-" + iter); + t.setDaemon(true); + workers[iter] = t; + t.start(); + } + } + + private void work() { + while (true) { + Runnable task; + synchronized (this) { + while (queue.isEmpty() && !shutdown) { + try { + wait(); + } catch (InterruptedException err) { + return; + } + } + if (queue.isEmpty()) { + notifyAll(); + return; + } + task = (Runnable) queue.removeFirst(); + active++; + } + runCounted(task); + } + } + + void runCounted(Runnable task) { + boolean ok = false; + // The task works for this executor's server: an @Async call it makes, or + // an executor it asks for, is that server's too. + Object previous = Tasks.enter(registry); + Object outer = RUNNING.get(); + RUNNING.set(this); + try { + task.run(); + ok = true; + } catch (Throwable err) { + System.err.println("Task " + task + " on executor " + name + " failed: " + err); + } finally { + RUNNING.set(outer); + Tasks.leave(previous); + synchronized (this) { + active--; + completed++; + if (!ok) { + failed++; + } + if (shutdown && queue.isEmpty()) { + // Not only at zero: a shutdown called from one of this + // executor's own tasks waits for the others to reach one. + notifyAll(); + } + } + } + } + + synchronized void recordFailure() { + failed++; + } + + /// Whether [#shutdown(long)] has been called; it then refuses tasks. + public synchronized boolean isShutdown() { + return shutdown; + } + + /// Stops taking tasks and waits up to `waitMillis` for the queued and + /// running ones to finish. + /// + /// Called from one of this executor's own tasks -- one that stops the + /// server -- that task is not waited for: it cannot finish until this + /// returns. The HTTP drain leaves out the request that stopped it the same way. + public synchronized void shutdown(long waitMillis) { + shutdown = true; + notifyAll(); + boolean fromOwnTask = RUNNING.get() == this; //NOPMD CompareObjectsWithEquals - the executor itself, by identity + int self = fromOwnTask ? 1 : 0; + long deadline = AsyncTask.deadline(System.currentTimeMillis(), Math.max(0, waitMillis)); + while ((active > self || !queue.isEmpty()) && waitMillis > 0) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + break; + } + try { + wait(left); + } catch (InterruptedException err) { + break; + } + } + if (queue.isEmpty() && active <= self) { + return; + } + // Out of time. The server destroys its beans and closes its database + // next, so a task still WAITING must never start: it would run against + // both. Those are dropped and counted. A task already running cannot be + // stopped from outside -- Java has no safe way to -- so its thread is + // interrupted, which ends any wait, sleep or interruptible I/O it is in, + // and it is reported by name so the overrun is visible. + timedOut = true; + int waiting = queue.size(); + dropped += waiting; + // A dropped @Async call still has a Future someone may be waiting on; + // it fails rather than never finishing. + for (Object queued : queue) { + if (queued instanceof AsyncTask) { + ((AsyncTask) queued).abandon("dropped when the server stopped before it " + + "could start"); + } + } + queue.clear(); + if (workers != null) { + Thread caller = Thread.currentThread(); + for (Thread element : workers) { + if (element != caller) { //NOPMD CompareObjectsWithEquals - threads by identity + element.interrupt(); + } + } + } + System.err.println("Executor " + name + " did not finish within the shutdown " + + "timeout: " + waiting + " queued task(s) dropped, " + (active - self) + + " still running"); + } + + /// Tasks the shutdown deadline dropped before they could start. + public synchronized long getDroppedCount() { + return dropped; + } + + /// Runs a task a host accepted for a virtual thread and then could not give + /// one -- no stack, or the server stopping -- on this executor's own platform + /// workers instead. It was accepted before any shutdown and is already counted + /// active, so it is queued even now, and shutdown() waits for it. + static void fallBack(Runnable task) { + if (task instanceof Counted) { + Counted c = (Counted) task; + c.owner.requeue(c.task); + return; + } + Tasks.platform(task); + } + + /// A task whose virtual thread was freed at shutdown before it finished. Its + /// own finally never runs, so what it would have done happens here: the + /// executor stops counting it active -- or shutdown() waits for it until the + /// deadline -- and its Future, if it has one, fails instead of never ending. + static void abandoned(Runnable task) { + Runnable inner = task; + if (task instanceof Counted) { + Counted c = (Counted) task; + inner = c.task; + synchronized (c.owner) { + c.owner.active--; + c.owner.completed++; + c.owner.failed++; + c.owner.notifyAll(); + } + } + if (inner instanceof AsyncTask) { + ((AsyncTask) inner).abandon("abandoned when the server stopped before it finished"); + } + } + + private synchronized void requeue(Runnable task) { + active--; + if (timedOut) { + dropped++; + if (task instanceof AsyncTask) { + ((AsyncTask) task).abandon("dropped when the server stopped before it could " + + "start"); + } + notifyAll(); + return; + } + queue.addLast(task); + if (workers == null) { + startWorkers(); + } else if (shutdown) { + // The pool's threads may already have drained and ended; one more + // runs this and ends in turn, and shutdown() is still waiting on it. + Thread t = new Thread(new Runnable() { + @Override + public void run() { + work(); + } + }, "cn1-task-" + name + "-late"); + t.setDaemon(true); + t.start(); + } + notifyAll(); + } + + @Override + public synchronized String toString() { + return name + (virtual ? " (virtual)" : " (" + size + " threads)"); + } + + /// A task on a virtual thread, counted like one on the pool. execute() has + /// already counted it active; runCounted's finally is the matching decrement. + private static final class Counted implements Runnable { + private final TaskExecutor owner; + private final Runnable task; + + Counted(TaskExecutor owner, Runnable task) { + this.owner = owner; + this.task = task; + } + + @Override + public void run() { + owner.runCounted(task); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java new file mode 100644 index 00000000000..f2c0038e4e4 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -0,0 +1,367 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Where background work runs: the named [TaskExecutor]s, and two +/// shorthands for running something once. +/// +/// ```java +/// Tasks.platform(new Runnable() { public void run() { rebuildIndex(); } }); +/// Tasks.virtual(new Runnable() { public void run() { pingWebhooks(); } }); +/// ``` +/// +/// ## Whose executors +/// +/// Every server has its own executors, sized from its own configuration and +/// stopped with it: two servers in one process -- or one stopped and started +/// again -- must not share a pool, or stopping one would shut down the executor +/// the other's scheduler and `@Async` methods still submit to. The calls +/// here are static because generated code makes them, so they find the server by +/// the calling thread: a request thread, a task thread and a scheduled job all +/// carry the server they work for, and so does the thread that builds and starts +/// the beans. A thread that carries none -- one the program started itself -- +/// gets the most recently started server that is still running. +/// +/// ## Which thread +/// +/// A virtual thread is the cheaper one, and the right one for work that waits +/// on sockets -- a PostgreSQL or MySQL query or an HTTP call parks it. It is the +/// wrong one for SQLite or file work: those block the HOST thread under the +/// virtual thread, and every other virtual thread on that host with it. See +/// `com.codename1.backend.annotations.ThreadKind`. +public final class Tasks { + public static final int AUTO = 0; + public static final int VIRTUAL = 1; + public static final int PLATFORM = 2; + + /// The executor `@Async` methods use when they name none. + public static final String DEFAULT = "default"; + /// The executor scheduled jobs use when they name none. + public static final String SCHEDULING = "scheduling"; + + /// The registry of the server the calling thread works for, when it carries one. + private static final ThreadLocal CURRENT = new ThreadLocal(); + /// Every registry not yet shut down, oldest first. + private static final List LIVE = new ArrayList(); + + private Tasks() { + } + + /// One server's executors. + static final class Registry { + private final Map executors = new LinkedHashMap(); + /// The thread kind each executor was first asked for, when configuration + /// does not decide it: a later request for the other kind is refused, as + /// the build refuses it between annotations, rather than silently getting + /// whichever kind happened to be asked for first. + private final Map requestedKinds = new HashMap(); + private final Config config; + private boolean reportedAuto; + private boolean shutdown; + /// The server whose hosts this registry's virtual tasks run on, once it is + /// listening; null before that. Only meaningful when [#ownedByServer]. + HttpServer server; + /// Whether a server opened this registry, as opposed to a bare process. + final boolean ownedByServer; + + Registry(Config config, boolean ownedByServer) { + this.config = config; + this.ownedByServer = ownedByServer; + } + + /// The server a virtual task of this registry may go to: its own, never + /// another backend's -- whose stop would then wait on, or abandon, a task + /// this one still wants. Null means "run it on a platform thread". + synchronized HttpServer virtualHost() { + return ownedByServer ? server : HttpServer.activeServer(); + } + } + + /// A new registry for a server that is starting, which threads without one then use. + static Registry open(Config config) { + if (config != null) { + // Every executor kind the files set is checked NOW, at start-up: an + // executor is created on first use, and a typo found then is a start + // that looked fine and a job running on the thread it was meant to + // leave. One set only in the environment is checked on creation. + for (Object name : config.keys()) { + String key = (String) name; + if (key.startsWith("cn1.task.executor.") && key.endsWith(".kind")) { + try { + checkedKind(key, config.get(key)); + } catch (java.io.IOException err) { + throw new IllegalStateException(key + " cannot be read: " + + err.getMessage(), err); + } + } + } + } + Registry r = new Registry(config, true); + synchronized (Tasks.class) { + LIVE.add(r); + } + return r; + } + + /// Makes `registry` the calling thread's until [#leave]; answers + /// what to restore. + static Object enter(Registry registry) { + Object previous = CURRENT.get(); + CURRENT.set(registry); + return previous; + } + + /// The calling thread's registry as it is, for restoring later; may be null. + static Object peek() { + return CURRENT.get(); + } + + static void leave(Object previous) { + CURRENT.set(previous); + } + + /// The calling thread's registry, the newest live one, or a new one of defaults. + private static Registry current() { + Registry r = (Registry) CURRENT.get(); + if (r != null) { + return r; + } + synchronized (Tasks.class) { + if (LIVE.isEmpty()) { + // No server is running -- a test calling an @Async method + // directly: executors take their defaults. + LIVE.add(new Registry(null, false)); + } + return (Registry) LIVE.get(LIVE.size() - 1); + } + } + + /// The executor called `name` of the calling thread's server, created + /// on first use. + /// + /// #### Parameters + /// + /// - `kind`: @param kind [#AUTO], [#VIRTUAL] or [#PLATFORM]: what the + /// code asked for, which `cn1.task.executor..kind` + /// overrides + public static TaskExecutor executor(String name, int kind) { + return executor(current(), name, kind); + } + + static TaskExecutor executor(Registry registry, String name, int kind) { + // An unnamed executor is named by the kind of thread asked for, so an + // @Async(thread = VIRTUAL) method never lands on the platform pool an + // unmarked method created first -- they would otherwise share "default". + String key = name == null || name.length() == 0 + ? (kind == VIRTUAL ? DEFAULT + "-virtual" : DEFAULT) : name; + synchronized (registry) { + TaskExecutor existing = (TaskExecutor) registry.executors.get(key); + if (existing != null) { + Integer first = (Integer) registry.requestedKinds.get(key); + if (first != null && kind != AUTO && first.intValue() != AUTO + && first.intValue() != kind) { + throw new IllegalStateException("Executor " + key + " was created for " + + kindName(first.intValue()) + " threads and is now asked for " + + kindName(kind) + " ones; give the two another executor name, " + + "or set cn1.task.executor." + key + ".kind"); + } + if (first != null && first.intValue() == AUTO && kind != AUTO) { + // The explicit kind wins whichever call came first: the + // executor stops choosing per task and runs this kind. + registry.requestedKinds.put(key, Integer.valueOf(kind)); + existing.pin(kind == VIRTUAL); + } + return existing; + } + if (registry.shutdown) { + throw new IllegalStateException("The server these tasks belong to has " + + "stopped; executor " + key + " cannot be created"); + } + String prefix = "cn1.task.executor." + key + "."; + int threads = SCHEDULING.equals(key) ? 2 : 8; + String configured = null; + if (registry.config != null) { + try { + threads = registry.config.getInt(prefix + "threads", threads); + configured = registry.config.get(prefix + "kind"); + } catch (java.io.IOException err) { + throw new IllegalStateException("Executor " + key + + " cannot be configured: " + err.getMessage(), err); + } + } + configured = checkedKind(prefix + "kind", configured); + boolean virtual; + if ("virtual".equalsIgnoreCase(configured)) { + virtual = true; + } else if ("platform".equalsIgnoreCase(configured)) { + virtual = false; + } else if (kind == AUTO) { + // Decided per task by the executor, not here: this may run during + // start-up, before the server's hosts exist. + TaskExecutor created = new TaskExecutor(key, false, true, threads, registry); + if (configured == null) { + registry.requestedKinds.put(key, Integer.valueOf(kind)); + } + registry.executors.put(key, created); + if (!registry.reportedAuto) { + registry.reportedAuto = true; + System.out.println("cn1: background tasks marked AUTO run on virtual " + + "threads where the server has them, platform threads otherwise"); + } + return created; + } else { + virtual = kind == VIRTUAL; + } + if (configured == null) { + registry.requestedKinds.put(key, Integer.valueOf(kind)); + } + TaskExecutor created = new TaskExecutor(key, virtual, threads, registry); + registry.executors.put(key, created); + return created; + } + } + + private static String kindName(int kind) { + return kind == VIRTUAL ? "VIRTUAL" : kind == AUTO ? "AUTO" : "PLATFORM"; + } + + /// A configured executor kind, trimmed; null when unset or empty. Refused, + /// not ignored, when it is neither platform nor virtual: the setting exists to + /// OVERRIDE the code -- typically to move database work off virtual hosts -- + /// and a typo falling back to the annotation's kind would leave it there. + static String checkedKind(String key, String value) { + String kind = value == null ? null : value.trim(); + if (kind == null || kind.length() == 0) { + return null; + } + if (!"virtual".equalsIgnoreCase(kind) && !"platform".equalsIgnoreCase(kind)) { + throw new IllegalStateException(key + " is \"" + kind + "\"; it must be " + + "platform or virtual"); + } + return kind; + } + + /// Runs `task` once on a virtual thread, or a platform one where there are none. + public static void virtual(Runnable task) { + executor(null, VIRTUAL).execute(task); + } + + /// Runs `task` once on the default pool of platform threads. + public static void platform(Runnable task) { + executor(null, PLATFORM).execute(task); + } + + /// The body of a background task's virtual thread: the native entry point + /// calls this with the token the task was queued under. Nothing in Java calls + /// it, which is why the native source names it -- that keeps it alive through + /// dead-code elimination. + static void runVirtual(long token) { + try { + HttpServer.runVirtualTask(token); + } catch (Throwable err) { + // The top of a virtual thread: nothing above this can catch it. + System.err.println("A virtual-thread task failed: " + err); + } + } + + /// The executors of every running server, for a listing. + public static List executors() { + List registries; + synchronized (Tasks.class) { + registries = new ArrayList(LIVE); + } + List out = new ArrayList(); + for (Object element : registries) { + Registry r = (Registry) element; + synchronized (r) { + out.addAll(r.executors.values()); + } + } + return out; + } + + /// The executors of the servers in `servers` only -- what a metric of + /// the measured servers reads. [#executors()] also answers for a server that + /// turned metrics off, whose queues would then appear in another server's + /// telemetry, the one built-in metric counting work from a server that opted + /// out. + public static List executorsOf(java.util.Collection servers) { + List registries; + synchronized (Tasks.class) { + registries = new ArrayList(LIVE); + } + List out = new ArrayList(); + for (Object element : registries) { + Registry r = (Registry) element; + synchronized (r) { + if (r.server != null && servers.contains(r.server)) { + out.addAll(r.executors.values()); + } + } + } + return out; + } + + /// Stops the executors of the calling thread's server, waiting up to + /// `waitMillis` in total for what is running. + public static void shutdown(long waitMillis) { + shutdown(current(), waitMillis); + } + + /// Waits until no task of `registry` runs any more, or `deadline` passes -- + /// after [#shutdown], which does not wait for the task that called it. + static void awaitIdle(Registry registry, long deadline) { + List all; + synchronized (registry) { + all = new ArrayList(registry.executors.values()); + } + for (Object element : all) { + ((TaskExecutor) element).awaitIdle(deadline); + } + } + + /// Stops `registry`'s executors, waiting up to `waitMillis` in + /// total, and retires it: nothing new is accepted, and threads that fell + /// back to it move on to another running server's. + static void shutdown(Registry registry, long waitMillis) { + List all; + synchronized (Tasks.class) { + LIVE.remove(registry); + } + synchronized (registry) { + registry.shutdown = true; + all = new ArrayList(registry.executors.values()); + } + long deadline = AsyncTask.deadline(System.currentTimeMillis(), Math.max(0, waitMillis)); + for (Object element : all) { + long left = deadline - System.currentTimeMillis(); + ((TaskExecutor) element).shutdown(left > 0 ? left : 0); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/Tracing.java b/vm/backend/src/com/codename1/backend/Tracing.java index 447540e9a0c..dcf7500a292 100644 --- a/vm/backend/src/com/codename1/backend/Tracing.java +++ b/vm/backend/src/com/codename1/backend/Tracing.java @@ -54,6 +54,48 @@ public final class Tracing { private static volatile Tracer tracer; //NOPMD AvoidUsingVolatile - read on every request without the lock private static final ThreadLocal CURRENT = new ThreadLocal(); private static final ThreadLocal SUPPRESSED = new ThreadLocal(); + /// The tracer of the server whose work this thread is doing when no span says + /// so: a websocket callback runs after its upgrade's span has ended, and with + /// two servers in one process the installed tracer is only the latest one's. + private static final ThreadLocal OWNER = new ThreadLocal(); + + /// The owner of a server that traces nothing. Null already means "whatever + /// tracer is installed", which is right for a bare HttpServer or a program's + /// own threads -- and wrong for a backend with tracing off: another backend in + /// the same process installing its tracer would then export this one's + /// requests, jobs and callbacks under the other's service name. A server with + /// tracing off passes this instead, and nothing it does is traced. + static final Tracer NONE = new Untraced(); + + private static final class Untraced implements Tracer { + @Override + public boolean open(Config config) { + return false; + } + + @Override + public Span startSpan(String name, int kind, Span parent, String traceparent, + String tracestate) { + return null; + } + + @Override + public void flush(int timeoutMillis) { + } + + @Override + public void shutdown(int timeoutMillis) { + } + + @Override + public HttpServer.Handler relay() { + return null; + } + + @Override + public void metrics(Map out) { + } + } private static volatile boolean reportedFailure; //NOPMD AvoidUsingVolatile - set once from any host thread private static final Span NOOP = new NoopSpan(); @@ -129,12 +171,32 @@ static Swap swap(Tracer installed) { /// can block for seconds. private static final Object LIFECYCLE = new Object(); + /// Tracers of servers that are running. A server's start used to retire + /// whatever tracer it displaced -- including a live server's, whose requests + /// then went out under the newcomer's name and endpoint while its own + /// exporter was stopped. An owned tracer is left alone and stopped by its + /// server. + private static final List OWNED = new ArrayList(); + /// A start-up completed: what its tracer displaced is stopped. static void commit(Swap claim) { + commit(claim, false); + } + + /// A start-up completed. With `owned` the installed tracer belongs to + /// the server that just started and lives until that server stops it with + /// [#shutdown]; a displaced tracer another running server owns is kept. + static void commit(Swap claim, boolean owned) { Tracer displaced; synchronized (LIFECYCLE) { PENDING.remove(claim); displaced = claim.previous; + if (owned && claim.installed != null && !OWNED.contains(claim.installed)) { + OWNED.add(claim.installed); + } + if (displaced != null && OWNED.contains(displaced)) { + displaced = null; + } } retire(displaced, claim.installed); } @@ -144,7 +206,7 @@ static void retire(Tracer previous, Tracer installed) { if (previous != null && previous != installed) { //NOPMD CompareObjectsWithEquals - tracer instances are compared by identity try { previous.shutdown(REPLACED_SHUTDOWN_MILLIS); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -193,7 +255,7 @@ static void rollBack(Swap claim) { // failed after its database initialisation holds exactly the spans // that explain the failure, and shutdown(0) dropped them unsent. stopFailed.shutdown(REPLACED_SHUTDOWN_MILLIS); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -240,22 +302,121 @@ public static Object inSpan(String name, Work work) throws Exception { } catch (Exception err) { guardedException(span, err); throw err; + } catch (Error err) { + // Recorded too: the span ends in the finally either way, and an + // AssertionError that failed the work must not export as a success. + guardedException(span, err); + throw err; + } finally { + finish(span); + } + } + + /// The current span when tracing is on, for handing to work that runs on + /// another thread; null otherwise. + static Span captureParent() { + return active() == null ? null : currentOrNull(); + } + + /// The tracer a span started now reports to: the one that made the current + /// span, so everything a request does is exported by the tracer of the server + /// serving it -- with two servers in one process, the installed one is only + /// the latest -- and otherwise the installed one. + static Tracer active() { + Span current = currentOrNull(); + if (current != null && current.owner != null) { + return current.owner; + } + Object owner = OWNER.get(); + if (owner == NONE) { //NOPMD CompareObjectsWithEquals - the untraced marker, by identity + return null; + } + if (owner instanceof Tracer) { + return (Tracer) owner; + } + return tracer; + } + + /// The owner this thread's work was bound to, for work handed to another + /// thread with no span to carry it; null when unbound. + static Tracer captureOwner() { + Object owner = OWNER.get(); + return owner instanceof Tracer ? (Tracer) owner : null; + } + + /// Makes `own` the tracer this thread's spans report to while no span of + /// its own is current; answers what to hand [#disown] afterwards. A null + /// `own` leaves the installed tracer in charge. + static Object own(Tracer own) { + Object previous = OWNER.get(); + OWNER.set(own); + return previous; + } + + /// Restores what [#own] replaced. + static void disown(Object previous) { + OWNER.set(previous); + } + + /// Runs `work` on this thread as a span whose parent is `parent`, + /// captured on the thread that scheduled it -- an `@Async` method's + /// caller -- or as a root span when that is null, as a scheduled job's run is. + static Object inBackground(String name, Span parent, Work work) throws Exception { + return inBackground(name, parent, null, work); + } + + /// [#inBackground(String,Span,Work)] reporting to `own` when + /// there is no parent to take the tracer from -- a scheduled run, whose + /// scheduler knows which server it belongs to. + static Object inBackground(String name, Span parent, Tracer own, Work work) + throws Exception { + // The parent's tracer: the run belongs to whoever started it. + Tracer t = parent != null && parent.owner != null ? parent.owner + : own != null ? own : tracer; + if (t == null || t == NONE || isSuppressed()) { //NOPMD CompareObjectsWithEquals - the untraced marker + return work.run(NOOP); + } + Span span = null; + try { + span = t.startSpan(name, Span.KIND_INTERNAL, parent, null, null); + if (span != null) { + span.owner = t; + enter(span); + } + } catch (Throwable err) { + failed(err); + span = null; + } + if (span == null) { + return work.run(NOOP); + } + try { + return work.run(span); + } catch (Exception err) { + guardedException(span, err); + throw err; + } catch (Error err) { + // Recorded too: the span ends in the finally either way, and an + // AssertionError that failed the work must not export as a success. + guardedException(span, err); + throw err; } finally { finish(span); } } - /// A new child of the current span that is NOT made current, for work whose - /// start and end are in different places. The caller must end it. public static Span startSpan(String name) { - Tracer t = tracer; + Tracer t = active(); if (t == null || isSuppressed()) { return NOOP; } try { Span span = t.startSpan(name, Span.KIND_INTERNAL, currentOrNull(), null, null); + if (span != null) { + span.owner = t; + } return span == null ? NOOP : span; - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); return NOOP; } @@ -289,9 +450,12 @@ public static boolean isSuppressed() { /// generated routers, which are the only code that knows the TEMPLATE -- the /// path alone would make every pet id its own operation. public static void route(String template) { + // The request histogram's label, when metrics are on: one static read + // when they are not. + com.codename1.backend.metrics.Metrics.route(template); // Every generated router calls this on every matched request, traced or // not; with no tracer that is this one read and nothing else. - if (tracer == null) { + if (tracer == null && currentOrNull() == null) { return; } Span span = currentOrNull(); @@ -314,7 +478,7 @@ public static void route(String template) { if (name != null && name.indexOf(' ') < 0) { span.updateName(name + " " + template); } - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -327,8 +491,16 @@ public static void route(String template) { /// The span for one request, made current. Everything is read from the /// request NOW, because a Request is valid only while its handler runs. static Span startServer(HttpServer.Request request, boolean secure) { - Tracer t = tracer; - if (t == null || request == null) { + return startServer(request, secure, null); + } + + /// [#startServer(HttpServer.Request,boolean)] with the tracer of the + /// server the request reached, when it has one of its own: a second server in + /// the process installs its tracer over the first's, and the first's requests + /// must still go to the first's endpoint under its service name. + static Span startServer(HttpServer.Request request, boolean secure, Tracer own) { + Tracer t = own != null ? own : tracer; + if (t == null || t == NONE || request == null) { //NOPMD CompareObjectsWithEquals - the untraced marker return null; } Span span = null; @@ -339,6 +511,7 @@ static Span startServer(HttpServer.Request request, boolean secure) { if (span == null) { return null; } + span.owner = t; if (span.isRecording()) { span.setAttribute("http.request.method", method); String target = request.getTarget(); @@ -365,7 +538,7 @@ static Span startServer(HttpServer.Request request, boolean secure) { } enter(span); return span; - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); abandon(span); return null; @@ -395,7 +568,7 @@ static void endServer(Span span, int status, Throwable error) { span.setError("the response could not be written"); } } - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } finish(span); @@ -414,13 +587,21 @@ static void endServer(Span span, int status, Throwable error) { /// ends after that write; shutting the tracer down first made the exporter /// refuse that span, so every shutdown requested over HTTP lost its own trace. static void shutdownAfterServing(Tracer owned, int timeoutMillis) { + if (!shutdownWhenServingEnds(owned, timeoutMillis)) { + shutdown(owned, timeoutMillis); + } + } + + /// Arranges for `owned` to stop once the request this thread is serving + /// has ended its span; false, arranging nothing, when it serves none. + static boolean shutdownWhenServingEnds(Tracer owned, int timeoutMillis) { Span serving = servingSpan(); - if (serving != null) { - serving.shutdownOnEnd = owned; - serving.shutdownOnEndMillis = timeoutMillis; - return; + if (serving == null) { + return false; } - shutdown(owned, timeoutMillis); + serving.shutdownOnEnd = owned; + serving.shutdownOnEndMillis = timeoutMillis; + return true; } /// The server span of the request this thread is serving, or null. @@ -432,7 +613,7 @@ private static Span servingSpan() { if (span.getKind() == Span.KIND_SERVER) { return span; } - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); return null; } @@ -445,7 +626,7 @@ private static Span servingSpan() { /// is off, suppressed on this thread, or already inside a client span -- one /// outbound operation is one span, whatever it happens to be built from. static Span startHttpClient(String method, String url, List callerHeaders) { - Tracer t = tracer; + Tracer t = active(); if (t == null || isSuppressed()) { return null; } @@ -473,7 +654,7 @@ static Span startHttpClient(String method, String url, List callerHeaders) { } } } - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } return span; @@ -501,7 +682,7 @@ static List propagationHeaders(Span span, List callerHeaders) { out.add(TRACESTATE + ": " + state); } return out; - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); return null; } @@ -527,7 +708,7 @@ static void endHttpClient(Span span, int status, Throwable error) { span.setError(String.valueOf(status)); } } - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } finish(span); @@ -540,7 +721,7 @@ static void endHttpClient(Span span, int status, Throwable error) { /// inlines literals into its SQL can drop the text with /// `cn1.otel.attributes.exclude=db.query.text`. static Span startDatabase(String system, String sql) { - Tracer t = tracer; + Tracer t = active(); if (t == null || isSuppressed()) { return null; } @@ -560,7 +741,7 @@ static Span startDatabase(String system, String sql) { span.setAttribute("db.query.text", sql); } } - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } return span; @@ -575,7 +756,7 @@ static void setAttribute(Span span, String key, long value) { if (span.isRecording()) { span.setAttribute(key, value); } - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -644,7 +825,7 @@ static Span startLambda(String traceHeader, String requestId) { } enter(span); return span; - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); abandon(span); return null; @@ -681,7 +862,7 @@ static void flush(int timeoutMillis) { } try { t.flush(timeoutMillis); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -704,8 +885,15 @@ static void shutdown(Tracer owned, int timeoutMillis) { } boolean stop = false; synchronized (LIFECYCLE) { + boolean wasOwned = OWNED.remove(owned); if (tracer == owned) { //NOPMD CompareObjectsWithEquals - tracer instances are compared by identity - tracer = null; + // Another running server's tracer takes the slot, so the work a + // thread does outside any request is still traced somewhere. + tracer = OWNED.isEmpty() ? null : (Tracer) OWNED.get(OWNED.size() - 1); + stop = true; + } else if (wasOwned) { + // Displaced by a later server, left running for its own; stopped + // now that its server is. stop = true; } for (Object pending : PENDING) { @@ -721,7 +909,7 @@ static void shutdown(Tracer owned, int timeoutMillis) { } try { owned.shutdown(timeoutMillis); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -734,7 +922,7 @@ static void metrics(Map out) { } try { t.metrics(out); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -751,7 +939,7 @@ private static Span currentOrNull() { /// query of its own to learn its key is one statement to the caller, and an /// outbound call cannot have another outbound call inside it. private static Span begin(String name, int kind, String traceparent, String tracestate) { - Tracer t = tracer; + Tracer t = active(); if (t == null || isSuppressed()) { return null; } @@ -764,12 +952,15 @@ private static Span begin(String name, int kind, String traceparent, String trac return null; } Span span = t.startSpan(name, kind, parent, traceparent, tracestate); + if (span != null) { + span.owner = t; + } if (span == null) { return null; } enter(span); return span; - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); return null; } @@ -802,7 +993,7 @@ private static void finish(Span span) { } try { span.end(); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -818,13 +1009,13 @@ private static void abandon(Span span) { } try { span.discard(); - } catch (RuntimeException err) { + } catch (Throwable err) { // The tracer is already failing; ending the span below still matters. failed(err); } try { span.end(); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -832,13 +1023,20 @@ private static void abandon(Span span) { private static void guardedException(Span span, Throwable error) { try { span.recordException(error); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } /// Once per process: a broken tracer must not also flood the log. - private static void failed(RuntimeException err) { + /// Every hook into the tracer catches Throwable, not just RuntimeException: + /// an AssertionError or a LinkageError out of a tracer is as much a + /// monitoring fault as an exception, and letting it escape would fail the + /// request -- and every later one, since the tracer stays installed. That + /// includes a VirtualMachineError raised inside the tracer's own code; if + /// the process really is out of memory, the request's next allocation says + /// so on its own. + private static void failed(Throwable err) { if (!reportedFailure) { reportedFailure = true; System.err.println("tracing failed and the operation continued untraced: " + err); diff --git a/vm/backend/src/com/codename1/backend/TransactionException.java b/vm/backend/src/com/codename1/backend/TransactionException.java new file mode 100644 index 00000000000..961e2512921 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/TransactionException.java @@ -0,0 +1,64 @@ +/* + * 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. + */ +package com.codename1.backend; + +/// A transaction could not begin, commit or roll back the way a +/// `@Transactional` method asked. +/// +/// Unchecked, as Spring's is: a woven method keeps the signature its author +/// wrote, and that signature cannot be made to declare a failure the author never +/// saw. The subclasses name the cases a caller might want to tell apart. +public class TransactionException extends RuntimeException { + public TransactionException(String message) { + super(message); + } + + public TransactionException(String message, Throwable cause) { + super(message, cause); + } + + /// A transaction was rolled back although its own method returned normally, + /// because a method that joined it failed and marked it rollback-only. + /// + /// Thrown rather than returning quietly, because the outer method's caller + /// would otherwise believe its work was committed. + public static class UnexpectedRollback extends TransactionException { + public UnexpectedRollback(String message) { + super(message); + } + } + + /// A MANDATORY method was called with no transaction, or a NEVER one inside one. + public static class IllegalState extends TransactionException { + public IllegalState(String message) { + super(message); + } + } + + /// The transaction ran past its `timeout` and was rolled back. + public static class TimedOut extends TransactionException { + public TimedOut(String message) { + super(message); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/Transactions.java b/vm/backend/src/com/codename1/backend/Transactions.java new file mode 100644 index 00000000000..7fdfd9316c4 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Transactions.java @@ -0,0 +1,655 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.io.IOException; + +import com.codename1.backend.orm.EntityManager; + +/// The transaction a `@Transactional` method runs in, bound to the calling +/// thread. +/// +/// The build rewrites every `@Transactional` method into a call to +/// [#begin], the original body, then [#commit] -- or +/// [#afterThrow] with the rollback decision it worked out from the +/// annotation. The propagation, the read-only flag and the timeout arrive as +/// constants, so this class interprets nothing: it holds the one piece of state +/// that has to exist at run time, which is which connection the thread's +/// transaction is on. +/// +/// ## Joining +/// +/// Nothing has to be handed the transaction. [DataSource#borrow] asks this +/// class first, and a thread inside a transaction gets the transaction's +/// connection back instead of a pooled one -- so the pool's own methods, an +/// [EntityManager]'s daos and a managed session all join it. The connection +/// is borrowed, and BEGIN sent, only when the transaction first needs it: a +/// `@Transactional` method that returns before touching the database costs +/// a thread-local write and nothing else. +/// +/// A thread that has never begun a transaction does not even read the +/// thread-local: [#used] is set by the first [#begin] of the process, +/// on the thread that is then inside it, so every other thread reading a stale +/// `false` is correct -- none of them is in a transaction. +/// +/// ## Propagation +/// +/// Spring's seven, one for one; the constants below are the ordinals of +/// `com.codename1.backend.annotations.Propagation`. A method that joins a +/// transaction and fails with a rollback-worthy exception marks the whole +/// transaction rollback-only, and the method that began it then rolls back and +/// throws [TransactionException.UnexpectedRollback] rather than reporting +/// a commit that did not happen. +public final class Transactions { + public static final int REQUIRED = 0; + public static final int SUPPORTS = 1; + public static final int MANDATORY = 2; + public static final int REQUIRES_NEW = 3; + public static final int NOT_SUPPORTED = 4; + public static final int NEVER = 5; + public static final int NESTED = 6; + + /// The physical transaction the thread is in, or null. + private static final ThreadLocal CURRENT = new ThreadLocal(); + + /// Whether any thread has ever begun one. Deliberately not volatile; see the + /// class comment for why a stale read is a correct one. + static boolean used; + + private static final int KIND_NONE = 0; + private static final int KIND_JOINED = 1; + private static final int KIND_NEW = 2; + private static final int KIND_SAVEPOINT = 3; + + private Transactions() { + } + + /// One database transaction, which any number of joined methods share. + static final class Physical { + DataSource pool; + Database db; + final boolean readOnly; + final long deadline; + /// Set by a participant that failed: the outer commit must refuse loudly. + boolean rollbackOnly; + /// Set by setRollbackOnly() in the method that began the transaction: it + /// chose to undo its own work, so the rollback is silent, as in Spring. + boolean localRollback; + /// The joined and nested calls running inside the one that began it, + /// innermost last -- which is how setRollbackOnly() knows whose work it + /// undoes. + final java.util.ArrayList calls = new java.util.ArrayList(); + int savepoints; + /// Savepoints a NESTED method took before the transaction had touched a + /// database, in order, to be set right after its BEGIN. There is no pool + /// to ask for a connection yet -- which one the transaction runs on is + /// decided by the first statement -- and a savepoint at the very start of + /// a transaction marks the same state BEGIN does, so setting it late + /// changes nothing. + java.util.List pendingSavepoints; + com.codename1.orm.session.Session session; + + Physical(DataSource pool, boolean readOnly, int timeoutSeconds) { + this.pool = pool; + this.readOnly = readOnly; + this.deadline = timeoutSeconds > 0 + ? System.currentTimeMillis() + timeoutSeconds * 1000L : 0; + } + } + + /// What one call to [#begin] did, which the matching [#commit] or + /// [#afterThrow] undoes. Opaque to the woven code, which only passes it + /// back. + public static final class Transaction { + final int kind; + final Physical physical; + /// The transaction to restore when this one ends: REQUIRES_NEW, NOT_SUPPORTED. + final Physical suspended; + final boolean suspends; + final String savepoint; + boolean completed; + /// A NESTED call asked, by setRollbackOnly(), to undo its own work. + boolean rollbackRequested; + + Transaction(int kind, Physical physical, Physical suspended, boolean suspends, + String savepoint) { + this.kind = kind; + this.physical = physical; + this.suspended = suspended; + this.suspends = suspends; + this.savepoint = savepoint; + } + + /// Whether this call began the physical transaction it runs in. + public boolean isNewTransaction() { + return kind == KIND_NEW; + } + } + + /// Whether the calling thread is inside a transaction. + public static boolean isActive() { + return used && CURRENT.get() != null; + } + + /// Whether the calling thread's transaction has been marked rollback-only. + public static boolean isRollbackOnly() { + Physical p = used ? (Physical) CURRENT.get() : null; + if (p == null) { + return false; + } + Transaction top = innermost(p); + return p.rollbackOnly || p.localRollback || (top != null && top.rollbackRequested); + } + + private static Transaction innermost(Physical p) { + return p.calls.isEmpty() ? null : (Transaction) p.calls.get(p.calls.size() - 1); + } + + /// Marks the calling thread's transaction so it rolls back however its method + /// ends -- the programmatic form of throwing, for a method that wants to + /// return a value and still undo its work. + public static void setRollbackOnly() { + Physical p = used ? (Physical) CURRENT.get() : null; + if (p == null) { + throw new TransactionException.IllegalState("setRollbackOnly() outside a " + + "transaction: there is nothing to roll back"); + } + Transaction top = innermost(p); + if (top == null) { + // The method that began the transaction: it returns normally and + // its work is undone, with nothing thrown. + p.localRollback = true; + } else if (top.kind == KIND_SAVEPOINT) { + // A NESTED method: its own work goes, back to its savepoint, and + // the transaction around it carries on -- which is what a savepoint + // is for. + top.rollbackRequested = true; + } else { + // A joined method: whoever began the transaction must learn that + // what it thinks it committed was not saved. + p.rollbackOnly = true; + } + } + + /// Starts what a `@Transactional` method asked for. Called by woven code. + /// + /// #### Parameters + /// + /// - `propagation`: one of the constants above + /// + /// - `readOnly`: the annotation's readOnly + /// + /// - `timeoutSeconds`: the annotation's timeout, zero or less for none + public static Transaction begin(int propagation, boolean readOnly, int timeoutSeconds) { + used = true; + Transaction tx = open(propagation, readOnly, timeoutSeconds); + if (tx.physical != null && (tx.kind == KIND_JOINED || tx.kind == KIND_SAVEPOINT)) { + tx.physical.calls.add(tx); + } + return tx; + } + + private static Transaction open(int propagation, boolean readOnly, int timeoutSeconds) { + Physical current = (Physical) CURRENT.get(); + switch (propagation) { + case SUPPORTS: + return current == null ? new Transaction(KIND_NONE, null, null, false, null) + : new Transaction(KIND_JOINED, current, null, false, null); + case MANDATORY: + if (current == null) { + throw new TransactionException.IllegalState("A MANDATORY transactional " + + "method was called with no transaction open. Call it from a " + + "@Transactional method, or change its propagation."); + } + return new Transaction(KIND_JOINED, current, null, false, null); + case NEVER: + if (current != null) { + throw new TransactionException.IllegalState("A NEVER transactional " + + "method was called inside a transaction."); + } + return new Transaction(KIND_NONE, null, null, false, null); + case NOT_SUPPORTED: + CURRENT.set(null); + return new Transaction(KIND_NONE, null, current, true, null); + case REQUIRES_NEW: + return fresh(readOnly, timeoutSeconds, current, true); + case NESTED: + if (current == null) { + return fresh(readOnly, timeoutSeconds, null, false); + } + return nested(current); + default: + if (current != null) { + return new Transaction(KIND_JOINED, current, null, false, null); + } + return fresh(readOnly, timeoutSeconds, null, false); + } + } + + private static Transaction fresh(boolean readOnly, int timeoutSeconds, Physical suspended, + boolean suspends) { + Physical p = new Physical(null, readOnly, timeoutSeconds); + CURRENT.set(p); + return new Transaction(KIND_NEW, p, suspended, suspends, null); + } + + private static Transaction nested(Physical current) { + String name = "cn1_sp_" + (++current.savepoints); + Database db = current.db; + if (db == null) { + // Nothing has run in the transaction yet, so there is no connection + // and -- deliberately -- no process-wide default pool to borrow one + // from: two servers in one process would hand this savepoint to + // whichever started last. It is set when the first statement picks + // the database. + if (current.pendingSavepoints == null) { + current.pendingSavepoints = new java.util.ArrayList(); + } + current.pendingSavepoints.add(name); + return new Transaction(KIND_SAVEPOINT, current, null, false, name); + } + try { + flushSession(current); + db.savepoint(name); + } catch (IOException err) { + // The outer transaction cannot commit after this, even if its method + // catches the exception: the failed flush may have left some of its + // statements applied (MySQL) or the transaction aborted (PostgreSQL), + // and a commit would persist half of it or report success for work + // the server threw away. + current.rollbackOnly = true; + throw new TransactionException("Could not set savepoint " + name + ": " + + err.getMessage(), err); + } + return new Transaction(KIND_SAVEPOINT, current, null, false, name); + } + + /// Ends a call that returned normally. Commits when the call began the + /// transaction, and does nothing more than restore state when it joined one. + public static void commit(Transaction tx) { + if (tx == null || tx.completed) { + return; + } + tx.completed = true; + leave(tx); + switch (tx.kind) { + case KIND_SAVEPOINT: + if (dropPending(tx)) { + // Never set: the nested method did not touch the database. + return; + } + if (tx.rollbackRequested) { + rollBackToSavepoint(tx); + return; + } + try { + flushSession(tx.physical); + tx.physical.db.releaseSavepoint(tx.savepoint); + } catch (IOException err) { + // The outer transaction is not over and may still commit, so + // it has to know this part could not be kept. + tx.physical.rollbackOnly = true; + throw new TransactionException("Could not release savepoint " + + tx.savepoint + ": " + err.getMessage(), err); + } + return; + case KIND_NEW: + try { + finishNew(tx.physical); + } finally { + restore(tx); + } + return; + default: + restore(tx); + } + } + + /// Ends a call that threw. Rolls back -- the call's own transaction, its + /// savepoint, or, when it joined one, by marking the shared transaction + /// rollback-only -- when `rollback` is true, and otherwise treats the + /// exception as a normal end, which is Spring's rule for a checked one. + /// + /// Never throws: the woven code rethrows the method's own exception after + /// this returns, and a failure here would replace the cause with a symptom. + /// A rollback that fails is reported on standard error and its connection + /// closed, so the pool cannot hand out a session that is still inside a + /// transaction. + public static void afterThrow(Transaction tx, boolean rollback) { + if (tx == null || tx.completed) { + return; + } + if (!rollback) { + try { + commit(tx); + } catch (RuntimeException err) { + System.err.println("A transaction ended by a checked exception could not " + + "commit: " + err); + } + return; + } + tx.completed = true; + leave(tx); + switch (tx.kind) { + case KIND_JOINED: + tx.physical.rollbackOnly = true; + return; + case KIND_SAVEPOINT: + if (dropPending(tx)) { + // Never set, so nothing ran after it: nothing to undo. + return; + } + rollBackToSavepoint(tx); + return; + case KIND_NEW: + try { + rollbackPhysical(tx.physical); + } finally { + restore(tx); + } + return; + default: + restore(tx); + } + } + + /// Undoes a NESTED call's work, back to its savepoint; never throws. + private static void rollBackToSavepoint(Transaction tx) { + try { + tx.physical.db.rollbackToSavepoint(tx.savepoint); + tx.physical.db.releaseSavepoint(tx.savepoint); + if (tx.physical.session != null) { + // The rows it wrote since the savepoint are gone, so the + // instances it still manages would be lying about them. + tx.physical.session.clear(); + } + } catch (Exception err) { + tx.physical.rollbackOnly = true; + System.err.println("Could not roll back to savepoint " + tx.savepoint + + "; the transaction will roll back instead: " + err); + } + } + + /// A joined or nested call has ended. + private static void leave(Transaction tx) { + if (tx.physical != null && (tx.kind == KIND_JOINED || tx.kind == KIND_SAVEPOINT)) { + tx.physical.calls.remove(tx); + } + } + + /// Forgets a savepoint that was never set; true when it was one. + private static boolean dropPending(Transaction tx) { + java.util.List pending = tx.physical.pendingSavepoints; + return pending != null && pending.remove(tx.savepoint); + } + + /// Puts back what this call suspended, or clears what it began. + private static void restore(Transaction tx) { + if (tx.kind == KIND_NEW || tx.suspends) { + CURRENT.set(tx.suspended); + } + } + + private static void finishNew(Physical p) { + if (p.rollbackOnly) { + rollbackPhysical(p); + throw new TransactionException.UnexpectedRollback("The transaction was rolled " + + "back because a method that joined it failed. Its own method returned " + + "normally, so this is what tells its caller the work was not saved."); + } + if (p.localRollback) { + rollbackPhysical(p); + return; + } + if (p.deadline != 0 && System.currentTimeMillis() > p.deadline) { + rollbackPhysical(p); + throw new TransactionException.TimedOut("The transaction ran past its timeout " + + "and was rolled back."); + } + if (p.db == null) { + // Never touched the database: nothing began, so nothing commits. + closeSession(p); + return; + } + try { + if (p.session != null) { + // Flushes the session's pending changes INTO the transaction + // before it commits; the adapter it runs on joined this + // transaction, so the session's own commit sends no COMMIT. + p.session.commitTransaction(); + } + p.db.commitTransaction(); + } catch (Exception err) { + rollbackPhysical(p); + throw new TransactionException("Commit failed; the transaction was rolled " + + "back: " + err.getMessage(), err); + } + closeSession(p); + release(p, false); + } + + private static void rollbackPhysical(Physical p) { + boolean broken = false; + if (p.session != null) { + try { + if (p.session.isTransactionActive()) { + p.session.rollbackTransaction(); + } + } catch (Exception err) { + System.err.println("Could not roll back the transaction's session: " + err); + } + } + closeSession(p); + if (p.db != null) { + try { + if (p.db.isInTransaction()) { + p.db.rollbackTransaction(); + } + } catch (Exception err) { + broken = true; + System.err.println("Rollback failed; closing the connection so the pool " + + "cannot reuse it: " + err); + } + release(p, broken); + } + } + + private static void closeSession(Physical p) { + if (p.session == null) { + return; + } + com.codename1.orm.session.Session session = p.session; + p.session = null; + try { + session.close(); + } catch (Exception err) { + System.err.println("Could not close the transaction's session: " + err); + } + } + + private static void release(Physical p, boolean broken) { + Database db = p.db; + DataSource pool = p.pool; + p.db = null; + if (db == null || pool == null) { + return; + } + if (broken) { + db.close(); + } + pool.releaseToPool(db); + } + + private static void flushSession(Physical p) throws IOException { + if (p.session != null && p.session.isTransactionActive()) { + try { + p.session.flush(); + } catch (RuntimeException err) { + throw new IOException("Flushing the session failed: " + err.getMessage(), err); + } + } + } + + /// The transaction's connection for `pool`, borrowing it and sending + /// BEGIN the first time; null when the thread is not in a transaction or its + /// transaction is on another database. Called by [DataSource#borrow]. + public static Database joined(DataSource pool) throws IOException { + if (!used) { + return null; + } + Physical p = (Physical) CURRENT.get(); + if (p == null) { + return null; + } + return materialize(p, pool); + } + + private static Database materialize(Physical p, DataSource pool) throws IOException { + if (p.pool == null) { + if (pool == null) { + return null; + } + p.pool = pool; + } else if (pool != null && p.pool != pool) { //NOPMD CompareObjectsWithEquals - pools and connections are compared by identity + // Refused rather than answered with "no transaction": the caller would + // then take an ordinary connection that commits each statement on its + // own, and a method that failed afterwards would roll back only the + // first database -- half of its work kept. + throw new TransactionException.IllegalState("This thread's transaction is on " + + "another database, and one transaction cannot span two. Do the work " + + "on this one in a @Transactional(propagation = REQUIRES_NEW) method for " + + "a transaction of its own, or NOT_SUPPORTED to run it outside any."); + } + if (p.db != null) { + return p.db; + } + if (p.deadline != 0 && System.currentTimeMillis() > p.deadline) { + throw new IOException("The transaction ran past its timeout before it used the " + + "database"); + } + Database db = p.pool.borrowFromPool(); + try { + if ("sqlite".equals(db.dialect().getName())) { + // The ORM relies on it and sets it outside a transaction, which is + // the only place SQLite honours it: inside one it is ignored. + db.execute("PRAGMA foreign_keys = ON", null); + } + db.beginTransaction(p.readOnly); + if (p.pendingSavepoints != null) { + for (int iter = 0 ; iter < p.pendingSavepoints.size() ; iter++) { + db.savepoint((String) p.pendingSavepoints.get(iter)); + } + p.pendingSavepoints = null; + } + } catch (IOException err) { + try { + if (db.isInTransaction()) { + db.rollbackTransaction(); + } + } catch (IOException ignored) { + db.close(); + } + p.pool.releaseToPool(db); + throw err; + } + p.db = db; + return db; + } + + /// Whether `db` is the connection of the calling thread's transaction on `pool`. + public static boolean isJoined(DataSource pool, Database db) { + if (!used) { + return false; + } + Physical p = (Physical) CURRENT.get(); + return p != null && p.db == db && p.pool == pool && db != null; //NOPMD CompareObjectsWithEquals - pools and connections are compared by identity + } + + /// Whether the calling thread's transaction is on `pool`, or could be. + public static boolean isActiveOn(DataSource pool) { + if (!used) { + return false; + } + Physical p = (Physical) CURRENT.get(); + return p != null && (p.pool == null || p.pool == pool); //NOPMD CompareObjectsWithEquals - pools and connections are compared by identity + } + + /// The managed session of the calling thread's transaction, opened on the + /// transaction's connection the first time it is asked for and flushed and + /// closed when the transaction ends. + /// + /// This is what an injected `Session` delegates to. Outside a + /// transaction there is no session to give, and the refusal says where one + /// comes from. + public static com.codename1.orm.session.Session session(EntityManager entities) { + Physical p = used ? (Physical) CURRENT.get() : null; + if (p == null) { + throw new TransactionException.IllegalState("The injected Session belongs to a " + + "transaction, and none is open. Annotate the calling method " + + "@Transactional -- @Transactional(readOnly = true) for one that only " + + "reads -- or open a session of your own with " + + "EntityManager.openSession()."); + } + if (p.session != null) { + return p.session; + } + if (entities.dataSource() == null) { + throw new TransactionException.IllegalState("A transaction's session needs an " + + "entity manager over a pool; this one is pinned to one connection."); + } + try { + if (materialize(p, entities.dataSource()) == null) { + throw new TransactionException.IllegalState("The transaction is on another " + + "database than this entity manager's."); + } + } catch (IOException err) { + throw new TransactionException("Could not begin the transaction: " + + err.getMessage(), err); + } + com.codename1.orm.session.Session session = entities.openSession(); + // Joins: the adapter under it sees the thread's transaction on its pool + // and pins that connection instead of sending a BEGIN of its own. + session.beginTransaction(); + p.session = session; + return session; + } + + /// Called by the session adapter when a session that joined this thread's + /// transaction rolls back: the rows are not the session's to keep, so the + /// transaction they are in cannot commit either. + /// Marks the calling thread's transaction, whatever pool it is on, as + /// unable to commit; nothing when there is none. + public static void markRollbackOnly() { + Physical p = used ? (Physical) CURRENT.get() : null; + if (p != null) { + p.rollbackOnly = true; + } + } + + public static void markRollbackOnly(DataSource pool) { + Physical p = used ? (Physical) CURRENT.get() : null; + if (p != null && (p.pool == null || p.pool == pool)) { //NOPMD CompareObjectsWithEquals - pools and connections are compared by identity + p.rollbackOnly = true; + } + } +} diff --git a/vm/backend/src/com/codename1/backend/Wiring.java b/vm/backend/src/com/codename1/backend/Wiring.java new file mode 100644 index 00000000000..f59b9d16961 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Wiring.java @@ -0,0 +1,290 @@ +/* + * 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. + */ +package com.codename1.backend; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// What the build-generated wiring calls: configuration placeholders, the +/// conversions a `@Value` needs, and the conditions of conditional beans. +/// +/// Everything here runs once, at start-up, from straight-line code the build +/// wrote. None of it looks up a bean: the build already decided which bean goes +/// where. +public final class Wiring { + private Wiring() { + } + + /// Where a request- or session-scoped bean's current instance comes from, for + /// the generated class that stands in for it in a singleton. `slot` is + /// the number the build gave the bean. + public interface Scope { + Object get(int slot); + } + + /// Resolves every `${key`} and `${key:fallback`} in + /// `expression` against the configuration, keeping the text around + /// them. + /// + /// #### Parameters + /// + /// - `where`: the injection point, for the refusal when a key has no value + public static String value(Config config, String expression, String where) { + if (expression == null || expression.indexOf("${") < 0) { + return expression; + } + StringBuilder out = new StringBuilder(); + int at = 0; + while (at < expression.length()) { + int open = expression.indexOf("${", at); + if (open < 0) { + out.append(expression.substring(at)); + break; + } + int close = expression.indexOf('}', open + 2); + if (close < 0) { + throw new IllegalStateException(where + ": unterminated ${ in \"" + expression + + "\""); + } + out.append(expression.substring(at, open)); + String inner = expression.substring(open + 2, close); + int colon = inner.indexOf(':'); + String key = colon < 0 ? inner : inner.substring(0, colon); + String resolved; + try { + resolved = config.get(key.trim()); + } catch (IOException err) { + throw new IllegalStateException(where + ": " + err.getMessage(), err); + } + if (resolved == null) { + if (colon < 0) { + throw new IllegalStateException(where + " needs the setting \"" + key.trim() + + "\", and nothing sets it: add it to application.properties, " + + "set it in the environment, or give the expression a " + + "fallback with ${" + key.trim() + ":...}"); + } + resolved = inner.substring(colon + 1); + } + out.append(resolved); + at = close + 1; + } + return out.toString(); + } + + /// The first of the keys that is set, or null. For configuration-property binding. + public static String property(Config config, String key, String relaxed) { + try { + String value = config.get(key); + if (value == null && relaxed != null) { + value = config.get(relaxed); + } + return value; + } catch (IOException err) { + throw new IllegalStateException(key + ": " + err.getMessage(), err); + } + } + + /// Whether any of the profiles, each optionally negated with `!`, is active. + public static boolean profiles(Config config, String[] profiles) { + String active = config.getProfile(); + for (String element : profiles) { + String p = element.trim(); + if (p.startsWith("!")) { + if (!p.substring(1).trim().equalsIgnoreCase(active)) { + return true; + } + } else if (p.equalsIgnoreCase(active)) { + return true; + } + } + return false; + } + + /// `@ConditionalOnProperty`: whether the key's value matches. + public static boolean propertyMatches(Config config, String key, String havingValue, + boolean matchIfMissing) { + String value; + try { + value = config.get(key); + } catch (IOException err) { + throw new IllegalStateException(key + ": " + err.getMessage(), err); + } + if (value == null) { + return matchIfMissing; + } + if (havingValue == null || havingValue.length() == 0) { + return !"false".equalsIgnoreCase(value.trim()); + } + return havingValue.equalsIgnoreCase(value.trim()); + } + + /// What a `@Bean` method returned, refused when it is null: a factory that + /// produces nothing is a bug in the factory, and handing the null to what + /// injects it would only move the failure to its first use. + public static Object produced(Object bean, String factory) { + if (bean == null) { + throw new IllegalStateException(factory + " returned null; a @Bean method must " + + "return the bean, or be made conditional so that it is not built"); + } + return bean; + } + + /// The first of `candidates` -- a conditional @Primary -- when it exists, + /// otherwise the one of the rest that does, as [#single] picks it. + public static Object preferred(Object[] candidates, boolean required, String what) { + if (candidates.length > 0 && candidates[0] != null) { + return candidates[0]; + } + Object[] rest = new Object[Math.max(0, candidates.length - 1)]; + System.arraycopy(candidates, 1, rest, 0, rest.length); + return single(rest, required, what); + } + + /// The one bean of `candidates` that exists, when all of them are + /// conditional. More than one existing is ambiguous; none is a missing + /// dependency when `required`. + public static Object single(Object[] candidates, boolean required, String what) { + Object found = null; + for (Object element : candidates) { + if (element != null) { + if (found != null) { + throw new IllegalStateException(what + ": more than one conditional bean " + + "is active for it; mark one @Primary or use @Qualifier"); + } + found = element; + } + } + if (found == null && required) { + throw new IllegalStateException(what + ": no bean for it is active under this " + + "configuration -- every candidate is conditional"); + } + return found; + } + + /// The beans of `items` that exist, for a `List` injection point. + public static List list(Object[] items) { + List out = new ArrayList(items.length); + for (Object element : items) { + if (element != null) { + out.add(element); + } + } + return out; + } + + public static int toInt(String value, String where) { + try { + return Integer.parseInt(value.trim()); + } catch (RuntimeException err) { + throw bad(value, where, "a whole number", err); + } + } + + public static long toLong(String value, String where) { + try { + return Long.parseLong(value.trim()); + } catch (RuntimeException err) { + throw bad(value, where, "a whole number", err); + } + } + + public static short toShort(String value, String where) { + int v = toInt(value, where); + if (v < Short.MIN_VALUE || v > Short.MAX_VALUE) { + throw bad(value, where, "a short"); + } + return (short) v; + } + + public static byte toByte(String value, String where) { + int v = toInt(value, where); + if (v < Byte.MIN_VALUE || v > Byte.MAX_VALUE) { + throw bad(value, where, "a byte"); + } + return (byte) v; + } + + public static double toDouble(String value, String where) { + try { + return Double.parseDouble(value.trim()); + } catch (RuntimeException err) { + throw bad(value, where, "a number", err); + } + } + + public static float toFloat(String value, String where) { + return (float) toDouble(value, where); + } + + public static char toChar(String value, String where) { + if (value.length() != 1) { + throw bad(value, where, "one character"); + } + return value.charAt(0); + } + + public static boolean toBoolean(String value, String where) { + String v = value.trim(); + if ("true".equalsIgnoreCase(v) || "yes".equalsIgnoreCase(v) || "on".equalsIgnoreCase(v) + || "1".equals(v)) { + return true; + } + if ("false".equalsIgnoreCase(v) || "no".equalsIgnoreCase(v) + || "off".equalsIgnoreCase(v) || "0".equals(v)) { + return false; + } + throw bad(value, where, "true or false"); + } + + /// The constant of an enum by name; `values` is the enum's values(). + public static Object toEnum(Object[] values, String value, String where) { + String v = value.trim(); + for (Object element : values) { + if (((Enum) element).name().equals(v)) { + return element; + } + } + for (Object element : values) { + if (((Enum) element).name().equalsIgnoreCase(v)) { + return element; + } + } + throw bad(value, where, "one of the enum's constants"); + } + + private static IllegalStateException bad(String value, String where, String what) { + return bad(value, where, what, null); + } + + private static IllegalStateException bad(String value, String where, String what, + Throwable cause) { + return new IllegalStateException(where + " is \"" + value + "\", which is not " + what, + cause); + } + + /// Reports a destroy method that failed; the others still run. + public static void destroyFailed(String bean, Throwable error) { + System.err.println("Destroying bean " + bean + " failed: " + error); + } +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Async.java b/vm/backend/src/com/codename1/backend/annotations/Async.java new file mode 100644 index 00000000000..f7f429007b0 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Async.java @@ -0,0 +1,49 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Runs this method on another thread; the caller returns at once. +/// +/// The method returns `void`, or a `java.util.concurrent.Future` -- typically +/// `com.codename1.backend.AsyncResult.of(value)` -- which the caller receives +/// immediately and which completes when the work does. Anything else is a build +/// error, because a caller could not receive it. +/// +/// The build rewrites the compiled method so that it packages its arguments into +/// a task and hands the task to the named executor; there is no proxy, so this +/// works however the method is called. An exception from a `void` method is +/// logged and recorded on the current span, since nobody is waiting for it. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface Async { + /// The executor to run on, configured as `cn1.task.executor..*`. + /// Empty means `default`. + String value() default ""; + /// Which kind of thread. + ThreadKind thread() default ThreadKind.PLATFORM; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Autowired.java b/vm/backend/src/com/codename1/backend/annotations/Autowired.java new file mode 100644 index 00000000000..f98304e309d --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Autowired.java @@ -0,0 +1,46 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Asks the build to inject this constructor, field or setter. +/// +/// A class with exactly one public constructor does not need the annotation on +/// it -- that constructor is used -- which is Spring's rule too. A field or a +/// setter does: the build injects members only when asked. +/// +/// A private field is fine. The build adds a setter to the compiled class for the +/// generated entry point to call, because there is no reflection to reach it +/// with. A final field cannot be injected at all, and is refused. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.FIELD}) +public @interface Autowired { + /// Whether a bean must exist. When false and there is none, the field keeps + /// its initial value, the setter is not called, and a constructor parameter + /// receives null. + boolean required() default true; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Bean.java b/vm/backend/src/com/codename1/backend/annotations/Bean.java new file mode 100644 index 00000000000..c6cd2ba6f00 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Bean.java @@ -0,0 +1,46 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A method of a [Configuration] class whose return value is a bean. +/// +/// Its parameters are injected like a constructor's, and may carry [Qualifier] +/// or [Value]. A static `@Bean` method is called without constructing the +/// configuration class. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Bean { + /// The bean's name. Empty means the method's name, which is Spring's rule. + String value() default ""; + /// A no-argument method of the returned object the build calls once it is + /// constructed and injected. Empty for none. + String initMethod() default ""; + /// A no-argument method of the returned object the build calls when the + /// server stops. Empty for none. + String destroyMethod() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Component.java b/vm/backend/src/com/codename1/backend/annotations/Component.java new file mode 100644 index 00000000000..dbb4f4c04c0 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Component.java @@ -0,0 +1,53 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks a class the build constructs and injects, once per server. +/// +/// The build finds the class, works out what its constructor and its +/// `@Autowired` members need, and writes the `new` into the entry point it +/// generates. Nothing is looked up at run time: there is no container, no +/// registry and no reflection, and a dependency that cannot be satisfied is a +/// build error that names the injection point. +/// +/// ```java +/// @Component +/// public class Clock { +/// public long now() { return System.currentTimeMillis(); } +/// } +/// ``` +/// +/// [Service] and [Repository] mean the same thing and exist so a class can say +/// which layer it belongs to, as they do in Spring. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Component { + /// The bean's name, for [Qualifier]. Empty means the class's simple name with + /// its first letter lower-cased, which is Spring's rule. + String value() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/ConditionalOnMissingBean.java b/vm/backend/src/com/codename1/backend/annotations/ConditionalOnMissingBean.java new file mode 100644 index 00000000000..e59d644a7e1 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ConditionalOnMissingBean.java @@ -0,0 +1,45 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Constructs this bean only when no other bean has its type. +/// +/// Decided entirely by the build, which sees every bean: a default +/// implementation marked with this steps aside for one the application +/// declares. +/// +/// Without `value`, "its type" is every type the bean can be injected as +/// other than the JDK's own -- the class and its interfaces -- so a +/// `DefaultMailer implements Mailer` steps aside for any other `Mailer` bean. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface ConditionalOnMissingBean { + /// The types whose presence makes this bean step aside, when the default + /// set is too wide or too narrow. + Class[] value() default {}; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/ConditionalOnProperty.java b/vm/backend/src/com/codename1/backend/annotations/ConditionalOnProperty.java new file mode 100644 index 00000000000..5d3c500ea68 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ConditionalOnProperty.java @@ -0,0 +1,48 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Constructs this bean only when a configuration key has a value. +/// +/// With [#havingValue] empty, any value other than `false` counts. Evaluated +/// once at start-up, like [Profile], and checked by the build the same way. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface ConditionalOnProperty { + /// The key, or several keys that must all match. + String[] value() default {}; + /// Same as [#value]; Spring accepts either. + String[] name() default {}; + /// Prepended to each key, with a dot. + String prefix() default ""; + /// The value to require, compared ignoring case. Empty means anything but + /// `false`. + String havingValue() default ""; + /// Whether an unset key counts as a match. + boolean matchIfMissing() default false; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Configuration.java b/vm/backend/src/com/codename1/backend/annotations/Configuration.java new file mode 100644 index 00000000000..6766ccf646f --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Configuration.java @@ -0,0 +1,57 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A class whose [Bean] methods produce beans. +/// +/// The class itself is a bean too, so its own constructor and fields can be +/// injected before its factory methods are called. The build calls each factory +/// method exactly once, directly, from the entry point it generates -- so unlike +/// Spring there is no proxy, and one `@Bean` method calling another simply gets a +/// second object. Take the other bean as a PARAMETER instead, which is what the +/// build wires: +/// +/// ```java +/// @Configuration +/// public class Clients { +/// @Bean +/// public Mailer mailer(@Value("${mail.host:localhost}") String host) { +/// return new Mailer(host); +/// } +/// @Bean +/// public Notifier notifier(Mailer mailer) { +/// return new Notifier(mailer); +/// } +/// } +/// ``` +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Configuration { + /// The bean's name, for [Qualifier]. Empty means the default name. + String value() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/ConfigurationProperties.java b/vm/backend/src/com/codename1/backend/annotations/ConfigurationProperties.java new file mode 100644 index 00000000000..066e1f585b4 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ConfigurationProperties.java @@ -0,0 +1,55 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Binds a group of configuration keys to the setters of a bean. +/// +/// ```java +/// @Component +/// @ConfigurationProperties("mail") +/// public class MailSettings { +/// private String host = "localhost"; +/// private int port = 25; +/// public void setHost(String host) { this.host = host; } +/// public void setPort(int port) { this.port = port; } +/// } +/// ``` +/// +/// Reads `mail.host` and `mail.port`, and calls a setter only when its key is +/// set, so a field initializer is the default. The build lists the setters and +/// writes one typed read per setter into the entry point; nothing is discovered +/// at run time. Spring's relaxed binding applies to the setter name: `setMaxSize` +/// reads `mail.max-size` when `mail.maxSize` is not set. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface ConfigurationProperties { + /// The key prefix, without the trailing dot. + String value() default ""; + /// Same as [#value]; Spring accepts either. + String prefix() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Counted.java b/vm/backend/src/com/codename1/backend/annotations/Counted.java new file mode 100644 index 00000000000..1ed376b272e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Counted.java @@ -0,0 +1,39 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Counts the calls of this method, and separately the calls that threw. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Counted { + /// The metric's name. Empty means the class and method, such as + /// `com.example.Orders.place.calls`. + String value() default ""; + /// What it counts, for the listing. + String description() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/DataSourceConfig.java b/vm/backend/src/com/codename1/backend/annotations/DataSourceConfig.java new file mode 100644 index 00000000000..b98c3b1551f --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/DataSourceConfig.java @@ -0,0 +1,51 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Connection pool settings, compiled in. +/// +/// Each attribute is the `cn1.datasource.*` key named beside it, and sets that +/// key's value at the bottom of the configuration, so `DATABASE_URL`, a +/// properties file or the environment still override it. An attribute left at its +/// default sets nothing. Keep credentials out of source: a URL may name an +/// environment variable as `${NAME}`, resolved when the server starts. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface DataSourceConfig { + /// `cn1.datasource.url`: a SQLite path, or a `postgres://` or `mysql://` URL. + String url() default ""; + + /// `cn1.datasource.pool.size`. + int poolSize() default -1; + + /// `cn1.datasource.pool.borrowTimeoutMillis`. + int borrowTimeoutMillis() default -1; + + /// `cn1.datasource.busyTimeoutMillis`: how long SQLite waits on a locked file. + int busyTimeoutMillis() default -1; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/EnableAsync.java b/vm/backend/src/com/codename1/backend/annotations/EnableAsync.java new file mode 100644 index 00000000000..d6997d46a78 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/EnableAsync.java @@ -0,0 +1,34 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Accepted for familiarity and otherwise ignored: [Async] works without it. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface EnableAsync { +} diff --git a/vm/backend/src/com/codename1/backend/annotations/EnableManagement.java b/vm/backend/src/com/codename1/backend/annotations/EnableManagement.java new file mode 100644 index 00000000000..f8417d19efe --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/EnableManagement.java @@ -0,0 +1,55 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Builds the management endpoints into the server: health, metrics, the +/// Prometheus view, scheduled jobs and managed beans, under `/manage`. +/// +/// ```java +/// @EnableManagement +/// @Configuration +/// public class Settings { } +/// ``` +/// +/// THIS IS A BUILD-TIME SWITCH. Without it -- or `cn1.management.enabled=true` +/// in `application.properties` or a profile's file -- the entry point never names +/// the endpoints and the translator leaves their code out of the binary, so a +/// packaged server has no `/manage` to find, whatever its profile. The development +/// run always has them. +/// +/// With it, the endpoints are on unless `cn1.management.enabled=false` turns them +/// off at start-up, and outside a development profile they refuse to start +/// without `cn1.management.token`, which belongs in the environment rather than +/// in source. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface EnableManagement { + /// Where the endpoints are served; `cn1.management.path`. Empty means + /// `/manage`. + String path() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/EnableMcpServer.java b/vm/backend/src/com/codename1/backend/annotations/EnableMcpServer.java new file mode 100644 index 00000000000..62637aca6fb --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/EnableMcpServer.java @@ -0,0 +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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Builds the MCP endpoint into the server, serving the application's +/// [McpTool] methods over Streamable HTTP at `/mcp`. +/// +/// A class with an [McpTool] method already does this, so the annotation is +/// needed only to move the endpoint or to name the origins it accepts. Without +/// either -- and without `cn1.mcp.enabled=true` in a properties file -- the entry +/// point of a packaged server never names the endpoint and the translator leaves +/// its code out of the binary. The endpoint serves only when it has a tool, and +/// `cn1.mcp.enabled=false` turns it off at start-up. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface EnableMcpServer { + /// Where the endpoint is served; `cn1.mcp.path`. Empty means `/mcp`. + String path() default ""; + + /// The browser origins allowed to call it; `cn1.mcp.allowedOrigins`. + String[] allowedOrigins() default {}; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/EnableScheduling.java b/vm/backend/src/com/codename1/backend/annotations/EnableScheduling.java new file mode 100644 index 00000000000..139b0ba97c6 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/EnableScheduling.java @@ -0,0 +1,35 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Accepted for familiarity and otherwise ignored: [Scheduled] works without +/// it. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface EnableScheduling { +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Lazy.java b/vm/backend/src/com/codename1/backend/annotations/Lazy.java new file mode 100644 index 00000000000..b1b11ca2a8d --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Lazy.java @@ -0,0 +1,46 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Constructs this bean the first time something calls it, instead of at start-up. +/// +/// The build injects a generated subclass that constructs the real bean on the +/// first call and delegates to it. The class must therefore be possible to +/// subclass: not final, with a constructor taking no arguments that the subclass +/// can call. +/// +/// That constructor runs once at start-up, for the stand-in itself, because a +/// subclass cannot be built without it. So keep it cheap, and put the expensive +/// set-up in a `@PostConstruct` method: that runs only on the real instance, on +/// first use. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface Lazy { + /// Whether the bean is lazy; false undoes a class-level annotation. + boolean value() default true; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/ManagedAttribute.java b/vm/backend/src/com/codename1/backend/annotations/ManagedAttribute.java new file mode 100644 index 00000000000..3f294dff970 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ManagedAttribute.java @@ -0,0 +1,41 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A getter of a [ManagedResource] bean published as a gauge. +/// +/// The getter takes no arguments and returns a number -- a primitive or its box +/// -- or a boolean, published as 0 or 1. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface ManagedAttribute { + /// What the value means, for the listing. + String description() default ""; + /// The unit, in UCUM as OpenTelemetry expects -- `ms`, `By`, `{request}`. + String unit() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/ManagedOperation.java b/vm/backend/src/com/codename1/backend/annotations/ManagedOperation.java new file mode 100644 index 00000000000..c1c32544b15 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ManagedOperation.java @@ -0,0 +1,40 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A method of a [ManagedResource] bean that the management endpoint and the +/// development MCP server can invoke. +/// +/// Its parameters may be strings, numbers or booleans, and its result anything +/// [com.codename1.backend.Json] can write. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface ManagedOperation { + /// What the operation does, for the listing. + String description() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/ManagedResource.java b/vm/backend/src/com/codename1/backend/annotations/ManagedResource.java new file mode 100644 index 00000000000..4724d083fd5 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ManagedResource.java @@ -0,0 +1,46 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Publishes this bean's [ManagedAttribute] getters as metrics and its +/// [ManagedOperation] methods as operations -- the JMX model, over OpenTelemetry. +/// +/// Each numeric attribute becomes an observable gauge named +/// `.`, read when metrics are collected. The operations +/// are listed by the management endpoint and by the development MCP server, +/// which can invoke them. Everything is enumerated by the build; the generated +/// code calls the getters and the operations directly. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface ManagedResource { + /// The prefix the metrics are named under. Empty means the class's simple + /// name. + String objectName() default ""; + /// What the bean is, for the listing. + String description() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/McpParam.java b/vm/backend/src/com/codename1/backend/annotations/McpParam.java new file mode 100644 index 00000000000..da514e79bfd --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/McpParam.java @@ -0,0 +1,40 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Names and describes one parameter of an [McpTool] method. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.PARAMETER) +public @interface McpParam { + /// The argument's name in the tool call. + String value(); + /// What the argument is, for the agent. + String description() default ""; + /// Whether the call must supply it. An optional primitive receives zero. + boolean required() default true; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/McpTool.java b/vm/backend/src/com/codename1/backend/annotations/McpTool.java new file mode 100644 index 00000000000..9801d860f9f --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/McpTool.java @@ -0,0 +1,51 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Publishes this method of a bean as a tool on the server's MCP endpoint, so an +/// agent can call it. +/// +/// The build writes the tool's JSON Schema from the parameter types and a +/// dispatcher that converts the call's arguments and invokes the method +/// directly. The parameters may be strings, numbers, booleans, or maps and lists +/// of those; each needs [McpParam] to name it, because a Java parameter name does +/// not survive compilation. The result is written with +/// [com.codename1.backend.Json]. +/// +/// The endpoint is at `cn1.mcp.path` (default `/mcp`). Outside a development +/// profile it requires `cn1.mcp.token` and refuses to start without one, because +/// a tool reachable by anyone is a vulnerability rather than a feature. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface McpTool { + /// The tool's name. Empty means the method's name. + String name() default ""; + /// What the tool does. Agents choose tools by this text, so say when to use + /// it, not only what it is. + String description(); +} diff --git a/vm/backend/src/com/codename1/backend/annotations/OpenTelemetry.java b/vm/backend/src/com/codename1/backend/annotations/OpenTelemetry.java index d5cad57d334..80d8b83538f 100644 --- a/vm/backend/src/com/codename1/backend/annotations/OpenTelemetry.java +++ b/vm/backend/src/com/codename1/backend/annotations/OpenTelemetry.java @@ -64,4 +64,18 @@ /// or `cn1.otel.service.name` names another. Empty means `unknown_service`, /// which is what every OpenTelemetry SDK reports when nobody said. String serviceName() default ""; + + /// The collector's base URL, `cn1.otel.endpoint`, compiled in below + /// `OTEL_EXPORTER_OTLP_ENDPOINT` and the properties files. Empty sets nothing. + String endpoint() default ""; + + /// `cn1.otel.protocol`: `http/protobuf` or `http/json`. Empty sets nothing. + String protocol() default ""; + + /// `cn1.otel.sampler`, such as `traceidratio`. Empty sets nothing. + String sampler() default ""; + + /// `cn1.otel.sampler.arg`: the ratio for the ratio samplers. Empty sets + /// nothing. + String samplerArg() default ""; } diff --git a/vm/backend/src/com/codename1/backend/annotations/PostConstruct.java b/vm/backend/src/com/codename1/backend/annotations/PostConstruct.java new file mode 100644 index 00000000000..2cd601db215 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/PostConstruct.java @@ -0,0 +1,37 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Called once the bean is constructed and every member is injected. +/// +/// The method takes no arguments. The generated entry point calls it directly, +/// in dependency order, so a bean's dependencies have already run theirs. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface PostConstruct { +} diff --git a/vm/backend/src/com/codename1/backend/annotations/PreDestroy.java b/vm/backend/src/com/codename1/backend/annotations/PreDestroy.java new file mode 100644 index 00000000000..bc1489cbe32 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/PreDestroy.java @@ -0,0 +1,39 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Called when the server stops, after it has finished the requests in flight +/// and before the database closes. +/// +/// The method takes no arguments. Beans are destroyed in the reverse of the +/// order they were built, so a bean's dependencies are still alive when its +/// method runs. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface PreDestroy { +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Primary.java b/vm/backend/src/com/codename1/backend/annotations/Primary.java new file mode 100644 index 00000000000..21acb964315 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Primary.java @@ -0,0 +1,35 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// The bean an injection point receives when several have its type and it names +/// none. Two primary beans of one type are a build error. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface Primary { +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Profile.java b/vm/backend/src/com/codename1/backend/annotations/Profile.java new file mode 100644 index 00000000000..73928bc799c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Profile.java @@ -0,0 +1,44 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Constructs this bean only when a profile is active. +/// +/// `@Profile("dev")` is active under `CN1_PROFILE=dev`; `@Profile("!prod")` is +/// active under every profile but `prod`; several values mean any of them. The +/// profile is the deployment's to choose, so this is the one decision the build +/// cannot make: it becomes a single `if` in the generated start-up code. The build +/// still checks that every bean needing this one is covered either way -- by +/// another bean of the same type under the other profiles, or by being +/// conditional itself. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface Profile { + /// The profiles, each optionally negated with `!`. + String[] value(); +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Propagation.java b/vm/backend/src/com/codename1/backend/annotations/Propagation.java new file mode 100644 index 00000000000..49a07b2bcc2 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Propagation.java @@ -0,0 +1,44 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +/// What a [Transactional] method does about a transaction that is already +/// open on the calling thread. Spring's meanings, one for one. +public enum Propagation { + /// Joins the open transaction, or opens one. The default. + REQUIRED, + /// Joins the open transaction, or runs without one. + SUPPORTS, + /// Joins the open transaction, and refuses to run without one. + MANDATORY, + /// Suspends the open transaction, if any, and opens its own on another + /// connection. It commits or rolls back independently of the outer one. + REQUIRES_NEW, + /// Suspends the open transaction, if any, and runs without one. + NOT_SUPPORTED, + /// Refuses to run inside a transaction. + NEVER, + /// Runs in a savepoint of the open transaction, which a failure rolls back + /// to without failing the outer transaction; opens one when none is open. + NESTED; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Qualifier.java b/vm/backend/src/com/codename1/backend/annotations/Qualifier.java new file mode 100644 index 00000000000..2a884224f1b --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Qualifier.java @@ -0,0 +1,38 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Picks one bean by name where several have the type an injection point asks +/// for. The name is the one [Component], [Bean] and the other stereotypes +/// declare, or the default they derive. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, ElementType.TYPE}) +public @interface Qualifier { + /// The bean's name. + String value(); +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Repository.java b/vm/backend/src/com/codename1/backend/annotations/Repository.java new file mode 100644 index 00000000000..84f5027c31b --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Repository.java @@ -0,0 +1,37 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A [Component] in the persistence layer. Identical to it in every respect +/// except the word, which is there for the reader. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Repository { + /// The bean's name, for [Qualifier]. Empty means the default name. + String value() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/RequestScope.java b/vm/backend/src/com/codename1/backend/annotations/RequestScope.java new file mode 100644 index 00000000000..fab81182389 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/RequestScope.java @@ -0,0 +1,34 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Shorthand for `@Scope("request")`. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface RequestScope { +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Scheduled.java b/vm/backend/src/com/codename1/backend/annotations/Scheduled.java new file mode 100644 index 00000000000..5d72f3f7a36 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Scheduled.java @@ -0,0 +1,84 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Runs this method of a bean on a schedule. +/// +/// Exactly one of [#cron], [#fixedRate] or [#fixedDelay] (or their `String` +/// forms) must be given. The method takes no arguments. +/// +/// A literal cron expression is parsed by the build, which writes its fields +/// into the entry point as bit masks and refuses a malformed one: an expression +/// nobody noticed was wrong otherwise fires never, or every second. One that +/// reads configuration (`${report.cron}`) is parsed once at start-up instead. +/// +/// The format is Spring's six fields -- second, minute, hour, day of month, +/// month, day of week -- plus the `@yearly`, `@monthly`, `@weekly`, `@daily` +/// and `@hourly` macros: +/// +/// ```java +/// @Scheduled(cron = "0 */15 * * * MON-FRI") +/// public void refresh() { ... } +/// ``` +/// +/// Several instances of one server each run every job. [#lock] makes a job run +/// on one of them at a time, through a row in the database. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Scheduled { + /// A cron expression, or `${key}` to read one from configuration. + String cron() default ""; + /// The time zone the cron expression is read in: a zone ID such as + /// `Europe/Berlin`, a fixed offset such as `+02:00`, or `UTC`. Empty means + /// UTC, which is what a server should keep its clock in anyway. + String zone() default ""; + /// Milliseconds between the starts of consecutive runs. + long fixedRate() default -1; + /// Milliseconds between the end of one run and the start of the next. + long fixedDelay() default -1; + /// Milliseconds before the first run of a fixed-rate or fixed-delay job. + long initialDelay() default -1; + /// [#fixedRate] as text, which may read configuration. + String fixedRateString() default ""; + /// [#fixedDelay] as text, which may read configuration. + String fixedDelayString() default ""; + /// [#initialDelay] as text, which may read configuration. + String initialDelayString() default ""; + /// Which kind of thread runs it. + ThreadKind thread() default ThreadKind.PLATFORM; + /// The executor to run on. Empty means `scheduling`. + String executor() default ""; + /// A lock name. When set, a run first claims the named row in the + /// `cn1_scheduler_lock` table and is skipped if another instance holds it. + /// Needs a database. + String lock() default ""; + /// How long a claimed lock is held at most, in milliseconds, so an instance + /// that dies mid-run does not hold it for good. Zero or less means ten + /// minutes. + long lockAtMostFor() default -1; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Scope.java b/vm/backend/src/com/codename1/backend/annotations/Scope.java new file mode 100644 index 00000000000..5dd21f2660e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Scope.java @@ -0,0 +1,48 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// How many instances of a bean there are, and how long each lives. +/// +/// - `singleton` (the default) -- one, for the life of the server. +/// - `prototype` -- a new one for every injection point. +/// - `request` -- one per HTTP request, built when the request first uses it. +/// - `session` -- one per [com.codename1.backend.HttpSession]. +/// +/// A request or session bean injected into a singleton is reached through a +/// small class the build generates, which finds the current request's instance +/// on each call. That class extends the bean's, so a request or session bean +/// cannot be final, needs a constructor taking no arguments, and has that +/// constructor run once at start-up for the stand-in -- per-request set-up +/// belongs in `@PostConstruct`, which runs on each real instance. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface Scope { + /// One of `singleton`, `prototype`, `request` or `session`. + String value(); +} diff --git a/vm/backend/src/com/codename1/backend/annotations/ServerConfig.java b/vm/backend/src/com/codename1/backend/annotations/ServerConfig.java new file mode 100644 index 00000000000..a10c375642c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ServerConfig.java @@ -0,0 +1,50 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Listener settings, compiled in. +/// +/// Each attribute is the `cn1.server.*` key named beside it, and sets that key's +/// value at the bottom of the configuration, so `PORT`, a properties file or the +/// environment still override it. An attribute left at its default sets nothing. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface ServerConfig { + /// `cn1.server.port`. + int port() default -1; + + /// `cn1.server.workers`: the request thread pool. + int workers() default -1; + + /// `cn1.server.backlog`: the listen backlog. + int backlog() default -1; + + /// `cn1.server.shutdownTimeoutMillis`: how long a stop waits for the requests + /// in flight. + int shutdownTimeoutMillis() default -1; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Service.java b/vm/backend/src/com/codename1/backend/annotations/Service.java new file mode 100644 index 00000000000..471967c9cdc --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Service.java @@ -0,0 +1,37 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A [Component] in the service layer. Identical to it in every respect except +/// the word, which is there for the reader. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Service { + /// The bean's name, for [Qualifier]. Empty means the default name. + String value() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/SessionConfig.java b/vm/backend/src/com/codename1/backend/annotations/SessionConfig.java new file mode 100644 index 00000000000..8d6719e9978 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/SessionConfig.java @@ -0,0 +1,63 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Session settings, compiled in. +/// +/// Each attribute is the `cn1.session.*` key named beside it, and sets that key's +/// value at the bottom of the configuration: `application.properties`, a +/// profile's file or the environment still change it without a rebuild. An +/// attribute left at its default sets nothing. +/// +/// ```java +/// @SessionConfig(store = "db", timeoutSeconds = 3600) +/// public class Settings { } +/// ``` +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface SessionConfig { + /// `cn1.session.store`: `memory` or `db`. + String store() default ""; + + /// `cn1.session.timeout`: seconds of inactivity before a session ends, 0 for + /// never. + int timeoutSeconds() default -1; + + /// `cn1.session.cookie`: the cookie's name. + String cookie() default ""; + + /// `cn1.session.same-site`: `Lax`, `Strict` or `None`. + String sameSite() default ""; + + /// `cn1.session.secure`: `auto`, `true` or `false`. + String secure() default ""; + + /// `cn1.session.namespace`: the prefix that separates this server's sessions + /// in a shared store. + String namespace() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/SessionScope.java b/vm/backend/src/com/codename1/backend/annotations/SessionScope.java new file mode 100644 index 00000000000..e173dec4a4b --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/SessionScope.java @@ -0,0 +1,34 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Shorthand for `@Scope("session")`. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface SessionScope { +} diff --git a/vm/backend/src/com/codename1/backend/annotations/StaticFilesConfig.java b/vm/backend/src/com/codename1/backend/annotations/StaticFilesConfig.java new file mode 100644 index 00000000000..f217f17d544 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/StaticFilesConfig.java @@ -0,0 +1,49 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Static file settings, compiled in. +/// +/// Each attribute is the `cn1.static.*` key named beside it, and sets that key's +/// value at the bottom of the configuration, so a properties file or the +/// environment still override it. An attribute left at its default sets nothing. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface StaticFilesConfig { + /// `cn1.static.root`: the directory to serve. + String root() default ""; + + /// `cn1.static.prefix`: the path they're served under. + String prefix() default ""; + + /// `cn1.static.index`: the file a directory answers with. + String index() default ""; + + /// `cn1.static.cacheControl`: their `Cache-Control` header. + String cacheControl() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/ThreadKind.java b/vm/backend/src/com/codename1/backend/annotations/ThreadKind.java new file mode 100644 index 00000000000..cf76af0c9ba --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ThreadKind.java @@ -0,0 +1,44 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +/// Which kind of thread runs background work: an [Async] method or a +/// [Scheduled] job. +/// +/// A virtual thread is cheap to create and to switch, and many can wait on +/// sockets at once: a PostgreSQL or MySQL query, an outbound HTTP call and a TLS +/// handshake all park it, and its host runs other virtual threads meanwhile. +/// What it must not do is block outside a socket. SQLite and file access are +/// local calls that hold the host thread under it, and with it every other +/// virtual thread that host is running, so work that uses them belongs on a +/// platform thread. +public enum ThreadKind { + /// A virtual thread when this build and this server run them, a platform + /// thread otherwise. The choice is logged once at start-up. + AUTO, + /// A virtual thread, falling back to a platform thread where there are none + /// (the Java SE runtime, a TLS server, Windows). + VIRTUAL, + /// A thread of the executor's pool. + PLATFORM; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Timed.java b/vm/backend/src/com/codename1/backend/annotations/Timed.java new file mode 100644 index 00000000000..7589ed1d232 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Timed.java @@ -0,0 +1,42 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Records how long each call of this method takes, as a histogram. +/// +/// The build rewrites the method to read the clock before and after its body; +/// the instrument itself is created once, when the class is first used. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Timed { + /// The metric's name. Empty means the class and method, such as + /// `com.example.Orders.place.duration`. + String value() default ""; + /// What it measures, for the listing. + String description() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Transactional.java b/vm/backend/src/com/codename1/backend/annotations/Transactional.java new file mode 100644 index 00000000000..852e7cb5242 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Transactional.java @@ -0,0 +1,70 @@ +/* + * 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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Runs this method -- or every public method of this class -- in a database +/// transaction. +/// +/// The build rewrites the compiled method: its body moves into a private method +/// of its own, and the method keeps its name and wraps that body in a begin and +/// a commit, with a rollback on failure. Everything is decided at build time -- +/// the propagation, the rules below, the exception types -- and written in as +/// constants and `instanceof` tests, so there is nothing to interpret on the +/// call. +/// +/// Because the method itself is rewritten rather than wrapped by a proxy, the +/// transaction applies however it is called: from another bean, from `this`, +/// from a private caller, or on an object the application built with `new`. +/// That is the one place this differs from Spring, where a call through `this` +/// silently skips the transaction. +/// +/// Rollback follows Spring's rule: an unchecked exception or an `Error` rolls +/// back, a checked exception commits, and [#rollbackFor] and [#noRollbackFor] +/// adjust that. When several listed types match the thrown one, the most +/// specific wins. +/// +/// Everything done through the server's [com.codename1.backend.DataSource] -- +/// its own methods, an entity manager's daos, the transaction's +/// `com.codename1.orm.session.Session` -- joins the transaction on the calling +/// thread. A connection is borrowed only when the transaction first needs one. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface Transactional { + /// What to do about a transaction that is already open. + Propagation propagation() default Propagation.REQUIRED; + /// A hint that the method only reads. Engines that support a read-only + /// transaction get one, which lets them skip work and refuse writes. + boolean readOnly() default false; + /// Seconds the transaction may take before its commit is refused and it is + /// rolled back. Zero or less means no limit. + int timeout() default -1; + /// Exception types that roll back, in addition to unchecked ones. + Class[] rollbackFor() default {}; + /// Exception types that commit even though they would roll back. + Class[] noRollbackFor() default {}; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Value.java b/vm/backend/src/com/codename1/backend/annotations/Value.java new file mode 100644 index 00000000000..04476a0e281 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Value.java @@ -0,0 +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. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Injects a configuration value. +/// +/// `${key}` reads `key` from [com.codename1.backend.Config] -- a system +/// property, the environment, the profile's properties file or the base one, in +/// that order -- and `${key:fallback}` supplies a value when none of them has +/// it. The text around a reference is kept, so `"${host}:${port}"` works. A +/// String, a primitive or its box, or an enum can receive one; the conversion is +/// written into the generated entry point. +/// +/// A key nobody sets, with no fallback, is a start-up error naming the key: a +/// missing setting is the deployment's mistake, and start-up is where it is +/// cheap to see. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD}) +public @interface Value { + /// The expression, such as `${mail.host:localhost}`. + String value(); +} diff --git a/vm/backend/src/com/codename1/backend/mcp/DevTools.java b/vm/backend/src/com/codename1/backend/mcp/DevTools.java new file mode 100644 index 00000000000..4e1a9cc525d --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mcp/DevTools.java @@ -0,0 +1,642 @@ +/* + * 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. + */ +package com.codename1.backend.mcp; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.codename1.backend.Backend; +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.Database; +import com.codename1.backend.DevConsole; +import com.codename1.backend.Management; +import com.codename1.backend.Scheduler; +import com.codename1.backend.Web; +import com.codename1.backend.metrics.Metrics; +import com.codename1.backend.orm.ColumnDefinition; +import com.codename1.backend.orm.EntityDefinition; +import com.codename1.backend.orm.EntityManager; + +/// The tools an agent developing a backend uses on the running server. +/// +/// | Tool | What it does | +/// |---|---| +/// | `backend_routes` | every route the build generated | +/// | `backend_beans` | every bean, its scope and what it was given | +/// | `backend_config` | the profile and the configured keys, secrets masked | +/// | `backend_call` | sends the server an HTTP request and returns the response | +/// | `backend_requests` | the last requests served, and what failed and why | +/// | `backend_logs` | the last console lines | +/// | `backend_sql` | runs SQL against the server's database | +/// | `backend_schema` | the entities and their tables and columns | +/// | `backend_jobs` / `backend_run_job` | the scheduled jobs; run one now | +/// | `backend_metrics` | every metric's current value | +/// | `backend_managed` / `backend_invoke` | the managed beans; call an operation | +/// +/// Linked only into a development build -- the entry point `cn1:backend` +/// runs -- and installed only on a development profile, so a production binary +/// has none of it. +public final class DevTools implements McpServer.Extension { + private Backend backend; + + private synchronized Backend installed() { + return backend; + } + + /// Takes `running` as this instance's server, unless it already has one. + private synchronized boolean claim(Backend running) { + if (backend != null && backend != running) { //NOPMD CompareObjectsWithEquals - servers by identity + return false; + } + backend = running; + return true; + } + + /// The server the tools were installed on; they are only reachable after that. + private Backend backend() { + Backend running = installed(); + if (running == null) { + throw new IllegalStateException("The development tools are not installed"); + } + return running; + } + + @Override + public void install(McpServer server, Backend running) { + if (!claim(running)) { + // Already installed on another server. Every tool reads the server it + // works on from its DevTools, so sharing one would point the first + // server's backend_sql, backend_call and operations at the second; this + // installation gets an instance of its own instead. + new DevTools().install(server, running); + return; + } + running.getRequestLog().enable(200); + DevConsole.install(); + server.register(new Tool("backend_routes", + "Lists every HTTP route of the running backend: method, path and the " + + "controller method that serves it. Use it to learn the API before " + + "calling it with backend_call.", schema()) { + @Override + Object run(Map a) { + return application() == null ? new ArrayList() + : application().describeRoutes(); + } + }); + server.register(new Tool("backend_beans", + "Lists every bean the build wired: its name, type, scope and the beans " + + "injected into it. Use it to check dependency injection did what the " + + "code intends.", schema()) { + @Override + Object run(Map a) { + return application() == null ? new ArrayList() + : application().describeBeans(); + } + }); + server.register(new Tool("backend_config", + "Shows the active profile and every configured key, with values that " + + "look secret masked.", schema()) { + @Override + Object run(Map a) throws Exception { + return config(); + } + }); + server.register(new Tool("backend_call", + "Sends an HTTP request to the running backend and returns the status, " + + "headers and body. Use it to exercise an endpoint after changing it.", + schema(new String[] {"method", "string", "GET, POST, PUT, PATCH or DELETE", + "path", "string", "The path and query, starting with /", + "body", "string", "The request body, usually JSON", + "headers", "object", "Extra request headers, name to value"}, + new String[] {"method", "path"})) { + @Override + Object run(Map a) throws Exception { + // Named apart from McpTool.call: inside this class, call(a) bound + // to the inherited method, which runs this one again -- a + // backend_call that recursed until the stack ran out. + return sendRequest(a); + } + }); + server.register(new Tool("backend_requests", + "The last requests the backend served, newest first, with status, time " + + "and any exception a handler threw. Set failuresOnly to see what broke.", + schema(new String[] {"limit", "integer", "How many, default 20", + "failuresOnly", "boolean", "Only 5xx answers and exceptions"}, null)) { + @Override + Object run(Map a) { + return backend().getRequestLog().recent(intArg(a, "limit", 20), + boolArg(a, "failuresOnly")); + } + }); + server.register(new Tool("backend_logs", + "The last lines the backend printed to its console, oldest first.", + schema(new String[] {"limit", "integer", "How many lines, default 100"}, + null)) { + @Override + Object run(Map a) { + if (!DevConsole.supported()) { + return "This runtime cannot capture the console; run the backend with " + + "cn1:backend to have logs here."; + } + return DevConsole.recent(intArg(a, "limit", 100)); + } + }); + server.register(new Tool("backend_sql", + "Runs SQL against the backend's database and returns the rows. Only " + + "SELECT-like statements run unless write is true. Use ? placeholders " + + "with params; the same SQL works on SQLite, PostgreSQL and MySQL.", + schema(new String[] {"sql", "string", "The statement", + "params", "array", "Values for the ? placeholders", + "write", "boolean", "Allow INSERT, UPDATE, DELETE and DDL"}, + new String[] {"sql"})) { + @Override + Object run(Map a) throws Exception { + return sql(a); + } + }); + server.register(new Tool("backend_schema", + "Lists the entities the build generated persistence for, with their " + + "tables and columns.", schema()) { + @Override + Object run(Map a) { + return schemaOf(); + } + }); + server.register(new Tool("backend_jobs", + "Lists the scheduled jobs: schedule, runs, failures, last and next run.", + schema()) { + @Override + Object run(Map a) { + Scheduler s = application() == null ? null : application().getScheduler(); + return s == null ? new ArrayList() : s.describe(); + } + }); + server.register(new Tool("backend_run_job", + "Runs a scheduled job now, whatever its schedule says.", + schema(new String[] {"name", "string", "The job's name from backend_jobs"}, + new String[] {"name"})) { + @Override + Object run(Map a) { + Scheduler s = application() == null ? null : application().getScheduler(); + if (s == null || !s.trigger(stringArg(a, "name"))) { + throw new IllegalArgumentException("No idle job named " + + stringArg(a, "name")); + } + return "started"; + } + }); + server.register(new Tool("backend_metrics", + "Every metric's current value: request durations by route, pool and " + + "executor gauges, and the application's own.", + schema(new String[] {"prefix", "string", "Only metrics whose name starts " + + "with this"}, null)) { + @Override + Object run(Map a) { + return metrics(stringArg(a, "prefix")); + } + }); + server.register(new Tool("backend_managed", + "Lists the @ManagedResource beans with their attributes' current values " + + "and their operations.", schema()) { + @Override + Object run(Map a) { + return Management.describeBeans(backend().getManagedBeans()); + } + }); + server.register(new Tool("backend_invoke", + "Calls an operation of a @ManagedResource bean.", + schema(new String[] {"bean", "string", "The bean's objectName", + "operation", "string", "The operation's name", + "arguments", "object", "Arguments by parameter name"}, + new String[] {"bean", "operation"})) { + @Override + Object run(Map a) throws Exception { + Object args = a.get("arguments"); + return Management.invoke(backend().getManagedBeans(), stringArg(a, "bean"), stringArg(a, "operation"), + args instanceof Map ? (Map) args : new LinkedHashMap()); + } + }); + } + + private Backend.Application application() { + return installed() == null ? null : backend().getApplication(); + } + + private Map config() throws Exception { + Config config = backend().getConfig(); + Map out = new LinkedHashMap(); + out.put("profile", config.getProfile()); + out.put("source", config.describe()); + Map values = new LinkedHashMap(); + List keys = config.keys(); + for (Object element : keys) { + String key = String.valueOf(element); + String value; + try { + value = config.get(key); + } catch (Exception err) { + value = "<" + err.getMessage() + ">"; + } + values.put(key, secret(key, value) ? "***" : value); + } + out.put("values", values); + return out; + } + + static boolean secret(String key, String value) { + String k = key; + // "header" and "auth" too: cn1.otel.headers carries api-key=... and + // Authorization=... pairs under a key that names none of the others. + String[] marks = {"password", "secret", "token", "key", "credential", "header", + "auth"}; + for (String element : marks) { + if (containsIgnoreCase(k, element)) { + return true; + } + } + // A value carrying a credential as name=value, whatever its key: a + // datasource URL's ?password=..., a header list's api-key=.... + String[] inValue = {"password", "passwd", "pwd", "secret", "token", "key", "auth", + "credential"}; + if (value != null && value.indexOf('=') >= 0) { + for (String element : inValue) { + if (containsIgnoreCase(value, element)) { + return true; + } + } + } + // A URL with a password in it: scheme://user:password@host + if (value != null) { + int scheme = value.indexOf("://"); + int at = value.indexOf('@'); + if (scheme > 0 && at > scheme && value.indexOf(':', scheme + 3) < at + && value.indexOf(':', scheme + 3) > 0) { + return true; + } + } + return false; + } + + private static boolean containsIgnoreCase(String text, String part) { + for (int iter = 0 ; iter + part.length() <= text.length() ; iter++) { + if (text.regionMatches(true, iter, part, 0, part.length())) { + return true; + } + } + return false; + } + + private Map sendRequest(Map a) throws Exception { + String method = stringArg(a, "method"); + String path = stringArg(a, "path"); + if (method == null || path == null || !path.startsWith("/")) { + throw new IllegalArgumentException("method and a path starting with / are " + + "required"); + } + List headers = new ArrayList(); + Object extra = a.get("headers"); + boolean contentType = false; + if (extra instanceof Map) { + Iterator it = ((Map) extra).entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + String name = String.valueOf(e.getKey()); + if ("content-type".equalsIgnoreCase(name)) { + contentType = true; + } + headers.add(name + ": " + e.getValue()); + } + } + String body = stringArg(a, "body"); + if (body != null && !contentType) { + headers.add("Content-Type: application/json"); + } + int port = backend().getServer().getPort(); + // The server's own scheme: plain HTTP to a TLS listener is answered + // with a handshake failure, never with the route's response. + String scheme = backend().getServer().isSecure() ? "https" : "http"; + // The address the listener is bound to: one bound to ::1 or to a single + // interface does not answer on 127.0.0.1. + Web.Result result = Web.request(asciiUpper(method), scheme + "://" + + backend().getListenAddress() + ":" + port + + path, headers, body == null ? null : McpServer.utf8(body)); + Map out = new LinkedHashMap(); + out.put("status", Integer.valueOf(result.getStatus())); + out.put("headers", result.getHeaders()); + String text = result.getBodyAsString(); + if (text != null && text.length() > 65536) { + text = text.substring(0, 65536) + "... (" + text.length() + " characters)"; + } + out.put("body", text); + return out; + } + + /// Runs a statement the caller did not confirm as a write, where the ENGINE + /// refuses writes -- the first keyword is only a courtesy check. A + /// PostgreSQL data-modifying WITH, an EXPLAIN ANALYZE DELETE and a writable + /// SQLite pragma all begin like reads, and each would otherwise change the + /// database. PostgreSQL and MySQL enforce a read-only transaction; SQLite + /// ignores that flag, so query_only is set on the connection as well. The + /// transaction is always rolled back. + private static List readOnly(DataSource pool, String statement, Object[] params) + throws IOException { + String body = statement.trim(); + while (body.endsWith(";")) { + body = body.substring(0, body.length() - 1).trim(); + } + if (body.indexOf(';') >= 0) { + // A second statement could end the read-only transaction and run + // outside it. + throw new IllegalArgumentException("Send one statement at a time, or pass " + + "write=true"); + } + if (body.regionMatches(true, 0, "PRAGMA", 0, 6) && !readOnlyPragma(body)) { + // Setting a pragma changes the pooled connection for whoever borrows + // it next -- or the file -- whatever the transaction says, and SQLite + // takes the value as `name = v` or as `name(v)`. So only pragmas known + // to read run unconfirmed. + throw new IllegalArgumentException("That pragma may change a setting; pass " + + "write=true to run it"); + } + Database db = pool.borrow(); + boolean sqlite = "sqlite".equals(db.dialect().getName()); + boolean healthy = true; + try { + if (sqlite) { + db.execute("PRAGMA query_only = ON", null); + } + db.beginTransaction(true); + List rows; + try { + rows = db.query(body, params); + } finally { + try { + db.rollbackTransaction(); + } catch (IOException err) { + // A connection whose rollback failed still believes it is in + // a transaction, and the next borrower would wait on an owner + // that has left; it is closed, not pooled. + healthy = false; + throw err; + } + } + return rows; + } finally { + if (sqlite) { + try { + db.execute("PRAGMA query_only = OFF", null); + } catch (IOException err) { + // A connection stuck read-only must not go back to the pool. + healthy = false; + } + } + if (!healthy) { + db.close(); + } + pool.release(db); + } + } + + /// The pragmas that only read -- listing tables, columns, indexes and settings + /// -- in either spelling: bare, or with a table name in parentheses. Anything + /// else, and any `=`, needs write=true. + private static final String[] READ_PRAGMAS = {"table_info", "table_xinfo", + "table_list", "index_list", "index_info", "index_xinfo", "foreign_key_list", + "foreign_key_check", "integrity_check", "quick_check", "database_list", + "collation_list", "function_list", "module_list", "pragma_list", + "compile_options", "page_count", "page_size", "freelist_count", "encoding", + "user_version", "schema_version", "application_id", "foreign_keys", + "journal_mode", "busy_timeout", "cache_size", "synchronous", "query_only"}; + + /// The pragmas that may take a table or index name in parentheses. + private static final String[] NAMED_PRAGMAS = {"table_info", "table_xinfo", + "table_list", "index_list", "index_info", "index_xinfo", "foreign_key_list", + "foreign_key_check", "integrity_check", "quick_check"}; + + static boolean readOnlyPragma(String statement) { + if (statement.indexOf('=') >= 0) { + return false; + } + String rest = statement.substring(6).trim(); + int paren = rest.indexOf('('); + String name = (paren < 0 ? rest : rest.substring(0, paren)).trim(); + int dot = name.indexOf('.'); + if (dot >= 0) { + name = name.substring(dot + 1); // schema.name + } + String[] allowed = paren < 0 ? READ_PRAGMAS : NAMED_PRAGMAS; + for (String element : allowed) { + if (element.equalsIgnoreCase(name)) { + return true; + } + } + return false; + } + + private Object sql(Map a) throws Exception { + DataSource pool = backend().getDataSource(); + if (pool == null) { + throw new IllegalArgumentException("This backend has no database"); + } + String statement = stringArg(a, "sql"); + if (statement == null || statement.trim().length() == 0) { + throw new IllegalArgumentException("sql is required"); + } + Object[] params = new Object[0]; + Object p = a.get("params"); + if (p instanceof List) { + params = ((List) p).toArray(); + } + if (boolArg(a, "write")) { + Map out = new LinkedHashMap(); + out.put("updated", Integer.valueOf(pool.execute(statement, params))); + return out; + } + String head = statement.trim(); + String[] reads = {"SELECT", "WITH", "EXPLAIN", "PRAGMA", "SHOW", "DESCRIBE", "VALUES"}; + boolean read = false; + for (int iter = 0 ; iter < reads.length && !read; iter++) { + read = head.regionMatches(true, 0, reads[iter], 0, reads[iter].length()); + } + if (!read) { + throw new IllegalArgumentException("That statement writes; pass write=true to run " + + "it"); + } + List rows = readOnly(pool, statement, params); + if (rows.size() > 500) { + List cut = new ArrayList(rows.subList(0, 500)); + Map out = new LinkedHashMap(); + out.put("rows", cut); + out.put("truncated", "first 500 of " + rows.size() + " rows"); + return out; + } + return rows; + } + + private List schemaOf() { + List out = new ArrayList(); + EntityDefinition[] all = EntityManager.registered(); + for (EntityDefinition d : all) { + Map m = new LinkedHashMap(); + m.put("entity", d.type().getName()); + m.put("table", d.table()); + List columns = new ArrayList(); + ColumnDefinition[] cols = d.columns(); + for (ColumnDefinition element : cols) { + Map col = new LinkedHashMap(); + col.put("field", element.getField()); + col.put("column", element.getColumn()); + col.put("type", element.getDeclaredType()); + if (element.isId()) { + col.put("id", Boolean.TRUE); + } + if (element.isNullable()) { + col.put("nullable", Boolean.TRUE); + } + columns.add(col); + } + m.put("columns", columns); + out.add(m); + } + return out; + } + + private static Map metrics(String prefix) { + Map all = Metrics.snapshot(); + if (prefix == null || prefix.length() == 0) { + return all; + } + Map out = new LinkedHashMap(); + Iterator it = all.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + if (String.valueOf(e.getKey()).startsWith(prefix)) { + out.put(e.getKey(), e.getValue()); + } + } + return out; + } + + /// Upper case by hand: toUpperCase follows the locale, and an HTTP method is ASCII. + static String asciiUpper(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int iter = 0 ; iter < value.length() ; iter++) { + char c = value.charAt(iter); + sb.append(c >= 'a' && c <= 'z' ? (char) (c - 32) : c); + } + return sb.toString(); + } + + static String stringArg(Map a, String name) { + Object v = a.get(name); + return v == null ? null : String.valueOf(v); + } + + static int intArg(Map a, String name, int fallback) { + Object v = a.get(name); + if (v instanceof Number) { + return ((Number) v).intValue(); + } + if (v instanceof String) { + try { + return Integer.parseInt((String) v); + } catch (NumberFormatException err) { + return fallback; + } + } + return fallback; + } + + static boolean boolArg(Map a, String name) { + Object v = a.get(name); + return Boolean.TRUE.equals(v) || "true".equals(v); + } + + /// An object schema with no properties. + static Map schema() { + return schema(new String[0], null); + } + + /// An object schema from triples of name, JSON type and description, and the + /// names that are required. + static Map schema(String[] triples, String[] required) { + Map properties = new LinkedHashMap(); + for (int iter = 0 ; iter + 2 < triples.length ; iter += 3) { + Map p = new LinkedHashMap(); + p.put("type", triples[iter + 1]); + p.put("description", triples[iter + 2]); + properties.put(triples[iter], p); + } + Map out = new LinkedHashMap(); + out.put("type", "object"); + out.put("properties", properties); + if (required != null && required.length > 0) { + List r = new ArrayList(); + for (String element : required) { + r.add(element); + } + out.put("required", r); + } + return out; + } + + /// A tool whose behaviour is one method. + abstract static class Tool implements McpTool { + private final String name; + private final String description; + private final Map schema; + + Tool(String name, String description, Map schema) { + this.name = name; + this.description = description; + this.schema = schema; + } + + @Override + public String name() { + return name; + } + + @Override + public String description() { + return description; + } + + @Override + public Map inputSchema() { + return schema; + } + + @Override + public Object call(Map arguments) throws Exception { + return run(arguments); + } + + abstract Object run(Map arguments) throws Exception; + } +} diff --git a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java new file mode 100644 index 00000000000..8d83ef66245 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java @@ -0,0 +1,316 @@ +/* + * 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. + */ +package com.codename1.backend.mcp; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The conversions the build's generated tool and operation adapters use: one +/// argument out of a call's argument map, as the type the method declares. +/// +/// Accepts what an agent or an HTTP client actually sends -- a JSON number or a +/// numeric string, a JSON boolean or "true" -- and refuses anything else with an +/// [IllegalArgumentException] naming the argument, which the MCP endpoint +/// reports to the agent as a tool error it can correct. +public final class McpArgs { + private McpArgs() { + } + + /// One property of an input schema. + public static Map property(String type, String description, String[] values) { + Map p = new LinkedHashMap(); + if (type != null && type.length() > 0) { + p.put("type", type); + } + if (description != null && description.length() > 0) { + p.put("description", description); + } + if (values != null) { + List list = new ArrayList(); + for (String element : values) { + list.add(element); + } + p.put("enum", list); + } + return p; + } + + /// An object schema. + public static Map object(Map properties, String[] required) { + Map out = new LinkedHashMap(); + out.put("type", "object"); + out.put("properties", properties); + if (required != null && required.length > 0) { + List list = new ArrayList(); + for (String element : required) { + list.add(element); + } + out.put("required", list); + } + return out; + } + + private static Object raw(Map args, String name, boolean required) { + Object v = args == null ? null : args.get(name); + if (v == null && required) { + throw new IllegalArgumentException("Missing required argument \"" + name + "\""); + } + return v; + } + + public static Object any(Map args, String name, boolean required) { + return raw(args, name, required); + } + + /// A string argument. A number or boolean is taken as its text, as Jackson + /// coerces a scalar for Spring; an array or object is refused. Its Java + /// text -- `[value]`, `{id=value}` -- is not what the caller sent, and a + /// malformed call would run a side-effecting tool with an identifier nobody + /// meant, where the schema promised a JSON string. + public static String string(Map args, String name, boolean required) { + Object v = raw(args, name, required); + if (v instanceof Map || v instanceof java.util.Collection || v instanceof Object[]) { + throw new IllegalArgumentException("\"" + name + "\" must be a string"); + } + return v == null ? null : String.valueOf(v); + } + + public static long longValue(Map args, String name, boolean required) { + Object v = raw(args, name, required); + return v == null ? 0L : toLong(v, name); + } + + public static int intValue(Map args, String name, boolean required) { + long v = longValue(args, name, required); + if (v < Integer.MIN_VALUE || v > Integer.MAX_VALUE) { + throw new IllegalArgumentException("\"" + name + "\" is out of range"); + } + return (int) v; + } + + /// A short argument, refused outside the short range rather than narrowed: + /// a cast would turn 40000 into -25536 and run the tool with a number + /// nobody sent. + public static short shortValue(Map args, String name, boolean required) { + int v = intValue(args, name, required); + if (v < Short.MIN_VALUE || v > Short.MAX_VALUE) { + throw new IllegalArgumentException("\"" + name + "\" is out of range"); + } + return (short) v; + } + + /// A byte argument, refused outside the byte range for the same reason. + public static byte byteValue(Map args, String name, boolean required) { + int v = intValue(args, name, required); + if (v < Byte.MIN_VALUE || v > Byte.MAX_VALUE) { + throw new IllegalArgumentException("\"" + name + "\" is out of range"); + } + return (byte) v; + } + + public static double doubleValue(Map args, String name, boolean required) { + Object v = raw(args, name, required); + return v == null ? 0 : toDouble(v, name); + } + + public static boolean booleanValue(Map args, String name, boolean required) { + Object v = raw(args, name, required); + if (v == null) { + return false; + } + if (v instanceof Boolean) { + return ((Boolean) v).booleanValue(); + } + String s = String.valueOf(v); + if ("true".equalsIgnoreCase(s)) { + return true; + } + if ("false".equalsIgnoreCase(s)) { + return false; + } + throw new IllegalArgumentException("\"" + name + "\" must be true or false"); + } + + public static char charValue(Map args, String name, boolean required) { + String s = string(args, name, required); + if (s == null) { + return 0; + } + if (s.length() != 1) { + throw new IllegalArgumentException("\"" + name + "\" must be one character"); + } + return s.charAt(0); + } + + public static Integer integerObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : Integer.valueOf(intValue(args, name, required)); + } + + public static Long longObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : Long.valueOf(longValue(args, name, required)); + } + + public static Short shortObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : Short.valueOf(shortValue(args, name, required)); + } + + public static Byte byteObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : Byte.valueOf(byteValue(args, name, required)); + } + + public static Double doubleObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : Double.valueOf(doubleValue(args, name, required)); + } + + public static Float floatObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : Float.valueOf(floatValue(args, name, required)); + } + + /// A float argument, refused when it has no float value: a finite number + /// such as 1e100 would otherwise narrow to infinity, and the method would + /// run with a number nobody sent. + public static float floatValue(Map args, String name, boolean required) { + double d = doubleValue(args, name, required); + if (Double.isNaN(d) || Double.isInfinite(d) || Math.abs(d) > Float.MAX_VALUE) { + throw new IllegalArgumentException("\"" + name + "\" is out of range"); + } + float f = (float) d; + if (f == 0 && d != 0) { + // Below the smallest float: 1e-100 would arrive as 0, a different + // number from the one sent, like the overflow above. + throw new IllegalArgumentException("\"" + name + "\" is out of range"); + } + return f; + } + + public static Boolean booleanObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : Boolean.valueOf(booleanValue(args, name, required)); + } + + public static Character characterObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : Character.valueOf(charValue(args, name, required)); + } + + public static Map map(Map args, String name, boolean required) { + Object v = raw(args, name, required); + if (v == null || v instanceof Map) { + return (Map) v; + } + throw new IllegalArgumentException("\"" + name + "\" must be an object"); + } + + public static List list(Map args, String name, boolean required) { + Object v = raw(args, name, required); + if (v == null || v instanceof List) { + return (List) v; + } + throw new IllegalArgumentException("\"" + name + "\" must be an array"); + } + + private static String constantName(Object constant) { + return constant instanceof Enum ? ((Enum) constant).name() : String.valueOf(constant); + } + + /// The constant of `values` -- an enum's values() -- that the argument names. + public static Object enumValue(Object[] values, Map args, String name, boolean required) { + String s = string(args, name, required); + if (s == null) { + return null; + } + // By name(), which is what the generated schema advertises; toString() + // may be overridden into a display label no schema lists. + for (Object element : values) { + if (constantName(element).equals(s)) { + return element; + } + } + StringBuilder allowed = new StringBuilder(); + for (int iter = 0 ; iter < values.length ; iter++) { + allowed.append(iter == 0 ? "" : ", ").append(constantName(values[iter])); + } + throw new IllegalArgumentException("\"" + name + "\" must be one of " + allowed); + } + + /// A boxed number as a double, NaN for null. + public static double toDouble(Object value) { + return value instanceof Number ? ((Number) value).doubleValue() : Double.NaN; + } + + private static long toLong(Object v, String name) { + if (v instanceof Long || v instanceof Integer || v instanceof Short + || v instanceof Byte) { + return ((Number) v).longValue(); + } + if (v instanceof Number) { + double d = ((Number) v).doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d) || d != Math.floor(d)) { + throw new IllegalArgumentException("\"" + name + "\" must be a whole number"); + } + // longValue() saturates: 1e20 would become Long.MAX_VALUE and the tool + // would act on a number nobody sent. Both boundaries are refused: 2^63 + // is out of range, and a double of exactly -2^63 is as likely to be a + // rounded -9223372036854775809.0 as the minimum itself -- doubles are + // 2048 apart there. The exact minimum written as an integer arrives as + // a Long above and stays valid. + if (d <= -9.223372036854775808E18 || d >= 9.223372036854775808E18) { + throw new IllegalArgumentException("\"" + name + "\" is out of range"); + } + return (long) d; + } + try { + return Long.parseLong(String.valueOf(v).trim()); + } catch (NumberFormatException err) { + throw new IllegalArgumentException("\"" + name + "\" must be a whole number", err); + } + } + + private static double toDouble(Object v, String name) { + double d; + if (v instanceof Number) { + d = ((Number) v).doubleValue(); + } else { + try { + d = Double.parseDouble(String.valueOf(v).trim()); + } catch (NumberFormatException err) { + throw new IllegalArgumentException("\"" + name + "\" must be a number", err); + } + } + // The schema advertises a JSON number, and JSON has no NaN or infinity; + // parseDouble accepts the strings "NaN" and "Infinity", which would slip + // past every comparison the method makes against its own limits. + if (Double.isNaN(d) || Double.isInfinite(d)) { + throw new IllegalArgumentException("\"" + name + "\" must be a finite number"); + } + return d; + } +} diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java new file mode 100644 index 00000000000..4feeff8873d --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -0,0 +1,534 @@ +/* + * 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. + */ +package com.codename1.backend.mcp; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.codename1.backend.Backend; +import com.codename1.backend.Config; +import com.codename1.backend.Crypto; +import com.codename1.backend.HttpServer; +import com.codename1.backend.Json; + +/// The server's Model Context Protocol endpoint: JSON-RPC over MCP's Streamable +/// HTTP transport, at `cn1.mcp.path` (`/mcp` by default). +/// +/// It serves two kinds of tools. The application's own -- every +/// `@McpTool` method, registered by the generated entry point -- and, on a +/// development profile of a development build, the [DevTools]: the routes, +/// the beans, the database, the jobs and the metrics of the running server, so an +/// agent building the backend can inspect and exercise it. +/// +/// ```java +/// claude mcp add --transport http backend http://127.0.0.1:8080/mcp +/// ``` +/// +/// ## Security +/// +/// Outside a development profile the endpoint needs +/// `Authorization: Bearer ` and the server refuses to start +/// without a token, because a tool anyone can call is a vulnerability. On a +/// development profile the token is optional. Either way a request whose +/// `Origin` is not a loopback address or one listed in +/// `cn1.mcp.allowedOrigins` is refused -- including a page the server +/// serves itself, which has to be listed -- which is what the MCP +/// specification asks for against DNS rebinding: a web page in the developer's +/// browser must not be able to drive the server. +/// +/// The endpoint answers POSTed JSON-RPC with a JSON body. It keeps no session +/// and opens no event stream, so a GET is answered 405, as the transport allows. +public final class McpServer implements HttpServer.Handler { + public static final String ENABLED = "cn1.mcp.enabled"; + public static final String PATH = "cn1.mcp.path"; + public static final String TOKEN = "cn1.mcp.token"; + public static final String ALLOWED_ORIGINS = "cn1.mcp.allowedOrigins"; + public static final String DEV_TOOLS = "cn1.mcp.devTools"; + + /// The newest protocol revision this server speaks, and the ones it accepts. + static final String[] PROTOCOL_VERSIONS = {"2025-06-18", "2025-03-26", "2024-11-05"}; + + /// This endpoint's tools. Per server, never per process: a server started + /// again in the same process -- with its development tools off, or a + /// conditional @McpTool bean inactive -- must not keep serving the tools of + /// one that stopped, bound to beans that have been destroyed. + private final List tools = new ArrayList(); + + private final String path; + private final byte[] token; + private final String[] allowedOrigins; + private final String serverName; + + /// Extra tools installed when the server starts; the development tools are one. + public interface Extension { + /// Registers tools, given the running server. + void install(McpServer server, Backend backend); + } + + private final Extension devTools; + + private McpServer(String path, String token, String[] allowedOrigins, String serverName, + Extension devTools, List tools) { + if (tools != null) { + for (Object element : tools) { + register((McpTool) element); + } + } + this.path = path; + this.token = token == null || token.length() == 0 ? null : utf8(token); + this.allowedOrigins = allowedOrigins; + this.serverName = serverName; + this.devTools = devTools; + } + + /// The endpoint this configuration asks for, or null when it is off or there + /// would be no tool on it. + /// + /// #### Parameters + /// + /// - `devTools`: the development tools, which the build passes only in a + /// development build; they are installed only on a development profile + /// or with `cn1.mcp.devTools=true` + /// + /// - `tools`: the application's tools, the ones its server registered + public static McpServer fromConfig(Config config, Extension devTools, String serverName, + List tools) throws IOException { + boolean development = config.isDevelopmentProfile(); + Extension extension = devTools != null + && config.getBoolean(DEV_TOOLS, development) ? devTools : null; + boolean anyTools = tools != null && !tools.isEmpty(); + if (!config.getBoolean(ENABLED, anyTools || extension != null)) { + return null; + } + String token = config.getHeaderSecret(TOKEN); + if (!development && (token == null || token.length() == 0)) { + throw new IOException("The MCP endpoint is on outside a development profile and " + + TOKEN + " is not set, so anyone who can reach the port could call its " + + "tools. Set a token, or " + ENABLED + "=false."); + } + String path = config.getRoutePath(PATH, "/mcp"); + if (!path.startsWith("/")) { + throw new IOException(PATH + " must start with /"); + } + String origins = config.get(ALLOWED_ORIGINS, ""); + List list = new ArrayList(); + int start = 0; + while (start <= origins.length()) { + int comma = origins.indexOf(',', start); + if (comma < 0) { + comma = origins.length(); + } + String o = origins.substring(start, comma).trim(); + if (o.length() > 0) { + list.add(o); + } + start = comma + 1; + } + String[] allowed = new String[list.size()]; + for (int iter = 0 ; iter < allowed.length ; iter++) { + allowed[iter] = (String) list.get(iter); + } + return new McpServer(path, token, allowed, + serverName == null || serverName.length() == 0 ? "codenameone-backend" + : serverName, extension, tools); + } + + /// Adds a tool to this endpoint, replacing one of the same name. + public synchronized void register(McpTool tool) { + for (int iter = 0 ; iter < tools.size() ; iter++) { + if (((McpTool) tools.get(iter)).name().equals(tool.name())) { + tools.set(iter, tool); + return; + } + } + tools.add(tool); + } + + /// Every tool on this endpoint. + public synchronized List tools() { + return new ArrayList(tools); + } + + /// Whether the development tools are installed on this endpoint. + public boolean hasDevTools() { + return devTools != null; + } + + /// The path the endpoint answers on. + public String getPath() { + return path; + } + + /// Called once the server is listening. + public void attach(Backend running) { + if (devTools != null) { + devTools.install(this, running); + } + } + + @Override + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + // The CANONICAL path, as every generated route and the static files + // compare it: /%6dcp is the same URI as /mcp (RFC 3986 6.2.2), and + // matching the raw spelling let it fall through to a later handler. + if (request.getTarget() == null || !request.pathFrom(0).equals(path)) { + return null; + } + // CORS for an origin the configuration allows. A browser sends a JSON + // POST with a bearer token only after an OPTIONS preflight, which carries + // no token -- so it is answered here, before authentication, and every + // answer to that origin names it, or the browser withholds the response + // from the page. An origin that is not allowed gets neither, and the 403 + // below. + String origin = request.getHeader("origin"); + boolean cors = origin != null && origin.length() > 0 && originAllowed(request); + if (cors && "OPTIONS".equals(request.getMethod())) { + return request.respond(204, "text/plain", new byte[0]) + .header("Access-Control-Allow-Origin", origin) + .header("Vary", "Origin") + .header("Access-Control-Allow-Methods", "POST") + .header("Access-Control-Allow-Headers", + "authorization, content-type, mcp-protocol-version, mcp-session-id") + .header("Access-Control-Max-Age", "600"); + } + HttpServer.Response response = serve(request); + if (cors && response != null) { + response.header("Access-Control-Allow-Origin", origin).header("Vary", "Origin"); + } + return response; + } + + private HttpServer.Response serve(HttpServer.Request request) throws Exception { + if (!originAllowed(request)) { + return request.respondJson(403, rpcError(null, -32600, + "Origin not allowed; add it to " + ALLOWED_ORIGINS)); + } + if (!authorized(request)) { + return request.respondJson(401, rpcError(null, -32600, + "A bearer token is required")); + } + String method = request.getMethod(); + if ("GET".equals(method) || "HEAD".equals(method) || "DELETE".equals(method)) { + // No server-initiated stream and no session to end: the transport + // lets a server answer both with 405. + return request.respond(405, "text/plain; charset=utf-8", + utf8("POST JSON-RPC to this endpoint")); + } + if (!"POST".equals(method)) { + return request.respond(405, "text/plain; charset=utf-8", utf8("POST only")); + } + Object parsed; + try { + parsed = Json.parse(request.getBody()); + } catch (IOException err) { + return request.respondJson(400, rpcError(null, -32700, "Parse error")); + } + if (parsed instanceof List) { + List batch = (List) parsed; + if (batch.isEmpty()) { + // JSON-RPC: an empty batch is an invalid request, answered with a + // single error -- not the silent 202 an all-notification batch gets. + return request.respondJson(200, rpcError(null, -32600, "Invalid request")); + } + List answers = new ArrayList(); + for (Object element : batch) { + Object answer = dispatch(element); + if (answer != null) { + answers.add(answer); + } + } + return answers.isEmpty() ? request.respond(202, "application/json", new byte[0]) + : request.respondJson(200, answers); + } + Object answer = dispatch(parsed); + if (answer == null) { + return request.respond(202, "application/json", new byte[0]); + } + return request.respondJson(200, answer); + } + + /// One JSON-RPC message; the response, or null for a notification. + Object dispatch(Object message) { + if (!(message instanceof Map)) { + return rpcError(null, -32600, "Invalid request"); + } + Map m = (Map) message; + Object id = m.get("id"); + // By the key, not the value: an absent id is a notification, while an + // explicit null is a request JSON-RPC answers -- with null as its id. + // Reading the value alone left such a client waiting forever on a 202. + boolean notification = !m.containsKey("id"); + if (id != null && !(id instanceof String) && !(id instanceof Number)) { + // JSON-RPC ids are strings, numbers or null. An object, array or + // boolean id is refused before the method runs -- a tool call must not + // take effect for a request whose answer the client cannot match -- + // and the error carries null, the id of a request it could not read. + return rpcError(null, -32600, "Invalid request: id must be a string, a number " + + "or null"); + } + Object methodValue = m.get("method"); + if (!(methodValue instanceof String)) { + if (m.containsKey("result") || m.containsKey("error")) { + // A response to something the server sent; it sends nothing, so + // there is nothing to match it to and nothing to answer. + return null; + } + // No method is not a notification -- a notification is a VALID + // request without an id -- so it is answered, with a null id when it + // carried none. + return rpcError(notification ? null : id, -32600, "Invalid request"); + } + if (!"2.0".equals(m.get("jsonrpc"))) { + // A JSON-RPC 2.0 request says so exactly; one that does not -- no + // version, or a 1.0 envelope -- is refused before anything runs, a + // tool call included. + return rpcError(notification ? null : id, -32600, "Invalid request: jsonrpc " + + "must be \"2.0\""); + } + String method = (String) methodValue; + Object rawParams = m.get("params"); + if (rawParams != null && !(rawParams instanceof Map)) { + // Every method here takes named params. An array or a scalar is not + // read as "none": initialize with params 1 must not succeed with the + // defaults, nor a positional tool call run with no arguments. + return notification ? null : rpcError(id, -32602, "Invalid params: params must " + + "be an object"); + } + Map params = rawParams == null ? new LinkedHashMap() : (Map) rawParams; + // A notification is still an invocation -- an id-less tools/call runs its + // tool -- and only the answer, result or error, is withheld. + Object answer = invoke(method, params, id); + return notification ? null : answer; + } + + private Object invoke(String method, Map params, Object id) { + try { + if ("initialize".equals(method)) { + return result(id, initialize(params)); + } + if ("ping".equals(method)) { + return result(id, new LinkedHashMap()); + } + if ("tools/list".equals(method)) { + return result(id, listTools()); + } + if ("tools/call".equals(method)) { + return result(id, callTool(params)); + } + if ("resources/list".equals(method)) { + return result(id, single("resources", new ArrayList())); + } + if ("resources/templates/list".equals(method)) { + return result(id, single("resourceTemplates", new ArrayList())); + } + if ("prompts/list".equals(method)) { + return result(id, single("prompts", new ArrayList())); + } + return rpcError(id, -32601, "Method not found: " + method); + } catch (IllegalArgumentException err) { + return rpcError(id, -32602, err.getMessage()); + } catch (Exception err) { + return rpcError(id, -32603, String.valueOf(err)); + } + } + + private Map initialize(Map params) { + Object requested = params.get("protocolVersion"); + String version = PROTOCOL_VERSIONS[0]; + for (String element : PROTOCOL_VERSIONS) { + if (element.equals(requested)) { + version = element; + } + } + Map out = new LinkedHashMap(); + out.put("protocolVersion", version); + Map capabilities = new LinkedHashMap(); + Map tools = new LinkedHashMap(); + tools.put("listChanged", Boolean.FALSE); + capabilities.put("tools", tools); + capabilities.put("resources", new LinkedHashMap()); + capabilities.put("prompts", new LinkedHashMap()); + out.put("capabilities", capabilities); + Map info = new LinkedHashMap(); + info.put("name", serverName); + info.put("version", "1"); + out.put("serverInfo", info); + if (devTools != null) { + out.put("instructions", "A Codename One backend running in development. The " + + "backend_* tools inspect and exercise it: backend_routes and " + + "backend_beans show what the build wired, backend_call sends it a " + + "request, backend_sql reads its database, backend_requests shows what " + + "it served and what failed."); + } + return out; + } + + private Map listTools() { + List out = new ArrayList(); + List all = tools(); + for (Object element : all) { + McpTool tool = (McpTool) element; + Map t = new LinkedHashMap(); + t.put("name", tool.name()); + t.put("description", tool.description()); + t.put("inputSchema", tool.inputSchema()); + out.add(t); + } + return single("tools", out); + } + + private Map callTool(Map params) throws Exception { + Object name = params.get("name"); + if (!(name instanceof String)) { + throw new IllegalArgumentException("tools/call needs a tool name"); + } + Object rawArguments = params.get("arguments"); + if (rawArguments != null && !(rawArguments instanceof Map)) { + // Refused, not read as "no arguments": a tool that needs none, or has + // defaults, would otherwise run -- side effects and all -- for a call + // the client got wrong. + throw new IllegalArgumentException("tools/call arguments must be an object"); + } + Map arguments = rawArguments == null ? new LinkedHashMap() : (Map) rawArguments; + List all = tools(); + for (Object element : all) { + McpTool tool = (McpTool) element; + if (tool.name().equals(name)) { + Map out = new LinkedHashMap(); + List content = new ArrayList(); + Map text = new LinkedHashMap(); + text.put("type", "text"); + try { + Object value = tool.call(arguments); + text.put("text", value instanceof String ? (String) value + : Json.write(value)); + out.put("isError", Boolean.FALSE); + } catch (IllegalArgumentException err) { + // A tool that fails answers a RESULT marked as an error, not a + // protocol error: the agent is meant to read it and try again. + // A bad argument's message is written for the agent already. + text.put("text", err.getMessage()); + out.put("isError", Boolean.TRUE); + } catch (Exception err) { + text.put("text", String.valueOf(err)); + out.put("isError", Boolean.TRUE); + } + content.add(text); + out.put("content", content); + return out; + } + } + throw new IllegalArgumentException("No tool named " + name); + } + + private boolean originAllowed(HttpServer.Request request) { + String origin = request.getHeader("origin"); + if (origin == null || origin.length() == 0) { + // Not a browser: an agent's HTTP client sends none. + return true; + } + for (String element : allowedOrigins) { + if (element.equals(origin) || "*".equals(element)) { + return true; + } + } + // Loopback origins only, never "the Host this request names": under DNS + // rebinding a hostile page's origin and the Host header are BOTH the + // attacker's name (evil.example:8080), resolved to 127.0.0.1, so + // comparing them lets exactly the page this check exists to stop drive + // the server. A page the server itself serves must be listed. + String host = hostOf(origin); + return "localhost".equalsIgnoreCase(host) + || "127.0.0.1".equals(host) //NOPMD AvoidUsingHardCodedIP - recognises a loopback origin + || "[::1]".equals(host); + } + + private static String hostOf(String origin) { + int scheme = origin.indexOf("://"); + String rest = scheme < 0 ? origin : origin.substring(scheme + 3); + int slash = rest.indexOf('/'); + if (slash >= 0) { + rest = rest.substring(0, slash); + } + if (rest.startsWith("[")) { + int close = rest.indexOf(']'); + return close > 0 ? rest.substring(0, close + 1) : rest; + } + int colon = rest.lastIndexOf(':'); + return colon > 0 ? rest.substring(0, colon) : rest; + } + + /// Whether requests must present a bearer token. Without one -- a + /// development profile's default -- the server binds its listener to + /// loopback, since this endpoint reaches the database and every handler. + public boolean hasToken() { + return token != null; + } + + private boolean authorized(HttpServer.Request request) { + if (token == null) { + return true; + } + String header = request.getHeader("authorization"); + if (header == null || !header.regionMatches(true, 0, "Bearer ", 0, 7)) { + return false; + } + return Crypto.equalsConstantTime(token, utf8(header.substring(7).trim())); + } + + static Map result(Object id, Object value) { + Map out = new LinkedHashMap(); + out.put("jsonrpc", "2.0"); + out.put("id", id); + out.put("result", value); + return out; + } + + static Map rpcError(Object id, int code, String message) { + Map error = new LinkedHashMap(); + error.put("code", Integer.valueOf(code)); + error.put("message", message == null ? "error" : message); + Map out = new LinkedHashMap(); + out.put("jsonrpc", "2.0"); + out.put("id", id); + out.put("error", error); + return out; + } + + private static Map single(String key, Object value) { + Map out = new LinkedHashMap(); + out.put(key, value); + return out; + } + + static byte[] utf8(String s) { + try { + return s.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException("UTF-8 is required", err); + } + } +} diff --git a/vm/backend/src/com/codename1/backend/mcp/McpTool.java b/vm/backend/src/com/codename1/backend/mcp/McpTool.java new file mode 100644 index 00000000000..dd675f13075 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mcp/McpTool.java @@ -0,0 +1,51 @@ +/* + * 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. + */ +package com.codename1.backend.mcp; + +import java.util.Map; + +/// One tool on the server's MCP endpoint. +/// +/// An `@McpTool` method becomes one of these through a class the build +/// generates, whose schema is a constant and whose [#call] converts the +/// arguments and invokes the method directly. Write one by hand for a tool that is +/// not a bean method, and pass it to [McpServer#register]. +public interface McpTool { + /// The tool's name, unique on the server. + String name(); + + /// What it does, for the agent choosing between tools. + String description(); + + /// The JSON Schema of its arguments: an object schema, as a map. + Map inputSchema(); + + /// Runs the tool. The result is written with [com.codename1.backend.Json] + /// as the call's text content. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: for arguments the tool cannot use, which + /// the agent is told as a tool error it can correct + Object call(Map arguments) throws Exception; +} diff --git a/vm/backend/src/com/codename1/backend/mcp/package-info.java b/vm/backend/src/com/codename1/backend/mcp/package-info.java new file mode 100644 index 00000000000..10987559cba --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mcp/package-info.java @@ -0,0 +1,34 @@ +/* + * 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. + */ +/// A Model Context Protocol server inside the backend, so an agent can call a +/// running server's tools over HTTP. +/// +/// [McpServer] answers JSON-RPC at `cn1.mcp.path` (`/mcp` by default). An +/// application publishes its own tools by annotating bean methods with +/// `McpTool` from `com.codename1.backend.annotations`; the build generates their +/// schemas and a dispatcher, so nothing is looked up by reflection. [DevTools] +/// adds the development tools -- routes, beans, configuration, requests, SQL, +/// jobs and metrics -- and is installed only on a development profile. +/// +/// Server code: this package is not available in the app. +package com.codename1.backend.mcp; diff --git a/vm/backend/src/com/codename1/backend/metrics/Counter.java b/vm/backend/src/com/codename1/backend/metrics/Counter.java new file mode 100644 index 00000000000..c8d54ce8205 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/Counter.java @@ -0,0 +1,76 @@ +/* + * 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. + */ +package com.codename1.backend.metrics; + +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +/// A count that only goes up -- requests served, jobs run, errors seen. Exported +/// as a cumulative monotonic sum. +/// +/// Recording is one atomic add: no lock, no allocation. +public final class Counter extends Instrument { + private final AtomicLong total = new AtomicLong(); + + Counter(String name, String description, String unit, boolean upDown) { + super(name, description, unit, upDown ? UP_DOWN_COUNTER : COUNTER); + } + + /// Adds one. + public void increment() { + add(1); + } + + /// Adds `amount`. A counter refuses a negative amount, since a + /// cumulative monotonic sum that falls reads as a reset to every backend; an + /// up-down counter takes either sign. + public void add(long amount) { + if (amount < 0 && getKind() == COUNTER) { + throw new IllegalArgumentException("Counter " + getName() + + " only goes up; use an up-down counter"); + } + // Refused rather than wrapped: addAndGet past Long.MAX_VALUE turns a + // total that only ever rises into a large negative one, which both + // exports then report as the counter's value. + while (true) { + long current = total.get(); + long next = current + amount; + if (((current ^ next) & (amount ^ next)) < 0) { + throw new IllegalStateException("Counter " + getName() + + " would overflow a 64-bit total"); + } + if (total.compareAndSet(current, next)) { + return; + } + } + } + + public long get() { + return total.get(); + } + + @Override + public List points() { + return single(point(null, total.get())); + } +} diff --git a/vm/backend/src/com/codename1/backend/metrics/Gauge.java b/vm/backend/src/com/codename1/backend/metrics/Gauge.java new file mode 100644 index 00000000000..1495f45e3cc --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/Gauge.java @@ -0,0 +1,100 @@ +/* + * 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. + */ +package com.codename1.backend.metrics; + +import java.util.List; + +/// A value read when metrics are collected -- a queue's depth, a pool's size, a +/// `@ManagedAttribute`. Nothing is recorded in between, so a gauge costs +/// nothing until something asks. +public final class Gauge extends Instrument { + /// Where a gauge's value comes from. + public interface Source { + double read(); + } + + /// Where a gauge with several labelled values comes from -- one per executor, + /// say. Each point is a map with `attributes` and `value`; see + /// [#point]. + public interface MultiSource { + List read(); + } + + private final Source source; + private final MultiSource multi; + + Gauge(String name, String description, String unit, Source source) { + super(name, description, unit, GAUGE); + this.source = source; + this.multi = null; + } + + Gauge(String name, String description, String unit, MultiSource multi) { + super(name, description, unit, GAUGE); + this.source = null; + this.multi = multi; + } + + /// One labelled value, for a [MultiSource]. + public static java.util.Map point(String key, Object label, double value) { + if (key == null || key.length() == 0) { + // As a histogram refuses one: rendered as {="value"}, an empty name + // makes the whole exposition invalid, not just this gauge. + throw new IllegalArgumentException("A gauge point needs a label key"); + } + java.util.Map attributes = new java.util.LinkedHashMap(); + attributes.put(key, label); + return Instrument.point(attributes, value); + } + + /// The value now, or NaN when reading it failed. + /// + /// Application code runs here, on the exporter's thread and on the + /// management endpoint's, so ANY throwable is contained -- an AssertionError + /// or LinkageError escaping used to end the exporter's only thread, and every + /// later export with it. + public double read() { + if (source == null) { + return Double.NaN; + } + try { + return source.read(); + } catch (Throwable err) { + return Double.NaN; + } + } + + @Override + public List points() { + if (multi != null) { + try { + List points = multi.read(); + return points == null ? new java.util.ArrayList() : points; + } catch (Throwable err) { + // Contained like read(): the callback is the application's. + return new java.util.ArrayList(); + } + } + return single(point(null, read())); + } +} diff --git a/vm/backend/src/com/codename1/backend/metrics/Histogram.java b/vm/backend/src/com/codename1/backend/metrics/Histogram.java new file mode 100644 index 00000000000..5adfd719f56 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -0,0 +1,312 @@ +/* + * 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. + */ +package com.codename1.backend.metrics; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// A distribution of values -- durations, sizes -- kept as counts per bucket. +/// Exported as a cumulative explicit-bucket histogram. +/// +/// The buckets are fixed when the histogram is created; the default +/// boundaries are OpenTelemetry's, which suit milliseconds. +/// +/// A histogram created with label keys keeps a series per combination of +/// label values -- the server's request histogram has route, method and status. +/// The series are found by a map lookup on the first label, which is a constant +/// such as a route template, so nothing new is hashed; and they are capped, so a +/// label with unbounded values cannot grow the histogram without limit. +public final class Histogram extends Instrument { + /// OpenTelemetry's default explicit bucket boundaries. Private, and handed + /// out as copies: a histogram's boundaries must never change once values are + /// counted against them, or the exported counts describe other buckets. + private static final double[] DEFAULT_BOUNDS = {0, 5, 10, 25, 50, 75, 100, 250, 500, + 750, 1000, 2500, 5000, 7500, 10000}; + + /// A copy of OpenTelemetry's default explicit bucket boundaries. + public static double[] defaultBounds() { + return (double[]) DEFAULT_BOUNDS.clone(); + } + + /// Series beyond this many share one, marked as overflow. + static final int MAX_SERIES = 2000; + + private final double[] bounds; + private final String[] labels; + private final Series plain; + /// first label value -> List of Series. + private final Map byFirst = new HashMap(); + /// The byFirst key of a series whose first label value is null. + private static final Object NO_VALUE = new Object(); + private int seriesCount; + private Series overflow; + + Histogram(String name, String description, String unit, double[] bounds, + String[] labels) { + super(name, description, unit, HISTOGRAM); + // Copied, so the caller changing its array later cannot move the + // boundaries or rename the labels under counts already recorded. + this.bounds = bounds == null ? DEFAULT_BOUNDS : (double[]) bounds.clone(); + for (int iter = 0 ; iter < this.bounds.length ; iter++) { + double b = this.bounds[iter]; + if (Double.isNaN(b) || Double.isInfinite(b) + || (iter > 0 && b <= this.bounds[iter - 1])) { + throw new IllegalArgumentException("Histogram " + name + ": bucket " + + "boundaries must be finite and strictly ascending"); + } + } + this.labels = labels == null ? new String[0] : (String[]) labels.clone(); + java.util.Set exported = new java.util.HashSet(); + for (int iter = 0 ; iter < this.labels.length ; iter++) { + // Null is an unused slot of the three; an empty name is a mistake. + if (this.labels[iter] == null) { + continue; + } + if (this.labels[iter].length() == 0) { + throw new IllegalArgumentException("Histogram " + name + ": a label key " + + "is empty"); + } + // As Prometheus will see them: folded to its alphabet, and beside the + // "le" every bucket sample carries. Two that fold alike, or one that + // IS le, repeat a label name in a sample, and the scrape is rejected. + String folded = Metrics.promLabel(this.labels[iter]); + if ("otel.metric.overflow".equals(this.labels[iter]) + || OVERFLOW_LABEL.equals(folded)) { + // Reserved: the overflow series carries it, and a real series with + // the same key and value true would export as that one's twin. + throw new IllegalArgumentException("Histogram " + name + ": label key " + + this.labels[iter] + " is reserved for the overflow series"); + } + if ("le".equals(folded) || !exported.add(folded)) { + throw new IllegalArgumentException("Histogram " + name + ": label key " + + this.labels[iter] + " is exported to Prometheus as " + folded + + ", which " + ("le".equals(folded) ? "the bucket boundary uses" + : "another key of this histogram already is")); + } + } + this.plain = new Series(null, this.bounds.length + 1); + } + + /// Whether `other` has the same boundaries and label keys: two + /// registrations of one name must agree on both, or the values the second + /// records are read against the first's buckets and labels. + boolean sameShape(Histogram other) { + return java.util.Arrays.equals(bounds, other.bounds) + && java.util.Arrays.equals(labels, other.labels); + } + + /// The label keys this histogram was created with. + public String[] getLabelKeys() { + return (String[]) labels.clone(); + } + + /// Records one value in the series without labels. + public void record(double value) { + plain.record(value, bounds); + } + + /// Records one value in the series for these label values, given in the + /// order of the keys the histogram was created with. + public void record(double value, Object first, Object second, Object third) { + // Values past the label keys this histogram has are not exported, so they + // must not tell series apart either: one key recorded with two different + // second values made two series exported as one label set -- duplicate + // samples a scrape rejects. + Object a = labels.length > 0 && labels[0] != null ? canonical(first) : null; + Object b = labels.length > 1 && labels[1] != null ? canonical(second) : null; + Object c = labels.length > 2 && labels[2] != null ? canonical(third) : null; + if (a == null && b == null && c == null) { + // No label values at all is the unlabelled series: a second series + // with the same empty attribute set exports duplicate samples. + plain.record(value, bounds); + return; + } + Series s; + synchronized (this) { + s = find(a, b, c); + } + s.record(value, bounds); + } + + private Series find(Object first, Object second, Object third) { + // Null has a key of its own: exported, null omits the attribute and "" + // sends an empty one, so they are two series and must stay two. + // + // Told apart by their TEXT, which is what Prometheus prints: Boolean.TRUE + // and "true", or 1L and "1", are one label value there, and as two series + // they exported duplicate samples a scrape rejects. The series keeps the + // first value's type, so OTLP still gets a typed attribute. + Object key = first == null ? NO_VALUE : String.valueOf(first); + List list = (List) byFirst.get(key); + if (list != null) { + for (Object element : list) { + Series s = (Series) element; + if (sameText(s.values[1], second) && sameText(s.values[2], third)) { + return s; + } + } + } + if (seriesCount >= MAX_SERIES) { + if (overflow == null) { + overflow = new Series(null, bounds.length + 1); + } + return overflow; + } + Series created = new Series(new Object[] {first, second, third}, bounds.length + 1); + if (list == null) { + list = new ArrayList(4); + byFirst.put(key, list); + } + list.add(created); + seriesCount++; + return created; + } + + /// A label value in the one form it is exported in: an Integer 1 and a Long + /// 1 are the same attribute to a collector and the same label to Prometheus, + /// so they must be one series here too, or the export carries duplicates. + /// The overflow attribute's key as Prometheus writes it. + /// A literal rather than promLabel(...): computed in a static initializer it + /// would start Metrics' class initialization from Histogram's. + private static final String OVERFLOW_LABEL = "otel_metric_overflow"; + + private static Object canonical(Object v) { + if (v instanceof Integer || v instanceof Short || v instanceof Byte) { + return Long.valueOf(((Number) v).longValue()); + } + if (v instanceof Float) { + return Double.valueOf(((Float) v).doubleValue()); + } + return v; + } + + /// Whether two label values print alike: both null, or the same text. + private static boolean sameText(Object a, Object b) { + if (a == null || b == null) { + return a == b; //NOPMD CompareObjectsWithEquals - both null + } + return String.valueOf(a).equals(String.valueOf(b)); + } + + @Override + public List points() { + List out = new ArrayList(); + synchronized (this) { + // Under the lock find() adds labelled series with: decided outside it, + // a first labelled observation racing this read exported a zero + // unlabelled series beside the real one. + if (plain.count() > 0 || byFirst.isEmpty()) { + out.add(plain.point(bounds, null, false)); + } + java.util.Iterator lists = byFirst.values().iterator(); + while (lists.hasNext()) { + List list = (List) lists.next(); + for (Object element : list) { + out.add(((Series) element).point(bounds, labels, false)); + } + } + if (overflow != null) { + out.add(overflow.point(bounds, null, true)); + } + } + return out; + } + + /// One set of bucket counts. + static final class Series { + final Object[] values; + private final long[] buckets; + private long count; + private double sum; + private double min = Double.NaN; + private double max = Double.NaN; + + Series(Object[] values, int bucketCount) { + this.values = values == null ? new Object[3] : values; + this.buckets = new long[bucketCount]; + } + + synchronized void record(double value, double[] bounds) { + if (Double.isNaN(value)) { + return; + } + int bucket = bounds.length; + for (int iter = 0 ; iter < bounds.length ; iter++) { + if (value <= bounds[iter]) { + bucket = iter; + break; + } + } + buckets[bucket]++; + count++; + sum += value; + if (count == 1 || value < min) { + min = value; + } + if (count == 1 || value > max) { + max = value; + } + } + + synchronized long count() { + return count; + } + + synchronized Map point(double[] bounds, String[] keys, boolean isOverflow) { + Map p = new LinkedHashMap(); + Map attributes = new LinkedHashMap(); + if (isOverflow) { + attributes.put("otel.metric.overflow", Boolean.TRUE); + } else if (keys != null) { + for (int iter = 0 ; iter < keys.length && iter < values.length ; iter++) { + Object v = values[iter]; + if (v != null) { + attributes.put(keys[iter], v instanceof Integer + ? Long.valueOf(((Integer) v).longValue()) : v); + } + } + } + p.put("attributes", attributes); + p.put("count", Long.valueOf(count)); + p.put("sum", Double.valueOf(sum)); + if (count > 0) { + p.put("min", Double.valueOf(min)); + p.put("max", Double.valueOf(max)); + } + List b = new ArrayList(bounds.length); + for (double element : bounds) { + b.add(Double.valueOf(element)); + } + p.put("bounds", b); + List c = new ArrayList(buckets.length); + for (long element : buckets) { + c.add(Long.valueOf(element)); + } + p.put("buckets", c); + return p; + } + } +} diff --git a/vm/backend/src/com/codename1/backend/metrics/Instrument.java b/vm/backend/src/com/codename1/backend/metrics/Instrument.java new file mode 100644 index 00000000000..44ae28a63be --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/Instrument.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * 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.backend.metrics; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// One named measurement: a counter, a gauge or a histogram. +/// +/// Created once through [Metrics] and kept -- the build's generated code +/// holds each in a static field -- so recording a value never looks anything up. +public abstract class Instrument { + public static final int COUNTER = 0; + public static final int UP_DOWN_COUNTER = 1; + public static final int GAUGE = 2; + public static final int HISTOGRAM = 3; + + private final String name; + private final String description; + private final String unit; + private final int kind; + + Instrument(String name, String description, String unit, int kind) { + this.name = name; + this.description = description == null ? "" : description; + this.unit = unit == null ? "" : unit; + this.kind = kind; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getUnit() { + return unit; + } + + /// [#COUNTER], [#UP_DOWN_COUNTER], [#GAUGE] or [#HISTOGRAM]. + public int getKind() { + return kind; + } + + /// The current points, each a map with `attributes` (a map, possibly + /// empty) and either `value` or, for a histogram, `count`, + /// `sum`, `min`, `max`, `bounds` and `buckets`. + public abstract List points(); + + /// A point with an integral value, kept as a Long: a counter past 2^53 is not + /// exactly representable as a double, and the exporter sends a Long as + /// OTLP's integer field rather than rounding it. + static Map point(Map attributes, long value) { + Map p = new LinkedHashMap(); + p.put("attributes", attributes == null ? new LinkedHashMap() : attributes); + p.put("value", Long.valueOf(value)); + return p; + } + + static Map point(Map attributes, double value) { + Map p = new LinkedHashMap(); + p.put("attributes", attributes == null ? new LinkedHashMap() : attributes); + p.put("value", Double.valueOf(value)); + return p; + } + + static List single(Map point) { + List out = new ArrayList(1); + out.add(point); + return out; + } +} diff --git a/vm/backend/src/com/codename1/backend/metrics/MetricReader.java b/vm/backend/src/com/codename1/backend/metrics/MetricReader.java new file mode 100644 index 00000000000..e02d69f30c6 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/MetricReader.java @@ -0,0 +1,42 @@ +/* + * 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. + */ +package com.codename1.backend.metrics; + +import java.io.IOException; + +import com.codename1.backend.Config; + +/// Something that reads [Metrics] periodically and sends them somewhere -- +/// the OTLP exporter. Installed by the generated entry point of a build that +/// enables OpenTelemetry, and referenced nowhere else, so a build that does not +/// leaves the exporter out of the binary. +public interface MetricReader { + /// Reads the configuration and starts. Answers false when the deployment has + /// metrics turned off, in which case nothing is started; one that throws + /// must leave nothing started either, and changes nothing it was already + /// doing -- the server does not shut down a reader whose open() failed. + boolean open(Config config) throws IOException; + + /// Sends what is left and stops, waiting up to `timeoutMillis`. + void shutdown(int timeoutMillis); +} diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java new file mode 100644 index 00000000000..b422df4c671 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -0,0 +1,748 @@ +/* + * 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. + */ +package com.codename1.backend.metrics; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The server's metrics: every [Instrument] by name, and the ones the +/// server records about itself. +/// +/// ```java +/// private static final Counter ORDERS = Metrics.counter("orders.placed", +/// "Orders accepted", "{order}"); +/// ... +/// ORDERS.increment(); +/// ``` +/// +/// Asking for a name twice answers the same instrument, so a static field +/// initialized in two classes, or a server restarted in one process, shares it. +/// Asking for an existing name as a different kind is refused. +/// +/// What reads them: the OTLP exporter, when the build enables OpenTelemetry; +/// the management endpoint's `metrics` and `prometheus` views; and +/// the development MCP server. None of them is linked into a server whose build +/// does not ask for it, and an instrument nothing reads costs its own +/// recording and nothing more. +/// +/// ## What the server records +/// +/// Once [#enableServer] has run -- the generated entry point calls it when +/// metrics are on -- every request is measured in +/// `http.server.request.duration` (milliseconds, by route template, method +/// and status), and the server's counters, the database pool, the task executors +/// and the process's memory are published as gauges. +public final class Metrics { + private static final Map INSTRUMENTS = new LinkedHashMap(); + private static final long STARTED = System.currentTimeMillis(); + + /// Whether the server records its own requests. Plain: see [#enableServer]. + static boolean serverEnabled; + private static Histogram requestDuration; + private static Histogram jobDuration; + private static final ThreadLocal ROUTE = new ThreadLocal(); + + private Metrics() { + } + + /// A counter; the same one for the same name. + public static Counter counter(String name, String description, String unit) { + return (Counter) register(name, Instrument.COUNTER, description, unit, null, null); + } + + /// A counter that can go down too -- items in a queue, open sessions. + public static Counter upDownCounter(String name, String description, String unit) { + return (Counter) register(name, Instrument.UP_DOWN_COUNTER, description, unit, null, + null); + } + + /// A histogram with the default bucket boundaries, which suit milliseconds. + public static Histogram histogram(String name, String description, String unit) { + return histogram(name, description, unit, null, null); + } + + /// A histogram with the given bucket boundaries, ascending, and label keys (up + /// to three), or null for either default. + public static Histogram histogram(String name, String description, String unit, + double[] bounds, String[] labels) { + if (labels != null && labels.length > 3) { + throw new IllegalArgumentException("A histogram takes at most three labels"); + } + return (Histogram) register(name, Instrument.HISTOGRAM, description, unit, bounds, + labels); + } + + /// A gauge read from `source` when metrics are collected. A gauge + /// registered again under the same name REPLACES the old one, since its + /// source is usually an object that has been replaced too -- a restarted + /// server's pool. + public static Gauge gauge(String name, String description, String unit, + Gauge.Source source) { + Gauge g = new Gauge(name, description, unit, source); + replaceGauge(g, false); + return g; + } + + /// A gauge with several labelled values. See [Gauge.MultiSource]. + public static Gauge gauge(String name, String description, String unit, + Gauge.MultiSource source) { + Gauge g = new Gauge(name, description, unit, source); + replaceGauge(g, false); + return g; + } + + /// Registers `g`, replacing a gauge of the same name. `shared` is true + /// only for addSource()'s aggregate. + private static synchronized void replaceGauge(Gauge g, boolean shared) { + if (g.getName() == null || g.getName().length() == 0) { + // As counters and histograms refuse it: a nameless gauge renders as a + // blank Prometheus name, and the scraper rejects the whole exposition. + throw new IllegalArgumentException("A metric needs a name"); + } + Instrument existing = (Instrument) INSTRUMENTS.get(g.getName()); + if (existing != null && existing.getKind() != Instrument.GAUGE) { + throw new IllegalArgumentException("Metric " + g.getName() + + " already exists as another kind"); + } + claimPrometheusNames(g.getName(), Instrument.GAUGE); + INSTRUMENTS.put(g.getName(), g); + if (!shared) { + // An application's gauge replacing one addSource() built: the sources + // behind the old one are no longer read, and their registration goes + // with it. Left behind, the last server to remove its source deleted + // THIS gauge by name, and it vanished from management and exports. + SHARED.remove(g.getName()); + } + } + + /// Prometheus series name -> the instrument name that renders it. + private static final Map PROMETHEUS_NAMES = new HashMap(); + + /// The Prometheus view folds every character a metric name cannot hold to + /// `_` and adds suffixes, so distinct instruments -- orders.total and + /// orders_total, or a counter orders beside a gauge orders_total -- would + /// render as one series, which a scrape rejects or silently merges. Refused + /// when the instrument is created instead. + private static void claimPrometheusNames(String name, int kind) { + String base = promName(name); + String[] series = kind == Instrument.COUNTER ? new String[] {base + "_total"} + : kind == Instrument.HISTOGRAM ? new String[] {base, base + "_bucket", + base + "_sum", base + "_count"} + : new String[] {base}; + for (String element : series) { + Object owner = PROMETHEUS_NAMES.get(element); + if (owner != null && !owner.equals(name)) { + throw new IllegalArgumentException("Metric " + name + " would be exported to " + + "Prometheus as " + element + ", which metric " + owner + + " already is; rename one"); + } + } + for (String element : series) { + PROMETHEUS_NAMES.put(element, name); + } + } + + private static void releasePrometheusNames(String name) { + Iterator it = PROMETHEUS_NAMES.values().iterator(); + while (it.hasNext()) { + if (name.equals(it.next())) { + it.remove(); + } + } + } + + private static synchronized Instrument register(String name, int kind, String description, + String unit, double[] bounds, + String[] labels) { + if (name == null || name.length() == 0) { + throw new IllegalArgumentException("A metric needs a name"); + } + Instrument existing = (Instrument) INSTRUMENTS.get(name); + if (existing != null) { + if (existing.getKind() != kind) { + throw new IllegalArgumentException("Metric " + name + + " already exists as another kind"); + } + checkSameUnit(existing, unit); + if (kind == Instrument.HISTOGRAM && !((Histogram) existing).sameShape( + new Histogram(name, description, unit, bounds, labels))) { + // Shared by name like every instrument, but only when the shape + // matches: the server's own http.server.request.duration reused + // with an application's labels or buckets would record its route + // values against the wrong keys, and export series nobody asked for. + throw new IllegalArgumentException("Histogram " + name + " already exists " + + "with other bucket boundaries or label keys; give this one " + + "another name"); + } + return existing; + } + Instrument created; + if (kind == Instrument.HISTOGRAM) { + created = new Histogram(name, description, unit, bounds, labels); + } else { + created = new Counter(name, description, unit, kind == Instrument.UP_DOWN_COUNTER); + } + // After construction, so a histogram refusing its bounds claims nothing. + claimPrometheusNames(name, kind); + INSTRUMENTS.put(name, created); + return created; + } + + /// Names already warned about by [#checkSameUnit], so a registration made + /// on every call warns once. + private static final java.util.Set UNIT_WARNED = new java.util.HashSet(); + + /// Warns, once per name, when `existing` is registered again in another + /// unit. Not refused, as Spring Boot's Micrometer does not refuse it -- a + /// meter is its name and tags, and the first registration's unit wins -- + /// and as the OpenTelemetry SDK only warns about a duplicate registration. + /// But not silent either: the two would add seconds to milliseconds in one + /// stream exported under the first unit. An empty unit is a lookup by name + /// and never conflicts. + private static void checkSameUnit(Instrument existing, String unit) { + if (existing == null || unit == null || unit.length() == 0 + || existing.getUnit().equals(unit)) { + return; + } + if (UNIT_WARNED.add(existing.getName())) { + System.err.println("cn1: metric " + existing.getName() + " is registered again " + + "with unit \"" + unit + "\", but it already exists with unit \"" + + existing.getUnit() + "\"; the values are combined and exported in the " + + "first unit. Give one of them another name."); + } + } + + /// Every instrument, in registration order. + public static synchronized List instruments() { + return new ArrayList(INSTRUMENTS.values()); + } + + /// The instrument called `name`, or null. + public static synchronized Instrument get(String name) { + return (Instrument) INSTRUMENTS.get(name); + } + + /// When this process's metrics started counting, for cumulative points. + public static long startTimeMillis() { + return STARTED; + } + + // ------------------------------------------------------- server instrumentation + + /// Gauges several servers contribute to: name -> List of Gauge.Source. + private static final Map SHARED = new HashMap(); + /// Shared-gauge source reads in progress, by source, under Metrics.class. A + /// removal waits for ITS source's: the server removing it destroys the bean + /// behind it next. Per source, so one gauge stuck in its callback delays only + /// its own removal, not every other gauge a stopping server removes. + private static final Map SOURCE_READS = new HashMap(); + + /// Adds one server's source to the gauge called `name`, which reports + /// the sum of every source still registered. A server's managed-resource + /// gauges go through here and are removed when it stops, so a stopped + /// server's bean is never read again and a second live server adds to the + /// gauge instead of silently replacing the first one's. + public static synchronized void addSource(String name, String description, String unit, + Gauge.Source source) { + List sources = (List) SHARED.get(name); + if (sources != null) { + // Summed with the sources already there, so it must measure the same. + checkSameUnit((Instrument) INSTRUMENTS.get(name), unit); + } + if (sources == null) { + final List all = new ArrayList(); + sources = all; + // Registered FIRST: a gauge refused here -- another kind under the + // name, a Prometheus clash -- must leave no entry behind, or a retry + // would find it and skip registering the gauge at all. + replaceGauge(new Gauge(name, description, unit, new Gauge.Source() { + @Override + public double read() { + Object[] each; + synchronized (Metrics.class) { + each = all.toArray(); + } + double sum = 0; + boolean any = false; + for (Object element : each) { + // Still registered, checked and counted under the lock + // removeSource() takes: the copy above may hold a source + // whose server has since stopped and is tearing it down. + if (!beginSourceRead(all, element)) { + continue; + } + try { + double v = ((Gauge.Source) element).read(); + if (!Double.isNaN(v)) { + sum += v; + any = true; + } + } catch (Throwable err) { + // One source failing leaves the others' values. + } finally { + endSourceRead(element); + } + } + return any ? sum : Double.NaN; + } + }), true); + SHARED.put(name, all); + } + sources.add(source); + } + + /// Removes a source [#addSource] added; the gauge goes with its last one. + public static synchronized void removeSource(String name, Gauge.Source source) { + List sources = (List) SHARED.get(name); + if (sources == null) { + return; + } + sources.remove(source); + if (sources.isEmpty()) { + SHARED.remove(name); + INSTRUMENTS.remove(name); + releasePrometheusNames(name); + } + // Reads that began before the removal finish first -- bounded, so a gauge + // stuck in its callback cannot hold a shutdown -- because the caller + // destroys the bean behind the source next. New reads skip it already. + long deadline = System.currentTimeMillis() + SOURCE_READ_WAIT_MILLIS; + while (SOURCE_READS.containsKey(source)) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + break; + } + try { + Metrics.class.wait(left); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + break; + } + } + } + + /// Counts a read of `source` in, unless it has been removed from `all`. + private static synchronized boolean beginSourceRead(List all, Object source) { + if (!all.contains(source)) { + return false; + } + int[] reads = (int[]) SOURCE_READS.get(source); + if (reads == null) { + reads = new int[1]; + SOURCE_READS.put(source, reads); + } + reads[0]++; + return true; + } + + private static synchronized void endSourceRead(Object source) { + int[] reads = (int[]) SOURCE_READS.get(source); + if (reads != null) { + reads[0]--; + if (reads[0] <= 0) { + SOURCE_READS.remove(source); + } + } + Metrics.class.notifyAll(); + } + + /// How long [#removeSource] waits for shared-gauge reads already running. + private static final long SOURCE_READ_WAIT_MILLIS = 2000; + + /// The servers recording their own metrics, and their pools. + private static final List LIVE_SERVERS = new ArrayList(); + private static final List LIVE_POOLS = new ArrayList(); + + /// Starts recording a server's own metrics. Called once the server is + /// listening; the pool may be null. + /// + /// The instruments are the process's, as an OpenTelemetry meter's are, so + /// with two servers in one process each built-in gauge reports the SUM over + /// the servers still running, and the request histogram counts both. Each + /// server used to register its own source under the same name, so the last + /// one started silently replaced the others' -- and a stopped server's stayed. + public static void enableServer(final com.codename1.backend.HttpServer server, + final com.codename1.backend.DataSource pool) { + synchronized (Metrics.class) { + if (!LIVE_SERVERS.contains(server)) { + LIVE_SERVERS.add(server); + } + if (pool != null && !LIVE_POOLS.contains(pool)) { + LIVE_POOLS.add(pool); + } + } + requestDuration = histogram("http.server.request.duration", + "Duration of HTTP server requests", "ms", null, + new String[] {"http.route", "http.request.method", "http.response.status_code"}); + jobDuration = histogram("cn1.scheduler.run.duration", + "Duration of scheduled job runs", "ms", null, + new String[] {"cn1.job", "cn1.outcome", null}); + serverMetric("http.server.active_requests", "activeRequests", + "Requests being served", "{request}"); + serverMetric("http.server.open_connections", "openConnections", + "Open client connections", "{connection}"); + serverMetric("cn1.server.websocket_connections", "webSocketConnections", + "Open websocket connections", "{connection}"); + serverMetric("cn1.server.requests_served", "requestsServed", + "Requests answered since start", "{request}"); + serverMetric("cn1.server.connections_refused", "connectionsRefused", + "Connections refused for being over the limit", "{connection}"); + gauge("db.client.connection.count", "Open database connections", "{connection}", + new Gauge.Source() { + @Override + public double read() { + return poolSum(false); + } + }); + gauge("db.client.connection.idle", "Idle database connections", "{connection}", + new Gauge.Source() { + @Override + public double read() { + return poolSum(true); + } + }); + gauge("cn1.task.queue_depth", "Tasks waiting for a thread, by executor", "{task}", + new Gauge.MultiSource() { + @Override + public List read() { + // Summed by name: two servers in the process each have a + // "default" executor, and two points with one label set are one + // series twice -- duplicate samples a scrape rejects. + Map byName = new LinkedHashMap(); + // Only the servers that measure: one with metrics off keeps + // its queues out of the others' telemetry, as its + // requests and jobs are. + List all = com.codename1.backend.Tasks.executorsOf(liveServers()); + for (Object element : all) { + com.codename1.backend.TaskExecutor e = + (com.codename1.backend.TaskExecutor) element; + Long sum = (Long) byName.get(e.getName()); + byName.put(e.getName(), Long.valueOf((sum == null ? 0 : sum.longValue()) + + e.getQueueDepth())); + } + List out = new ArrayList(); + Iterator names = byName.entrySet().iterator(); + while (names.hasNext()) { + Map.Entry entry = (Map.Entry) names.next(); + out.add(Gauge.point("cn1.executor", (String) entry.getKey(), + ((Long) entry.getValue()).longValue())); + } + return out; + } + }); + gauge("process.runtime.memory.used", "Heap in use", "By", new Gauge.Source() { + @Override + public double read() { + Runtime r = Runtime.getRuntime(); + return r.totalMemory() - r.freeMemory(); + } + }); + gauge("process.uptime", "Seconds since the process started", "s", new Gauge.Source() { + @Override + public double read() { + return (System.currentTimeMillis() - STARTED) / 1000.0; + } + }); + synchronized (Metrics.class) { + serverEnabled = true; + } + } + + /// A server has stopped: its gauges stop counting it, and once none is left + /// the server instruments stop recording. + public static void disableServer(com.codename1.backend.HttpServer server, + com.codename1.backend.DataSource pool) { + synchronized (Metrics.class) { + LIVE_SERVERS.remove(server); + if (pool != null) { + LIVE_POOLS.remove(pool); + } + if (LIVE_SERVERS.isEmpty()) { + serverEnabled = false; + } + } + } + + private static synchronized List liveServers() { + return new ArrayList(LIVE_SERVERS); + } + + private static double poolSum(boolean idle) { + List pools; + synchronized (Metrics.class) { + pools = new ArrayList(LIVE_POOLS); + } + double sum = 0; + for (Object element : pools) { + com.codename1.backend.DataSource p = (com.codename1.backend.DataSource) element; + sum += idle ? p.getIdleCount() : p.getOpenCount(); + } + return sum; + } + + private static void serverMetric(String name, final String key, String description, + String unit) { + gauge(name, description, unit, new Gauge.Source() { + @Override + public double read() { + List servers = liveServers(); + double sum = 0; + for (Object element : servers) { + Object v = ((com.codename1.backend.HttpServer) element) + .getMetrics().get(key); + if (v instanceof Number) { + sum += ((Number) v).doubleValue(); + } + } + return sum; + } + }); + } + + /// Records which route template matched, for the request histogram. + public static void route(String template) { + if (serverEnabled) { + ROUTE.set(template); + } + } + + /// A request is starting; answers the time to hand to [#requestEnded]. + public static long requestStarted() { + return serverEnabled ? System.nanoTime() : 0L; + } + + /// A request has been answered. + public static void requestEnded(long started, String method, int status) { + // The route is cleared on every path: a server that does not measure + // still has its routers call route(), and a value left behind would be + // recorded under the next request this thread serves. + Object route = ROUTE.get(); + if (route != null) { + ROUTE.set(null); + } + if (!serverEnabled || started == 0L) { + return; + } + requestDuration.record((System.nanoTime() - started) / 1000000.0, route, method, + Integer.valueOf(status)); + } + + /// A scheduled job has run. + public static void jobRan(String job, long millis, boolean failed) { + if (serverEnabled) { + jobDuration.record(millis, job, failed ? "failure" : "success", null); + } + } + + // ------------------------------------------------------------------ views + + /// Every instrument and its points, for the management endpoint and MCP: + /// `name -> {kind, description, unit, points`}. + public static Map snapshot() { + Map out = new LinkedHashMap(); + List all = instruments(); + for (Object element : all) { + Instrument i = (Instrument) element; + Map m = new LinkedHashMap(); + m.put("kind", kindName(i.getKind())); + if (i.getDescription().length() > 0) { + m.put("description", i.getDescription()); + } + if (i.getUnit().length() > 0) { + m.put("unit", i.getUnit()); + } + m.put("points", i.points()); + out.put(i.getName(), m); + } + return out; + } + + static String kindName(int kind) { + switch (kind) { + case Instrument.COUNTER: return "counter"; + case Instrument.UP_DOWN_COUNTER: return "upDownCounter"; + case Instrument.GAUGE: return "gauge"; + default: return "histogram"; + } + } + + /// Every instrument in the Prometheus text exposition format. + public static String prometheus() { + StringBuilder sb = new StringBuilder(); + List all = instruments(); + for (Object entry : all) { + Instrument i = (Instrument) entry; + String name = promName(i.getName()); + String type; + switch (i.getKind()) { + case Instrument.COUNTER: + type = "counter"; + name = name + "_total"; + break; + case Instrument.HISTOGRAM: + type = "histogram"; + break; + default: + type = "gauge"; + } + if (i.getDescription().length() > 0) { + sb.append("# HELP ").append(name).append(' ') + .append(escapeHelp(i.getDescription())).append('\n'); + } + sb.append("# TYPE ").append(name).append(' ').append(type).append('\n'); + List points = i.points(); + for (Object element : points) { + Map point = (Map) element; + Map attributes = (Map) point.get("attributes"); + if (i.getKind() == Instrument.HISTOGRAM) { + List bounds = (List) point.get("bounds"); + List buckets = (List) point.get("buckets"); + long cumulative = 0; + for (int b = 0 ; b < buckets.size() ; b++) { + cumulative += ((Number) buckets.get(b)).longValue(); + String le = b < bounds.size() + ? number(((Number) bounds.get(b)).doubleValue()) : "+Inf"; + sb.append(name).append("_bucket"); + labels(sb, attributes, le); + sb.append(' ').append(cumulative).append('\n'); + } + sb.append(name).append("_sum"); + labels(sb, attributes, null); + sb.append(' ').append(number(((Number) point.get("sum")).doubleValue())) + .append('\n'); + sb.append(name).append("_count"); + labels(sb, attributes, null); + sb.append(' ').append(point.get("count")).append('\n'); + } else { + sb.append(name); + labels(sb, attributes, null); + Object value = point.get("value"); + // A counter's Long exactly, not through a double. + sb.append(' ').append(value instanceof Long ? String.valueOf(value) + : number(((Number) value).doubleValue())).append('\n'); + } + } + } + return sb.toString(); + } + + private static void labels(StringBuilder sb, Map attributes, String le) { + boolean any = (attributes != null && !attributes.isEmpty()) || le != null; + if (!any) { + return; + } + sb.append('{'); + boolean first = true; + if (attributes != null) { + Iterator it = attributes.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + if (!first) { + sb.append(','); + } + first = false; + sb.append(promLabel(String.valueOf(e.getKey()))).append("=\"") + .append(escapeLabel(String.valueOf(e.getValue()))).append('"'); + } + } + if (le != null) { + if (!first) { + sb.append(','); + } + sb.append("le=\"").append(le).append('"'); + } + sb.append('}'); + } + + static String promName(String name) { + StringBuilder sb = new StringBuilder(name.length()); + for (int iter = 0 ; iter < name.length() ; iter++) { + char c = name.charAt(iter); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' + || c == ':' || (iter > 0 && c >= '0' && c <= '9'); + sb.append(ok ? c : '_'); + } + return sb.toString(); + } + + /// A label name in Prometheus's alphabet, which is the metric-name alphabet + /// WITHOUT the colon: `tenant:id` written as a label is a syntax error the + /// scraper rejects the whole exposition for. + static String promLabel(String name) { + StringBuilder sb = new StringBuilder(name.length()); + for (int iter = 0 ; iter < name.length() ; iter++) { + char c = name.charAt(iter); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' + || (iter > 0 && c >= '0' && c <= '9'); + sb.append(ok ? c : '_'); + } + return sb.toString(); + } + + private static String escapeLabel(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int iter = 0 ; iter < value.length() ; iter++) { + char c = value.charAt(iter); + if (c == '\\' || c == '"') { + sb.append('\\').append(c); + } else if (c == '\n') { + sb.append("\\n"); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static String escapeHelp(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int iter = 0 ; iter < value.length() ; iter++) { + char c = value.charAt(iter); + if (c == '\\') { + sb.append("\\\\"); + } else if (c == '\n') { + sb.append("\\n"); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static String number(double d) { + if (Double.isNaN(d)) { + return "NaN"; + } + if (Double.isInfinite(d)) { + return d > 0 ? "+Inf" : "-Inf"; + } + if (d == Math.floor(d) && Math.abs(d) < 1e15) { + return Long.toString((long) d); + } + return Double.toString(d); + } +} diff --git a/vm/backend/src/com/codename1/backend/metrics/package-info.java b/vm/backend/src/com/codename1/backend/metrics/package-info.java new file mode 100644 index 00000000000..e652db03980 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/package-info.java @@ -0,0 +1,35 @@ +/* + * 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. + */ +/// Metrics for a backend: counters, gauges and histograms named after the +/// OpenTelemetry semantic conventions. +/// +/// [Metrics] holds every instrument in the process. The server records its own +/// -- request durations, open connections, database pool use, scheduled runs -- +/// and an application adds more through the `Timed`, `Counted` and +/// `ManagedResource` annotations, or by creating instruments directly. A +/// [MetricReader] takes them from there: the OTLP exporter in +/// `com.codename1.backend.otel`, or the management endpoints' JSON and +/// Prometheus text. +/// +/// Server code: this package is not available in the app. +package com.codename1.backend.metrics; diff --git a/vm/backend/src/com/codename1/backend/orm/EntityManager.java b/vm/backend/src/com/codename1/backend/orm/EntityManager.java index ed61f8d8089..7ddf928b692 100644 --- a/vm/backend/src/com/codename1/backend/orm/EntityManager.java +++ b/vm/backend/src/com/codename1/backend/orm/EntityManager.java @@ -255,8 +255,9 @@ public Object transaction(final Work body) throws Exception { if (transactionScoped) { // Already inside one. Every engine refuses a nested BEGIN, and a // service method that works alone should not break when another one - // calls it. - return body.run(this); + // calls it. A failure still dooms the thread's transaction, as it + // would roll this back alone. + return joinedRun(body, this); } if (pinned != null) { // Pinned, but by the CALLER rather than by a transaction: this is @@ -265,9 +266,30 @@ public Object transaction(final Work body) throws Exception { // BEGIN left the writes before a failure committed. return pinned.transaction(new ScopedWork(body, dialect, tables)); } + Database joined = com.codename1.backend.Transactions.joined(pool); + if (joined != null) { + // Inside a @Transactional method: join its transaction, as a manager + // already inside one does above. + return joinedRun(body, new EntityManager(null, joined, dialect, tables, true)); + } return pool.withConnection(new InTransaction(new ScopedWork(body, dialect, tables))); } + /// Runs `body` as part of the thread's transaction; if it throws, that + /// transaction can no longer commit -- a caller catching the exception would + /// otherwise commit what the body wrote before failing. + private static Object joinedRun(Work body, EntityManager manager) throws Exception { + try { + return body.run(manager); + } catch (Exception err) { + com.codename1.backend.Transactions.markRollbackOnly(); + throw err; + } catch (Error err) { + com.codename1.backend.Transactions.markRollbackOnly(); + throw err; + } + } + /// Opens a transaction on a borrowed connection and runs the work inside it. private static final class InTransaction implements DataSource.Work { private final ScopedWork scoped; diff --git a/vm/backend/src/com/codename1/backend/orm/TransactionSession.java b/vm/backend/src/com/codename1/backend/orm/TransactionSession.java new file mode 100644 index 00000000000..bb5770bc8fa --- /dev/null +++ b/vm/backend/src/com/codename1/backend/orm/TransactionSession.java @@ -0,0 +1,192 @@ +/* + * 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. + */ +package com.codename1.backend.orm; + +import com.codename1.backend.Transactions; +import com.codename1.orm.session.JpqlQuery; +import com.codename1.orm.session.LockMode; +import com.codename1.orm.session.Query; +import com.codename1.orm.session.Session; + +/// The [Session] the build injects: the managed session of whatever +/// transaction the calling thread is in. +/// +/// One object is injected into a singleton and used by every request, so it +/// cannot BE a session -- a session is a persistence context, one per unit of +/// work, and not thread-safe. Each call is forwarded to the session of the +/// calling thread's `@Transactional` method instead, which is opened on the +/// transaction's connection when first used, flushed before the transaction +/// commits and closed when it ends. That is the same contract as a Spring-managed +/// `EntityManager`. +/// +/// Outside a transaction there is no unit of work to belong to, and every call +/// refuses with a message saying so. The transaction's boundaries belong to the +/// annotation, so beginning, committing, rolling back and closing through this +/// object are refused too. +public final class TransactionSession implements Session { + private final EntityManager entities; + + public TransactionSession(EntityManager entities) { + this.entities = entities; + } + + private Session current() { + return Transactions.session(entities); + } + + private static UnsupportedOperationException boundary(String what) { + return new UnsupportedOperationException(what + " belongs to the @Transactional " + + "method this session is part of. Throw to roll back, or call " + + "Transactions.setRollbackOnly()."); + } + + @Override + public JpqlQuery createQuery(String statement, Class resultType) { + return current().createQuery(statement, resultType); + } + + @Override + public JpqlQuery createQuery(String statement) { + return current().createQuery(statement); + } + + @Override + public void beginTransaction() { + throw boundary("Beginning a transaction"); + } + + @Override + public void commitTransaction() { + throw boundary("Committing"); + } + + @Override + public void rollbackTransaction() { + throw boundary("Rolling back"); + } + + @Override + public boolean isTransactionActive() { + return Transactions.isActive(); + } + + @Override + public boolean isRollbackOnly() { + return Transactions.isRollbackOnly(); + } + + @Override + public boolean contains(Object entity) { + return Transactions.isActive() && current().contains(entity); + } + + @Override + public void detach(Object entity) { + current().detach(entity); + } + + @Override + public void clear() { + current().clear(); + } + + @Override + public void close() { + throw boundary("Closing the session"); + } + + @Override + public T find(Class type, Object id) { + return current().find(type, id); + } + + @Override + public T find(Class type, Object id, LockMode mode) { + return current().find(type, id, mode); + } + + @Override + public void lock(Object entity, LockMode mode) { + current().lock(entity, mode); + } + + @Override + public void persist(T entity) { + current().persist(entity); + } + + @Override + public T merge(T entity) { + return current().merge(entity); + } + + @Override + public void remove(Object entity) { + current().remove(entity); + } + + @Override + public void refresh(Object entity) { + current().refresh(entity); + } + + @Override + public void flush() { + current().flush(); + } + + @Override + public boolean increment(Class type, Object id, String field, long amount) { + return current().increment(type, id, field, amount); + } + + @Override + public Query query(Class type) { + return current().query(type); + } + + @Override + public void createTables() { + current().createTables(); + } + + @Override + public void validateSchema() { + current().validateSchema(); + } + + @Override + public long count(Object entity, String field) { + return current().count(entity, field); + } + + @Override + public boolean isLoaded(Object entity, String field) { + return current().isLoaded(entity, field); + } + + @Override + public void initialize(Object entity, String field) { + current().initialize(entity, field); + } +} diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java new file mode 100644 index 00000000000..ce8d2868dcd --- /dev/null +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -0,0 +1,555 @@ +/* + * 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. + */ +package com.codename1.backend.otel; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.codename1.backend.Config; +import com.codename1.backend.Web; +import com.codename1.backend.metrics.Instrument; +import com.codename1.backend.metrics.MetricReader; +import com.codename1.backend.metrics.Metrics; + +/// Exports [Metrics] over OTLP/HTTP, every +/// `cn1.otel.metrics.intervalMillis` (OTEL_METRIC_EXPORT_INTERVAL, one minute +/// by default), with cumulative temporality. +/// +/// Configured like the tracer, with the metrics-specific settings taking +/// precedence the way the OpenTelemetry specification says: +/// +/// | Key | Environment | Default | +/// |---|---|---| +/// | `cn1.otel.metrics.endpoint` | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `cn1.otel.endpoint` + `/v1/metrics` | +/// | `cn1.otel.metrics.headers` | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `cn1.otel.headers` | +/// | `cn1.otel.metrics.protocol` | `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | `cn1.otel.protocol` | +/// | `cn1.otel.metrics.enabled` | | `true` | +/// +/// `OTEL_SDK_DISABLED=true` turns it off with the tracer. +public final class OtlpMetricExporter implements MetricReader { + public static final String ENABLED = "cn1.otel.metrics.enabled"; + public static final String ENDPOINT = "cn1.otel.metrics.endpoint"; + public static final String HEADERS = "cn1.otel.metrics.headers"; + public static final String PROTOCOL = "cn1.otel.metrics.protocol"; + public static final String INTERVAL = "cn1.otel.metrics.intervalMillis"; + + private final String defaultServiceName; + private String endpoint; + private List headers = new ArrayList(); + private boolean protobuf; + private int intervalMillis; + private Map resource; + private Thread thread; + private final Object lock = new Object(); + /// The lifecycle of one open(): a builder started again reuses this reader, + /// and a fresh Run is what keeps the new thread from inheriting the stopped + /// one's flags -- and an old thread still finishing its last export from + /// being revived by the new ones. + private Run run; + /// Exporters open in this process; see open(). + private static final List OPEN = new ArrayList(); + private long exports; + private long failures; + /// Data points collectors reported rejecting in a partial success. + private long rejectedDataPoints; + private String lastError; + + public OtlpMetricExporter(String defaultServiceName) { + this.defaultServiceName = defaultServiceName; + // Set here, not left null until open(): open() compares every open + // exporter's, and one is only in OPEN after its own open() set them. + this.endpoint = ""; + this.resource = new LinkedHashMap(); + } + + @Override + public boolean open(Config config) throws IOException { + if (config.getBoolean(OtlpTracer.DISABLED, false) || !config.getBoolean(ENABLED, true)) { + return false; + } + awaitPreviousRun(); + // Read into locals and applied only once accepted: this instance may + // already be exporting for another server, and a second open() that + // wrote its endpoint, headers and resource before being refused + // reconfigured the running exporter it was then refused for. + String protocol = config.get(PROTOCOL, config.get(OtlpTracer.PROTOCOL, + "http/protobuf")).trim(); + boolean asProtobuf; + if ("http/protobuf".equals(protocol)) { + asProtobuf = true; + } else if ("http/json".equals(protocol)) { + asProtobuf = false; + } else { + throw new IOException(PROTOCOL + " is '" + protocol + "'; this server exports " + + "OTLP over HTTP, so use http/protobuf or http/json"); + } + String target = config.get(ENDPOINT); + if (target == null || target.trim().length() == 0) { + target = OtlpTracer.appendSignalPath( + config.get(OtlpTracer.ENDPOINT, "http://localhost:4318").trim(), + "/v1/metrics"); + } + String url = target.trim(); + if (!OtlpTracer.hasHttpAuthority(url)) { + throw new IOException("The metrics endpoint must be an http or https URL naming " + + "a host and is '" + BatchExporter.redact(url) + "'"); + } + List sent = new ArrayList(); + String own = config.get(HEADERS); + OtlpTracer.parseHeaders(own != null ? own : config.get(OtlpTracer.HEADERS), sent); + int interval = OtlpTracer.positive(config, INTERVAL, 60000); + Map describedAs = OtlpTracer.resource(config, defaultServiceName); + synchronized (OPEN) { + if (OPEN.contains(this)) { + // A second thread would export the same streams beside the first, + // and shutdown() tracks one run: the other would never stop. + throw new IOException("This metrics exporter is already open; give each " + + "server its own, or open it once"); + } + // The instruments are the process's -- every server's requests, jobs + // and gauges in one set -- so two exporters with different resources + // would each send the SAME numbers under their own service name, or + // to their own collector, and both would be wrong. One process, one + // metrics identity; the same one twice is fine. + // + // The same identity includes how it is sent: only one of them + // exports, so a second server's API-key header -- the tenant on a + // shared collector -- or its protocol would be silently ignored, + // its numbers sent under the first one's credentials, and the + // credentials would change again at a hand-over. Compared as sets: + // the order the headers are listed in means nothing. + for (Object element : OPEN) { + OtlpMetricExporter other = (OtlpMetricExporter) element; + if (!other.resource.equals(describedAs) || !other.endpoint.equals(url)) { + throw new IOException("Another server in this process already exports " + + "metrics as a different service or to a different collector. " + + "Metrics are per process, so they would be reported twice " + + "under two names; give both servers the same OpenTelemetry " + + "service and endpoint, or set " + ENABLED + "=false on one."); + } + if (other.protobuf != asProtobuf || !sameHeaders(other.headers, sent)) { + // The values are never printed: they are usually credentials. + throw new IOException("Another server in this process already exports " + + "metrics to this collector with different " + HEADERS + " or " + + PROTOCOL + ". Metrics are per process and only one server " + + "sends them, so this one's settings would be ignored; give " + + "both servers the same headers and protocol, or set " + + ENABLED + "=false on one."); + } + } + protobuf = asProtobuf; + endpoint = url; + headers = sent; + intervalMillis = interval; + resource = describedAs; + OPEN.add(this); + if (OPEN.size() > 1) { + // The same identity as the one already exporting: sending the + // process's instruments again would give the collector every point + // twice and run every gauge callback twice. This one waits, and + // takes over if that one shuts down first. + return true; + } + } + startExporting(); + return true; + } + + /// Reads an ExportMetricsServiceResponse's partial_success, in whichever + /// encoding the collector answered; true when it rejected anything or said + /// why. An unreadable body is a full success, as a 2xx without the field is. + private boolean partialSuccess(Web.Result result, long[] rejected, String[] message) { + byte[] body = result.getBody(); + if (body == null || body.length == 0) { + return false; + } + String type = result.getHeader("content-type"); + boolean json = type != null ? type.regionMatches(true, 0, "application/json", 0, 16) + : !protobuf; + try { + if (json) { + OtlpSchema.jsonPartialSuccess(result.getBodyAsString(), "rejectedDataPoints", + rejected, message); + } else { + OtlpSchema.protobufPartialSuccess(body, rejected, message); + } + } catch (Exception err) { + return false; + } + return rejected[0] > 0 || (message[0] != null && message[0].length() > 0); + } + + /// How long a reopen waits for this exporter's previous thread to finish. + private static final long PREVIOUS_RUN_WAIT_MILLIS = 60000; + + /// Waits for the thread of this exporter's previous open() to exit. A + /// shutdown with a short or zero timeout leaves it finishing its last export, + /// and a reopen that went ahead reconfigured the instance under it and + /// started a second loop beside it -- two threads calling the gauges and + /// sending the same cumulative stream. Refused if it outlasts the wait. + private void awaitPreviousRun() throws IOException { + synchronized (OPEN) { + if (OPEN.contains(this)) { + return; // still open: the check below refuses it + } + } + Thread previous; + synchronized (lock) { + previous = thread; + } + if (previous == null || previous == Thread.currentThread() || !previous.isAlive()) { //NOPMD CompareObjectsWithEquals - the thread itself, by identity + return; + } + try { + previous.join(PREVIOUS_RUN_WAIT_MILLIS); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted waiting for this metrics exporter's previous " + + "export to finish", err); + } + if (previous.isAlive()) { + throw new IOException("This metrics exporter's previous run is still exporting; " + + "stop it with a longer timeout, or give the new server its own " + + "exporter"); + } + } + + /// Whether two header lists name the same headers, in any order. + private static boolean sameHeaders(List a, List b) { + return new java.util.HashSet(a).equals(new java.util.HashSet(b)); + } + + /// Starts exporting for a leader that stopped -- but only while this one is + /// still open and first in line, checked and started under the same lock + /// its own shutdown() takes: a successor that shut down meanwhile would + /// otherwise get a thread nothing ever stops, exporting after both servers + /// are gone. + void takeOver() { + takeOver(null); + } + + /// [#takeOver()], its thread first waiting for `predecessor` to exit. + void takeOver(Thread predecessor) { + synchronized (OPEN) { + if (OPEN.isEmpty() || OPEN.get(0) != this) { //NOPMD CompareObjectsWithEquals - the exporter itself, by identity + return; + } + synchronized (lock) { + if (run != null && !run.stopping) { + return; + } + } + startExporting(predecessor); + } + } + + /// Starts this exporter's thread: it is the one exporting the process's metrics. + private void startExporting() { + startExporting(null); + } + + private void startExporting(final Thread predecessor) { + final Run mine = new Run(); + Thread started = new Thread(new Runnable() { + @Override + public void run() { + if (predecessor != null) { + try { + predecessor.join(); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return; + } + } + loop(mine); + } + }, "cn1-otel-metrics"); + started.setDaemon(true); + synchronized (lock) { + run = mine; + thread = started; + } + started.start(); + } + + private void loop(Run mine) { + while (true) { + synchronized (lock) { + long deadline = System.currentTimeMillis() + intervalMillis; + while (!mine.stopping) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + break; + } + try { + lock.wait(left); + } catch (InterruptedException err) { + return; + } + } + if (mine.stopping) { + if (!mine.finalExport) { + return; + } + break; + } + } + export(); + } + // On THIS thread, after any periodic export still in flight, so the two + // never overlap -- and shutdown() only waits for it as long as it was + // told to. + export(); + } + + /// Sends one export now. Answers whether the collector accepted it. + public boolean export() { + try { + Map request = request(resource, Metrics.instruments(), System.currentTimeMillis()); + byte[] body = protobuf ? OtlpSchema.metricsProtobuf(request) + : OtlpSchema.json(request); + List lines = new ArrayList(headers.size() + 1); + lines.add("Content-Type: " + (protobuf ? "application/x-protobuf" + : "application/json")); + lines.addAll(headers); + Web.Result result = Web.request("POST", endpoint, lines, body); + int status = result.getStatus(); + synchronized (lock) { + exports++; + if (status < 200 || status >= 300) { + failures++; + lastError = "the collector answered " + status; + return false; + } + } + // A 2xx can still carry a partial success naming the data points the + // collector dropped -- the trace exporter reads the same field for + // spans. Unread, the status answered healthy while metrics were + // being discarded. + long[] rejected = new long[1]; + String[] message = new String[1]; + if (partialSuccess(result, rejected, message)) { + synchronized (lock) { + failures++; + rejectedDataPoints += Math.max(0, rejected[0]); + lastError = BatchExporter.bounded("the collector rejected " + + rejected[0] + " data point(s)" + (message[0] == null + || message[0].length() == 0 ? "" : ": " + message[0])); + } + return false; + } + return true; + } catch (Throwable err) { + // Throwable, not Exception: this runs on the exporter's only thread, + // and an Error out of an instrument or the encoder must cost one export, + // not every export after it. + synchronized (lock) { + failures++; + lastError = BatchExporter.bounded("could not export metrics: " + + err); + if (failures == 1 || failures % 100 == 0) { + System.err.println(lastError); + } + } + return false; + } + } + + @Override + public void shutdown(int timeoutMillis) { + OtlpMetricExporter successor = null; + synchronized (OPEN) { + boolean leading = !OPEN.isEmpty() && OPEN.get(0) == this; //NOPMD CompareObjectsWithEquals - the exporter itself, by identity + OPEN.remove(this); + if (leading && !OPEN.isEmpty()) { + successor = (OtlpMetricExporter) OPEN.get(0); + } + } + Thread exporter = null; + synchronized (lock) { + if (run != null && !run.stopping) { + run.stopping = true; + // One last export, so the counts of the final minute are not lost + // -- made by the exporter thread, which is bounded by the join + // below rather than by the HTTP client's own timeouts. + run.finalExport = timeoutMillis > 0; + exporter = thread; + lock.notifyAll(); + } + } + if (successor != null) { + // The one that waited behind this, with the same identity, exports + // from now on; the process's metrics are not left unreported while + // another server is still running. It starts only once THIS thread has + // exited -- its periodic or final export may still be in flight, and + // the two at once would send the process's stream twice. + successor.takeOver(exporter); + } + if (exporter != null && timeoutMillis > 0) { + try { + exporter.join(timeoutMillis); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + } + } + + /// Whether one open()'s thread should stop, and whether it exports once more first. + private static final class Run { + boolean stopping; + boolean finalExport; + } + + /// Exports attempted, failures, and the last error, for the management view. + public Map status() { + Map out = new LinkedHashMap(); + synchronized (lock) { + out.put("endpoint", BatchExporter.redact(endpoint)); + out.put("exports", Long.valueOf(exports)); + out.put("failures", Long.valueOf(failures)); + if (rejectedDataPoints > 0) { + out.put("dataPointsRejected", Long.valueOf(rejectedDataPoints)); + } + if (lastError != null) { + out.put("lastError", lastError); + } + } + return out; + } + + /// The ExportMetricsServiceRequest tree for these instruments, at `now`. + static Map request(Map resource, List instruments, long nowMillis) { + String start = nanos(Metrics.startTimeMillis()); + String time = nanos(nowMillis); + List metrics = new ArrayList(instruments.size()); + for (Object item : instruments) { + Instrument instrument = (Instrument) item; + Map metric = new LinkedHashMap(); + metric.put("name", instrument.getName()); + if (instrument.getDescription().length() > 0) { + metric.put("description", instrument.getDescription()); + } + if (instrument.getUnit().length() > 0) { + metric.put("unit", instrument.getUnit()); + } + List points = instrument.points(); + List dataPoints = new ArrayList(points.size()); + for (Object entry : points) { + Map point = (Map) entry; + Map dp = new LinkedHashMap(); + dp.put("attributes", OtlpTracer.keyValues((Map) point.get("attributes"))); + if (instrument.getKind() != Instrument.GAUGE) { + dp.put("startTimeUnixNano", start); + } + dp.put("timeUnixNano", time); + if (instrument.getKind() == Instrument.HISTOGRAM) { + dp.put("count", String.valueOf(point.get("count"))); + dp.put("sum", jsonDouble(point.get("sum"))); + List buckets = (List) point.get("buckets"); + List counts = new ArrayList(buckets.size()); + for (Object element : buckets) { + counts.add(String.valueOf(element)); + } + dp.put("bucketCounts", counts); + dp.put("explicitBounds", point.get("bounds")); + if (point.get("min") != null) { + dp.put("min", jsonDouble(point.get("min"))); + dp.put("max", jsonDouble(point.get("max"))); + } + } else { + Object value = point.get("value"); + if (value instanceof Double && ((Double) value).isNaN()) { + continue; + } + if (value instanceof Long) { + // as_int, exactly: through asDouble a counter past 2^53 + // would be rounded. A string, as OTLP JSON writes int64. + dp.put("asInt", String.valueOf(value)); + } else { + dp.put("asDouble", jsonDouble(value)); + } + } + dataPoints.add(dp); + } + Map data = new LinkedHashMap(); + data.put("dataPoints", dataPoints); + switch (instrument.getKind()) { + case Instrument.GAUGE: + metric.put("gauge", data); + break; + case Instrument.HISTOGRAM: + data.put("aggregationTemporality", Integer.valueOf(2)); + metric.put("histogram", data); + break; + default: + data.put("aggregationTemporality", Integer.valueOf(2)); + data.put("isMonotonic", Boolean.valueOf( + instrument.getKind() == Instrument.COUNTER)); + metric.put("sum", data); + } + metrics.add(metric); + } + Map scope = new LinkedHashMap(); + scope.put("name", "com.codename1.backend"); + Map scopeMetrics = new LinkedHashMap(); + scopeMetrics.put("scope", scope); + scopeMetrics.put("metrics", metrics); + List scopes = new ArrayList(1); + scopes.add(scopeMetrics); + Map resourceMetrics = new LinkedHashMap(); + resourceMetrics.put("resource", resource); + resourceMetrics.put("scopeMetrics", scopes); + List all = new ArrayList(1); + all.add(resourceMetrics); + Map request = new LinkedHashMap(); + request.put("resourceMetrics", all); + return request; + } + + private static String nanos(long millis) { + return String.valueOf(millis) + "000000"; + } + + /// A double as OTLP/JSON carries it. The protobuf JSON mapping writes the + /// non-finite values as the strings "Infinity", "-Infinity" and "NaN"; the + /// generic writer would put null there instead, which a collector reads as an + /// unset value -- or rejects, with the whole export. An infinite gauge + /// reading, or an infinite observation in a histogram's sum, min or max, + /// is a real value to report. The same tree feeds the protobuf encoder, which + /// parses a double field given as a string, so both encodings carry it. + static Object jsonDouble(Object value) { + if (value instanceof Double || value instanceof Float) { + double d = ((Number) value).doubleValue(); + if (Double.isNaN(d)) { + return "NaN"; + } + if (Double.isInfinite(d)) { + return d > 0 ? "Infinity" : "-Infinity"; + } + } + return value; + } +} diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java index f56cc1c9ac2..5ecc46abc0a 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java @@ -61,6 +61,12 @@ final class OtlpSchema { private static final int BOOL = 8; private static final int DOUBLE = 9; private static final int MESSAGE = 10; + /// A repeated fixed64, packed: the histogram's bucket counts. + private static final int PACKED_FIXED64 = 11; + /// A repeated double, packed: the histogram's bucket bounds. + private static final int PACKED_DOUBLE = 12; + /// A signed fixed64 (sfixed64): as_int, which an up-down counter makes negative. + private static final int SFIXED64 = 13; /// One field: its JSON name, its number, its kind, and for a message its type. private static final class Field { @@ -146,6 +152,9 @@ private static Field rep(String name, int number) { private static final Message RESOURCE_SPANS; /// ExportTraceServiceRequest, the body of POST /v1/traces. private static final Message EXPORT; + /// ExportMetricsServiceRequest, the body of POST /v1/metrics. The field + /// numbers are `opentelemetry/proto/metrics/v1/metrics.proto`'s. + private static final Message METRICS_EXPORT; static { Field arrayValue = f("arrayValue", 5, MESSAGE); @@ -215,6 +224,65 @@ private static Field rep(String name, int number) { Field resourceSpans = rep("resourceSpans", 1); resourceSpans.type = RESOURCE_SPANS; EXPORT = new Message(new Field[] {resourceSpans}); + + Message numberPoint = new Message(new Field[] { + attributes(7), f("startTimeUnixNano", 2, FIXED64), f("timeUnixNano", 3, FIXED64), + // as_int is sfixed64: signed, so an up-down counter below zero is a + // value like any other -- FIXED64 refuses negatives, rightly, for + // the timestamps and counts that use it. + f("asDouble", 4, DOUBLE), f("asInt", 6, SFIXED64), f("flags", 8, VARINT) + }); + // bucket_counts is "repeated fixed64" on HistogramDataPoint in + // opentelemetry-proto's metrics.proto, like count -- NOT the "repeated + // uint64" (varint) of the exponential histogram's Buckets message. The + // two are easy to confuse; OtlpMetricsProtoTest decodes this with the + // generated classes to hold it. + Message histogramPoint = new Message(new Field[] { + attributes(9), f("startTimeUnixNano", 2, FIXED64), f("timeUnixNano", 3, FIXED64), + f("count", 4, FIXED64), f("sum", 5, DOUBLE), + f("bucketCounts", 6, PACKED_FIXED64), f("explicitBounds", 7, PACKED_DOUBLE), + f("flags", 10, VARINT), f("min", 11, DOUBLE), f("max", 12, DOUBLE) + }); + Field gaugePoints = rep("dataPoints", 1); + gaugePoints.type = numberPoint; + Message gauge = new Message(new Field[] {gaugePoints}); + Field sumPoints = rep("dataPoints", 1); + sumPoints.type = numberPoint; + Message sum = new Message(new Field[] { + sumPoints, f("aggregationTemporality", 2, VARINT), f("isMonotonic", 3, BOOL) + }); + Field histogramPoints = rep("dataPoints", 1); + histogramPoints.type = histogramPoint; + Message histogram = new Message(new Field[] { + histogramPoints, f("aggregationTemporality", 2, VARINT) + }); + Field gaugeField = f("gauge", 5, MESSAGE); + gaugeField.type = gauge; + Field sumField = f("sum", 7, MESSAGE); + sumField.type = sum; + Field histogramField = f("histogram", 9, MESSAGE); + histogramField.type = histogram; + Message metric = new Message(new Field[] { + f("name", 1, STRING), f("description", 2, STRING), f("unit", 3, STRING), + gaugeField, sumField, histogramField + }); + Field metricsScope = f("scope", 1, MESSAGE); + metricsScope.type = SCOPE; + Field metrics = rep("metrics", 2); + metrics.type = metric; + Message scopeMetrics = new Message(new Field[] { + metricsScope, metrics, f("schemaUrl", 3, STRING) + }); + Field metricsResource = f("resource", 1, MESSAGE); + metricsResource.type = RESOURCE; + Field scopeMetricsField = rep("scopeMetrics", 2); + scopeMetricsField.type = scopeMetrics; + Message resourceMetrics = new Message(new Field[] { + metricsResource, scopeMetricsField, f("schemaUrl", 3, STRING) + }); + Field resourceMetricsField = rep("resourceMetrics", 1); + resourceMetricsField.type = resourceMetrics; + METRICS_EXPORT = new Message(new Field[] {resourceMetricsField}); } private static Field attributes(int number) { @@ -244,6 +312,13 @@ static byte[] protobuf(Map request) throws IOException { return copy(out); } + /// A metrics export request as protobuf. + static byte[] metricsProtobuf(Map request) throws IOException { + ByteSink out = new ByteSink(1024); + writeMessage(METRICS_EXPORT, request, out, 0); + return copy(out); + } + /// Re-builds a tree, keeping only what this table names. The relay forwards the /// result instead of the client's own tree, so unknown fields -- whatever a /// client decided to attach -- never reach the collector. @@ -427,6 +502,12 @@ private static void writeScalar(Field field, Object value, ByteSink out) throws out.put(bytes, 0, bytes.length); return; } + case SFIXED64: { + // Two's complement in the same eight little-endian bytes. + tag(out, field.number, 1); + fixed64(out, number(field, value)); + return; + } case FIXED64: { long v = number(field, value); // Every fixed64 in the trace schema is an unsigned nanosecond @@ -492,6 +573,29 @@ private static void writeScalar(Field field, Object value, ByteSink out) throws fixed64(out, Double.doubleToLongBits(d)); return; } + case PACKED_FIXED64: + case PACKED_DOUBLE: { + if (!(value instanceof List)) { + throw new IOException(field.name + " must be a list"); + } + List items = (List) value; + if (items.isEmpty()) { + return; + } + tag(out, field.number, 2); + varint(out, items.size() * 8L); + for (Object item : items) { + if (field.kind == PACKED_DOUBLE) { + if (!(item instanceof Number)) { + throw new IOException(field.name + " must hold numbers"); + } + fixed64(out, Double.doubleToLongBits(((Number) item).doubleValue())); + } else { + fixed64(out, number(field, item)); + } + } + return; + } default: throw new IOException("unknown field kind for " + field.name); } @@ -676,6 +780,15 @@ private static byte[] copy(ByteSink sink) { /// ExportTraceServiceResponse's partial_success, as OTLP/JSON writes it: /// `{"partialSuccess":{"rejectedSpans":"3","errorMessage":"..."}}`. static void jsonPartialSuccess(String body, long[] rejected, String[] message) throws IOException { + jsonPartialSuccess(body, "rejectedSpans", rejected, message); + } + + /// [#jsonPartialSuccess(String, long[], String[])] for a response whose count + /// is named `countKey`: rejectedSpans for traces, rejectedDataPoints for + /// metrics. The binary form needs no such parameter -- both messages keep the + /// count in field 1 -- so [#protobufPartialSuccess] reads either. + static void jsonPartialSuccess(String body, String countKey, long[] rejected, + String[] message) throws IOException { Object parsed = Json.parse(body); if (!(parsed instanceof Map)) { return; @@ -684,9 +797,9 @@ static void jsonPartialSuccess(String body, long[] rejected, String[] message) t if (!(partial instanceof Map)) { return; } - Object count = ((Map) partial).get("rejectedSpans"); + Object count = ((Map) partial).get(countKey); if (count != null) { - rejected[0] = number(f("rejectedSpans", 1, INT64), count); + rejected[0] = number(f(countKey, 1, INT64), count); } Object text = ((Map) partial).get("errorMessage"); if (text instanceof String) { diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java b/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java index 6f7595780b8..a6e8f8017bc 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java @@ -130,6 +130,31 @@ public static OtlpTracer open(Config config, String defaultServiceName) throws I return tracer.open(config) ? tracer : null; } + /// The OTLP resource this process reports as: `service.name` and the + /// configured resource attributes. Shared by the trace and metric exporters, + /// which must describe the same service. + static Map resource(Config config, String defaultServiceName) throws IOException { + Map resourceAttributes = new LinkedHashMap(); + parsePairs(config.get(RESOURCE_ATTRIBUTES), resourceAttributes, RESOURCE_ATTRIBUTES); + // Each source in turn, a blank one counting as absent: tested raw, a name + // of " " was chosen and then trimmed to an empty service.name, where the + // specification wants unknown_service. + Object fromResource = resourceAttributes.get("service.name"); + String service = nonBlank(config.get(SERVICE_NAME)); + if (service == null) { + service = nonBlank(fromResource == null ? null : String.valueOf(fromResource)); + } + if (service == null) { + service = nonBlank(defaultServiceName); + } + resourceAttributes.put("service.name", service == null ? "unknown_service" : service); + resourceAttributes.put("telemetry.sdk.name", "codenameone"); + resourceAttributes.put("telemetry.sdk.language", "java"); + Map resource = new LinkedHashMap(); + resource.put("attributes", keyValues(resourceAttributes)); + return resource; + } + @Override public boolean open(Config config) throws IOException { if (config.getBoolean(DISABLED, false)) { @@ -173,24 +198,7 @@ public boolean open(Config config) throws IOException { String tracesHeaders = config.get(TRACES_HEADERS); parseHeaders(tracesHeaders != null ? tracesHeaders : config.get(HEADERS), headers); - Map resourceAttributes = new LinkedHashMap(); - parsePairs(config.get(RESOURCE_ATTRIBUTES), resourceAttributes, RESOURCE_ATTRIBUTES); - // Each source in turn, a blank one counting as absent: tested raw, a name - // of " " was chosen and then trimmed to an empty service.name, where the - // specification wants unknown_service. - Object fromResource = resourceAttributes.get("service.name"); - String service = nonBlank(config.get(SERVICE_NAME)); - if (service == null) { - service = nonBlank(fromResource == null ? null : String.valueOf(fromResource)); - } - if (service == null) { - service = nonBlank(defaultServiceName); - } - resourceAttributes.put("service.name", service == null ? "unknown_service" : service); - resourceAttributes.put("telemetry.sdk.name", "codenameone"); - resourceAttributes.put("telemetry.sdk.language", "java"); - Map resource = new LinkedHashMap(); - resource.put("attributes", keyValues(resourceAttributes)); + Map resource = resource(config, defaultServiceName); int queue = positive(config, QUEUE_SIZE, 2048); int batch = Math.min(queue, positive(config, BATCH_SIZE, 512)); @@ -211,18 +219,10 @@ public boolean open(Config config) throws IOException { + "ASCII characters a URL path allows (percent-encode anything " + "else); it is '" + configuredPath + "'"); } - String token = config.get(RELAY_TOKEN); - if (token != null && token.length() > 0 && !sendableFieldValue(token)) { - // Refused here because no client could ever present it: the request - // parser refuses a control character in a header value and trims - // surrounding spaces and tabs, so a trailing newline from a mounted - // secret made every relay export a 401 the app drops silently. The - // value itself stays out of the message -- it is a secret. - throw new IOException(RELAY_TOKEN + " holds a control character or " - + "leading or trailing whitespace, so no request can carry it " - + "in a header; remove it (a secret file's trailing newline is " - + "the usual cause)"); - } + // Refused there when no client could ever present it: a trailing + // newline from a mounted secret made every relay export a 401 the app + // drops silently. + String token = config.getHeaderSecret(RELAY_TOKEN); relay = new OtlpRelay(path, token, relayBytes, positive(config, RELAY_MAX_SPANS, 1000), corsOrigin(config), exporter); @@ -231,23 +231,6 @@ public boolean open(Config config) throws IOException { return true; } - /// Whether a request header could carry this value exactly: no control - /// character but tab, and no space or tab at either end, which the request - /// parser trims as surrounding whitespace. - static boolean sendableFieldValue(String value) { - int last = value.length() - 1; - for (int iter = 0 ; iter <= last ; iter++) { - char c = value.charAt(iter); - if ((c < 0x20 && c != '\t') || c == 0x7f) { - return false; - } - if ((iter == 0 || iter == last) && (c == ' ' || c == '\t')) { - return false; - } - } - return true; - } - @Override public Span startSpan(String name, int kind, Span parent, String traceparent, String tracestate) { @@ -633,7 +616,7 @@ static boolean isIpv4(String s) { } /// `value` trimmed, or null when that leaves nothing. - private static String nonBlank(String value) { + static String nonBlank(String value) { if (value == null) { return null; } @@ -868,6 +851,11 @@ static List keyValues(Map attributes) { /// appending to the whole string put the path inside the key's value and sent /// the export to the base path with a corrupted credential. static String appendTracesPath(String base) { + return appendSignalPath(base, "/v1/traces"); + } + + /// The generic endpoint plus a signal's path, appended to the PATH. + static String appendSignalPath(String base, String signal) { int cut = base.length(); int query = base.indexOf('?'); int fragment = base.indexOf('#'); @@ -881,10 +869,10 @@ static String appendTracesPath(String base) { while (path.endsWith("/")) { path = path.substring(0, path.length() - 1); } - return path + "/v1/traces" + base.substring(cut); + return path + signal + base.substring(cut); } - private static int positive(Config config, String key, int fallback) throws IOException { + static int positive(Config config, String key, int fallback) throws IOException { int value = config.getInt(key, fallback); if (value <= 0) { throw new IOException(key + " must be a positive number and is " + value); @@ -913,7 +901,7 @@ private static Set splitSet(String list) { } /// OTEL_EXPORTER_OTLP_HEADERS: `name=value,...`, values percent-encoded. - private static void parseHeaders(String text, List out) throws IOException { + static void parseHeaders(String text, List out) throws IOException { Map pairs = new LinkedHashMap(); parsePairs(text, pairs, HEADERS); Iterator it = pairs.entrySet().iterator(); diff --git a/vm/backend/src/com/codename1/backend/otel/package-info.java b/vm/backend/src/com/codename1/backend/otel/package-info.java index aef5bf5b5f4..ced7ad70f99 100644 --- a/vm/backend/src/com/codename1/backend/otel/package-info.java +++ b/vm/backend/src/com/codename1/backend/otel/package-info.java @@ -20,15 +20,16 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -/// OpenTelemetry tracing for a backend, exported over OTLP/HTTP without an -/// OpenTelemetry library. +/// OpenTelemetry tracing and metrics for a backend, exported over OTLP/HTTP +/// without an OpenTelemetry library. /// /// Most servers never name these classes: the `OpenTelemetry` annotation in /// `com.codename1.backend.annotations` has the build wire an [OtlpTracer] into the /// generated entry point. Code that assembles its own server installs one with /// `com.codename1.backend.Tracing.install`, and every request, outbound call and /// database statement is then exported as a span to the collector the deployment -/// names. +/// names. [OtlpMetricExporter] sends the process's metrics to the same collector +/// on an interval. /// /// Server code: this package is not available in the app. package com.codename1.backend.otel; diff --git a/vm/backend/src/com/codename1/backend/sql/Dialect.java b/vm/backend/src/com/codename1/backend/sql/Dialect.java index 929c55b7691..bb09d251e42 100644 --- a/vm/backend/src/com/codename1/backend/sql/Dialect.java +++ b/vm/backend/src/com/codename1/backend/sql/Dialect.java @@ -1103,4 +1103,22 @@ String unboundedLimit() { return "18446744073709551615"; } } + + /// The name, if it is a plain SQL identifier: an ASCII letter or underscore, + /// then letters, digits and underscores. For the few statements that cannot + /// take a parameter where a name goes -- a savepoint -- so a name reaching the + /// text of a statement can never carry anything else. + public static String checkIdentifier(String name) throws IOException { + if (name == null || name.length() == 0 || name.length() > 63) { + throw new IOException("Not an identifier: " + name); + } + for (int iter = 0 ; iter < name.length() ; iter++) { + char c = name.charAt(iter); + boolean letter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; + if (!letter && (iter == 0 || c < '0' || c > '9')) { + throw new IOException("Not an identifier: " + name); + } + } + return name; + } } diff --git a/vm/backend/src/com/codename1/backend/sql/MySql.java b/vm/backend/src/com/codename1/backend/sql/MySql.java index 39f6d168e07..0f209bd6b8b 100644 --- a/vm/backend/src/com/codename1/backend/sql/MySql.java +++ b/vm/backend/src/com/codename1/backend/sql/MySql.java @@ -422,6 +422,23 @@ public void rollback() throws IOException { command("ROLLBACK"); } + /// Opens a transaction that refuses writes. + public void beginReadOnly() throws IOException { + command("START TRANSACTION READ ONLY"); + } + + /// Savepoint control, through the text protocol for the same reason as + /// [#begin]. The name is checked to be a plain identifier here as well + /// as by the caller, because this is the one text-protocol entry point that + /// takes a value at all. + public void savepoint(String verb, String name) throws IOException { + if (!"SAVEPOINT".equals(verb) && !"ROLLBACK TO SAVEPOINT".equals(verb) + && !"RELEASE SAVEPOINT".equals(verb)) { + throw new IOException("Not a savepoint statement: " + verb); + } + command(verb + " " + Dialect.checkIdentifier(name)); + } + public long lastInsertId() { return lastInsertId; } diff --git a/vm/backend/src/com/codename1/impl/orm/BackendSqlAccess.java b/vm/backend/src/com/codename1/impl/orm/BackendSqlAccess.java index 8654d209dfb..cdb176cf61e 100644 --- a/vm/backend/src/com/codename1/impl/orm/BackendSqlAccess.java +++ b/vm/backend/src/com/codename1/impl/orm/BackendSqlAccess.java @@ -23,6 +23,7 @@ package com.codename1.impl.orm; import com.codename1.backend.Crypto; +import com.codename1.backend.Transactions; import com.codename1.backend.Database; import com.codename1.backend.DataSource; import com.codename1.backend.sql.Dialect; @@ -40,6 +41,10 @@ public final class BackendSqlAccess implements SqlAccess { private final Database supplied; private final Dialect dialect; private Database transaction; + /// Whether [#transaction] is a `@Transactional` method's rather than this + /// session's own. Then the session neither sends BEGIN nor COMMIT: the + /// method's transaction decides, and a rollback here only marks it. + private boolean joinedTransaction; public BackendSqlAccess(DataSource pool, Database supplied, Dialect dialect) { this.pool = pool; @@ -143,7 +148,8 @@ public String limit(int count, int offset) { // two distinct connections are never interchangeable. private Database connection() throws IOException { Database db = transaction != null ? transaction : supplied != null ? supplied : pool.borrow(); - if (db != transaction && db.isInTransaction()) { //NOPMD CompareObjectsWithEquals - connection identity + if (db != transaction && db.isInTransaction() //NOPMD CompareObjectsWithEquals - connection identity + && !(pool != null && Transactions.isJoined(pool, db))) { if (db != supplied) { //NOPMD CompareObjectsWithEquals - connection identity db.close(); pool.release(db); @@ -255,6 +261,16 @@ public void begin() throws IOException { if (transaction != null) { throw new IOException("Transaction already active"); } + if (supplied == null && pool != null) { + // Inside a @Transactional method: this session becomes part of that + // transaction instead of opening one the connection would refuse. + Database joined = Transactions.joined(pool); + if (joined != null) { + transaction = joined; + joinedTransaction = true; + return; + } + } Database db = supplied != null ? supplied : pool.borrow(); try { if ("sqlite".equals(dialect())) { @@ -277,6 +293,10 @@ public void commit() throws IOException { if (transaction == null) { throw new IOException("No transaction"); } + if (joinedTransaction) { + unpin(); + return; + } transaction.commitTransaction(); unpin(); } @@ -286,6 +306,11 @@ public void rollback() throws IOException { if (transaction == null) { throw new IOException("No transaction"); } + if (joinedTransaction) { + Transactions.markRollbackOnly(pool); + unpin(); + return; + } transaction.rollbackTransaction(); unpin(); } @@ -293,11 +318,19 @@ public void rollback() throws IOException { private void unpin() { Database db = transaction; transaction = null; + joinedTransaction = false; release(db); } @Override public void close() throws IOException { + if (transaction != null && joinedTransaction) { + // Closed without committing: its changes were never flushed, and the + // method's transaction cannot commit as though they had been. + Transactions.markRollbackOnly(pool); + unpin(); + return; + } if (transaction != null) { boolean rolledBack = false; try { diff --git a/vm/selfhost/perf-baseline.json b/vm/selfhost/perf-baseline.json index 9eb99877c24..353475771f7 100644 --- a/vm/selfhost/perf-baseline.json +++ b/vm/selfhost/perf-baseline.json @@ -1143,97 +1143,98 @@ "arrayRandom": { "all": { "memory": 0.227, - "runs": 6, - "time": 0.969, + "runs": 7, + "time": 1.061, "tolerance": { - "time": 0.25 + "time": 0.45 } } }, "arraySequential": { "all": { "memory": 0.385, - "runs": 6, - "time": 1.375 + "runs": 7, + "time": 1.364 } }, "hashMapChurn": { "all": { - "memory": 0.092, - "runs": 6, - "time": 1.802 + "memory": 0.093, + "runs": 7, + "time": 1.798 } }, "hello": { "all": { - "memory": 0.805, - "runs": 6, - "time": 1.259 + "memory": 0.803, + "runs": 7, + "time": 1.241 } }, "intArithmetic": { "all": { "memory": 0.056, - "runs": 6, + "runs": 7, "time": 1.1 } }, "longArithmetic": { "all": { "memory": 0.054, - "runs": 6, - "time": 1.08 + "runs": 7, + "time": 1.081 } }, "mathTranscendental": { "all": { - "memory": 0.056, - "runs": 6, + "memory": 0.059, + "runs": 7, "time": 0.79 } }, "objectAllocation": { "all": { - "memory": 0.392, - "runs": 6, - "time": 5.024, + "memory": 0.4, + "runs": 7, + "time": 4.932, "tolerance": { - "time": 0.45 + "memory": 0.2, + "time": 0.5 } } }, "quicksort": { "all": { "memory": 0.12, - "runs": 6, - "time": 1.129 + "runs": 7, + "time": 1.133 } }, "recursion": { "all": { "memory": 0.06, - "runs": 6, - "time": 1.491 + "runs": 7, + "time": 1.494 } }, "stringBuilding": { "all": { "memory": 0.322, - "runs": 6, - "time": 1.205 + "runs": 7, + "time": 1.216 } }, "translator": { "all": { "memory": 0.542, - "runs": 6, - "time": 0.609 + "runs": 7, + "time": 0.606 } }, "valueEscape": { "all": { "memory": 0.044, - "runs": 6, + "runs": 7, "time": 0.1 } } diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BackendOtelTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BackendOtelTest.java index ce7cc2a650b..fa9e35125a6 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BackendOtelTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BackendOtelTest.java @@ -75,7 +75,7 @@ class BackendOtelTest { private static final String WS_REFUSED_TRACE = "7d0a1e4bb7c9a2f35e61d8c04f2b9a13"; @Test - @DisplayName("a translated server exports one connected trace, and an untraced one carries no tracer") + @DisplayName("a translated server exports one connected trace, and an untraced one carries no tracer, management or MCP") void tracesOnThePackagedRuntime() throws Exception { if (CompilerHelper.isWindows()) { BackendTestSupport.skipOrFail("the server-side backend is POSIX-only for now"); @@ -106,6 +106,17 @@ void tracesOnThePackagedRuntime() throws Exception { "the traced binary carries no tracer symbols; the check below would be vacuous"); assertEquals(0, otelSymbols(untraced), "a server that never asked for tracing links the tracer anyway"); + // The same claim for the server's own endpoints, which the docs make: the + // generated entry point names them only when the build asked, and a + // server that never did must not carry them. + assertTrue(symbols(traced, "com_codename1_backend_Management_") > 0 + && symbols(traced, "com_codename1_backend_mcp_McpServer_") > 0, + "the control links management and MCP and carries neither symbol; the " + + "check below would be vacuous"); + assertEquals(0, symbols(untraced, "com_codename1_backend_Management_"), + "a server that never asked for the management endpoints links them anyway"); + assertEquals(0, symbols(untraced, "com_codename1_backend_mcp_McpServer_"), + "a server that never asked for MCP links the endpoint anyway"); final List exports = Collections.synchronizedList(new ArrayList()); final List contentTypes = Collections.synchronizedList(new ArrayList()); @@ -271,10 +282,15 @@ void tracesOnThePackagedRuntime() throws Exception { /** How many translated symbols of the tracer package a binary holds. */ private static int otelSymbols(Path binary) throws Exception { + return symbols(binary, "com_codename1_backend_otel_"); + } + + /** How many of a binary's symbols contain `prefix`. */ + private static int symbols(Path binary, String prefix) throws Exception { String symbols = BackendTestSupport.run(Arrays.asList("nm", binary.toString()), 120); int count = 0; int at = 0; - while ((at = symbols.indexOf("com_codename1_backend_otel_", at)) >= 0) { + while ((at = symbols.indexOf(prefix, at)) >= 0) { count++; at++; } diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BackendRuntimeSelfTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BackendRuntimeSelfTest.java index 4769e647f7c..51b70381133 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BackendRuntimeSelfTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BackendRuntimeSelfTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -87,6 +88,15 @@ void translatedSelfTest() throws Exception { run.environment().put("CN1_WEB_MAX_RESPONSE_MB", "1"); run.environment().put("CN1_TLS_HANDSHAKE_MS", "1500"); run.environment().put("CN1_HTTP_MAX_RESPONSE_MB", "1"); + // A certificate for 127.0.0.1, so the self-test can stand up a local TLS + // peer and prove an outbound TLS read parks its virtual thread. Optional: + // without openssl the check says it skipped rather than failing here. + Path cert = work.resolve("peer-cert.pem"); + Path key = work.resolve("peer-key.pem"); + if (makeLoopbackCertificate(work, cert, key)) { + run.environment().put("CN1_SELFTEST_TLS_CERT", cert.toString()); + run.environment().put("CN1_SELFTEST_TLS_KEY", key.toString()); + } if (System.getenv("CN1_SELFTEST_NETWORK") != null) { run.environment().put("CN1_SELFTEST_NETWORK", "1"); String bundle = caBundle(); @@ -111,6 +121,29 @@ void translatedSelfTest() throws Exception { "expected the full set of checks, only " + passed + " ran:\n" + tail(output)); } + /** + * A self-signed certificate whose subjectAltName is the loopback ADDRESS, so a + * client verifying against it as its CA bundle does a real verification -- + * chain and name -- rather than one switched off for the test. + */ + private static boolean makeLoopbackCertificate(Path work, Path cert, Path key) + throws Exception { + ProcessBuilder openssl = new ProcessBuilder("openssl", "req", "-x509", "-newkey", + "rsa:2048", "-keyout", key.toString(), "-out", cert.toString(), + "-days", "1", "-nodes", "-subj", "/CN=127.0.0.1", + "-addext", "subjectAltName=IP:127.0.0.1"); + openssl.redirectErrorStream(true); + openssl.redirectOutput(work.resolve("openssl.log").toFile()); + Process made; + try { + made = openssl.start(); + } catch (IOException noOpenssl) { + return false; + } + return made.waitFor(60, TimeUnit.SECONDS) && made.exitValue() == 0 + && Files.exists(cert) && Files.exists(key); + } + /** Reads "passed=N" out of the self-test's own summary line. */ private static int passedCount(String output) { int at = output.indexOf("passed="); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/GcSteadyStateIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/GcSteadyStateIntegrationTest.java index fb5c024786c..c5c7bbf2d76 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/GcSteadyStateIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/GcSteadyStateIntegrationTest.java @@ -550,25 +550,48 @@ private void runGate(List tempDirs) throws Exception { // ---- 4. proof that scenario 3 can fail --------------------------------- Path noReserve = build(legacyDist, tempDirs, "noreserve", "-DCN1_GC_CONFORM -DCN1_PACING_NO_RESERVE"); - Run unbounded = run(noReserve, legacyDist, ceiling); - assertHealthy(unbounded, "the -DCN1_PACING_NO_RESERVE build", javaResult); - long unboundedHeadroomMb = minHeadroomMb(unbounded.output); - assertTrue(unboundedHeadroomMb >= 0, - "No [PACING] report from the no-reserve build. Output: " + tail(unbounded.output)); - // The fault twin, and what keeps scenario 3 non-vacuous: with the bound compiled - // out the process must end up on the bare admission margin. If it does not, the - // environment is not pressuring it at all and scenario 3's "never entered the - // reserve" branch would be passing for the wrong reason. - assertTrue(unboundedHeadroomMb < HEADROOM_THRESHOLD_MB, - "Compiling the reserve out did NOT put the process back on the admission " - + "margin (smallest headroom " + unboundedHeadroomMb + "MB), so the " - + "ceiling is not pressuring this workload and scenario 3 proved " - + "nothing." + evidence(unbounded)); - assertEquals(0, pacingCounter(unbounded.output, "volumeParks="), - "The reserve was compiled out, so nothing may have parked on it." - + evidence(unbounded)); - System.err.println("[GcSteadyState] ceiling/no-reserve: smallestHeadroom=" - + unboundedHeadroomMb + "MB"); + Run unbounded = run(noReserve, legacyDist, ceiling, true); + if (unbounded.exit == WEDGED) { + // The fault twin can wedge instead of finishing: with the reserve compiled + // out it rides the budget to the admission margin, where every mutator + // parks on the exhausted budget and a cycle can stop being owed. That IS + // the failure scenario 3 guards against, not a broken gate -- but it leaves + // no [PACING] report, which is written at exit. The per-second probe says + // the same things: the footprint it reached, and whether the reserve bound + // (compiled out) ever parked anyone. A shipped build never runs this path; + // requiring the deliberately broken one to finish made the gate fail on + // whichever runner happened to be slow enough to wedge. + long wedgedHeadroomMb = CEILING_MB - maxProbe(unbounded.output, "[GCPROBE-T]", + "fpKb=") / 1024; + assertTrue(wedgedHeadroomMb < HEADROOM_THRESHOLD_MB, + "The no-reserve build stopped finishing WITHOUT reaching the admission " + + "margin (headroom " + wedgedHeadroomMb + "MB), so it is stuck " + + "for some other reason." + evidence(unbounded)); + assertEquals(0, maxProbe(unbounded.output, "[GCSTALL-T]", "volume="), + "The reserve was compiled out, so nothing may have parked on it." + + evidence(unbounded)); + System.err.println("[GcSteadyState] ceiling/no-reserve: wedged on the margin, " + + "smallestHeadroom=" + wedgedHeadroomMb + "MB"); + } else { + assertHealthy(unbounded, "the -DCN1_PACING_NO_RESERVE build", javaResult); + long unboundedHeadroomMb = minHeadroomMb(unbounded.output); + assertTrue(unboundedHeadroomMb >= 0, + "No [PACING] report from the no-reserve build. Output: " + tail(unbounded.output)); + // The fault twin, and what keeps scenario 3 non-vacuous: with the bound compiled + // out the process must end up on the bare admission margin. If it does not, the + // environment is not pressuring it at all and scenario 3's "never entered the + // reserve" branch would be passing for the wrong reason. + assertTrue(unboundedHeadroomMb < HEADROOM_THRESHOLD_MB, + "Compiling the reserve out did NOT put the process back on the admission " + + "margin (smallest headroom " + unboundedHeadroomMb + "MB), so the " + + "ceiling is not pressuring this workload and scenario 3 proved " + + "nothing." + evidence(unbounded)); + assertEquals(0, pacingCounter(unbounded.output, "volumeParks="), + "The reserve was compiled out, so nothing may have parked on it." + + evidence(unbounded)); + System.err.println("[GcSteadyState] ceiling/no-reserve: smallestHeadroom=" + + unboundedHeadroomMb + "MB"); + } // ---- 5. the mutator must be RUNNING, not waiting on the collector ------- // The four scenarios above all measure memory, and the reporter's build passed @@ -1100,7 +1123,36 @@ private Run run(Path executable, Path workingDir) throws Exception { return run(executable, workingDir, new HashMap()); } + /** The exit code of a run that never finished; see run(..., mayWedge). */ + private static final int WEDGED = Integer.MIN_VALUE; + + /** The largest value of `key` on the probe lines starting with `prefix`, or 0. */ + private static long maxProbe(String output, String prefix, String key) { + long max = 0; + for (String line : output.split("\\R")) { + int at = line.indexOf(key); + if (!line.startsWith(prefix) || at < 0) { + continue; + } + int end = at + key.length(); + while (end < line.length() && Character.isDigit(line.charAt(end))) { + end++; + } + if (end > at + key.length()) { + max = Math.max(max, Long.parseLong(line.substring(at + key.length(), end))); + } + } + return max; + } + private Run run(Path executable, Path workingDir, Map env) throws Exception { + return run(executable, workingDir, env, false); + } + + /// `mayWedge`: a run that does not finish is returned with exit WEDGED rather than + /// failed, for a fault twin whose not finishing is itself the demonstration. + private Run run(Path executable, Path workingDir, Map env, + boolean mayWedge) throws Exception { ProcessBuilder builder = new ProcessBuilder(executable.toString()); builder.directory(workingDir.toFile()); // A developer debugging the collector has CN1_* knobs exported, and several of them @@ -1148,6 +1200,9 @@ private Run run(Path executable, Path workingDir, Map env) throw synchronized (captured) { output = captured.toString(); } + if (!exited && mayWedge) { + return new Run(WEDGED, output); + } assertTrue(exited, "The workload did not finish within " + VM_RUN_TIMEOUT_SECONDS + "s (env " + env + "). For this gate that is a result and not an"