From 6dd51f4064d8001850159cd6c715cc741994718b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:36:03 +0300 Subject: [PATCH 01/48] Backend: Spring-style beans, transactions, scheduling, metrics and MCP, resolved at build time The backend gains Spring's programming model under com.codename1.backend.annotations -- @Service/@Component/@Repository, @Autowired, @Value, @ConfigurationProperties, @Bean, scopes, profiles and conditions, @Transactional, @Async, @Scheduled, @ManagedResource/@Timed/@Counted and @McpTool -- with every decision made by the build: a generated BackendWiring constructs and injects the beans with plain code, and aspects are woven into the annotated methods. No container, scan, proxy or reflection at run time. Runtime: thread-bound transactions that the pool, daos and sessions join, a cron and fixed-rate scheduler with an optional database lock, task executors including fd-less virtual-thread tasks with a per-host wake pipe, HTTP sessions (memory or JDBC store), OTLP metrics and management endpoints, and an MCP endpoint with development tools. Also fixes the sticky virtual-thread yield reason (yieldNow from a handler hung), a handler taking HttpServer.Request being refused, and adds the backend and full-stack references to the generated agent skill. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/beans/ConsoleMailer.java | 36 + .../developerguide/backend/beans/Limits.java | 37 + .../developerguide/backend/beans/Login.java | 43 + .../developerguide/backend/beans/Mailer.java | 28 + .../backend/beans/PriceCache.java | 57 + .../backend/beans/RateLimiter.java | 39 + .../developerguide/backend/beans/Reports.java | 60 + .../backend/beans/SignupApi.java | 50 + .../developerguide/backend/beans/Signups.java | 66 + .../backend/beans/SmtpMailer.java | 43 + .../backend/beans/SupportTools.java | 51 + .../backend/beans/WiringTest.java | 53 + docs/developer-guide/Backend.asciidoc | 279 ++- docs/developer-guide/languagetool-accept.txt | 6 + .../backend/ApplicationRuntimeTest.java | 457 ++++ .../backend/ServerEngineTransactionsTest.java | 225 ++ .../codename1/backend/TransactionsTest.java | 232 +++ .../backend/application-dev.properties | 8 + .../backend/src/main/java/Api.java | 23 +- .../backend/src/main/java/Greeter.java | 47 + maven/codenameone-maven-plugin/pom.xml | 8 + .../codename1/maven/BackendPackageMojo.java | 14 +- .../maven/annotations/AnnotatedClass.java | 6 + .../maven/annotations/ClassScanner.java | 31 +- .../maven/annotations/FieldInfo.java | 1 + .../maven/annotations/MethodInfo.java | 16 + .../maven/annotations/ProcessorContext.java | 9 + .../BackendBeanAnnotationProcessor.java | 58 + .../maven/processors/BackendBeans.java | 1831 +++++++++++++++++ .../maven/processors/BackendSources.java | 831 ++++++++ .../maven/processors/BackendWeaver.java | 339 +++ .../maven/processors/BackendWiringWriter.java | 878 ++++++++ .../maven/processors/CronCompiler.java | 231 +++ .../RestControllerAnnotationProcessor.java | 320 ++- ...ame1.maven.annotations.AnnotationProcessor | 1 + .../maven/processors/BackendBeansTest.java | 613 ++++++ ...RestControllerAnnotationProcessorTest.java | 20 + .../WebSocketMappingProcessorTest.java | 15 +- .../cn1app-archetype-test.sh | 14 + .../main/resources/agent-skill-agents-md.md | 6 + .../main/resources/agent-skill-claude-stub.md | 5 +- .../common/src/main/resources/common.zip | Bin 255838 -> 259139 bytes .../common/src/main/resources/skill/SKILL.md | 19 +- .../resources/skill/references/backend.md | 274 +++ .../skill/references/full-stack-loop.md | 118 ++ .../model/GeneratorModelMatrixTest.java | 6 + .../com/codename1/backend/DevConsole.java | 106 + .../javase/com/codename1/backend/Reactor.java | 14 + .../com/codename1/backend/VirtualThread.java | 5 + .../codename1/backend/mcp/StdioBridge.java | 108 + .../com/codename1/backend/DevConsole.java | 47 + .../com/codename1/backend/Reactor.java | 24 + .../com/codename1/backend/VirtualThread.java | 13 + vm/backend/native/cn1_backend_server.c | 114 + .../com/codename1/backend/AsyncResult.java | 86 + .../src/com/codename1/backend/AsyncTask.java | 204 ++ .../src/com/codename1/backend/Backend.java | 316 ++- .../src/com/codename1/backend/Config.java | 22 + .../com/codename1/backend/CronSchedule.java | 482 +++++ .../src/com/codename1/backend/DataSource.java | 39 +- .../src/com/codename1/backend/Database.java | 55 + .../src/com/codename1/backend/HttpServer.java | 321 ++- .../com/codename1/backend/HttpSession.java | 230 +++ .../com/codename1/backend/ManagedBean.java | 58 + .../src/com/codename1/backend/Management.java | 307 +++ .../src/com/codename1/backend/RequestLog.java | 111 + .../src/com/codename1/backend/Scheduler.java | 484 +++++ .../com/codename1/backend/SessionStore.java | 53 + .../src/com/codename1/backend/Sessions.java | 423 ++++ .../com/codename1/backend/TaskExecutor.java | 221 ++ .../src/com/codename1/backend/Tasks.java | 176 ++ .../src/com/codename1/backend/Tracing.java | 44 + .../backend/TransactionException.java | 68 + .../com/codename1/backend/Transactions.java | 563 +++++ .../src/com/codename1/backend/Wiring.java | 268 +++ .../codename1/backend/annotations/Async.java | 49 + .../backend/annotations/Autowired.java | 46 + .../codename1/backend/annotations/Bean.java | 46 + .../backend/annotations/Component.java | 53 + .../annotations/ConditionalOnMissingBean.java | 38 + .../annotations/ConditionalOnProperty.java | 48 + .../backend/annotations/Configuration.java | 57 + .../annotations/ConfigurationProperties.java | 55 + .../backend/annotations/Counted.java | 39 + .../backend/annotations/EnableAsync.java | 34 + .../backend/annotations/EnableScheduling.java | 35 + .../codename1/backend/annotations/Lazy.java | 46 + .../backend/annotations/ManagedAttribute.java | 41 + .../backend/annotations/ManagedOperation.java | 40 + .../backend/annotations/ManagedResource.java | 46 + .../backend/annotations/McpParam.java | 40 + .../backend/annotations/McpTool.java | 51 + .../backend/annotations/PostConstruct.java | 37 + .../backend/annotations/PreDestroy.java | 39 + .../backend/annotations/Primary.java | 35 + .../backend/annotations/Profile.java | 44 + .../backend/annotations/Propagation.java | 44 + .../backend/annotations/Qualifier.java | 38 + .../backend/annotations/Repository.java | 37 + .../backend/annotations/RequestScope.java | 34 + .../backend/annotations/Scheduled.java | 84 + .../codename1/backend/annotations/Scope.java | 48 + .../backend/annotations/Service.java | 37 + .../backend/annotations/SessionScope.java | 34 + .../backend/annotations/ThreadKind.java | 42 + .../codename1/backend/annotations/Timed.java | 42 + .../backend/annotations/Transactional.java | 70 + .../codename1/backend/annotations/Value.java | 47 + .../com/codename1/backend/mcp/DevTools.java | 474 +++++ .../com/codename1/backend/mcp/McpArgs.java | 245 +++ .../com/codename1/backend/mcp/McpServer.java | 452 ++++ .../com/codename1/backend/mcp/McpTool.java | 53 + .../codename1/backend/metrics/Counter.java | 66 + .../com/codename1/backend/metrics/Gauge.java | 92 + .../codename1/backend/metrics/Histogram.java | 215 ++ .../codename1/backend/metrics/Instrument.java | 90 + .../backend/metrics/MetricReader.java | 44 + .../codename1/backend/metrics/Metrics.java | 450 ++++ .../codename1/backend/orm/EntityManager.java | 6 + .../backend/orm/TransactionSession.java | 168 ++ .../backend/otel/OtlpMetricExporter.java | 293 +++ .../codename1/backend/otel/OtlpSchema.java | 91 + .../codename1/backend/otel/OtlpTracer.java | 59 +- .../com/codename1/backend/sql/Dialect.java | 20 + .../src/com/codename1/backend/sql/MySql.java | 19 + .../codename1/impl/orm/BackendSqlAccess.java | 24 +- 126 files changed, 16531 insertions(+), 272 deletions(-) create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ConsoleMailer.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Limits.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Login.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Mailer.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PriceCache.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RateLimiter.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Reports.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SignupApi.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SmtpMailer.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SupportTools.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/WiringTest.java create mode 100644 maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java create mode 100644 maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java create mode 100644 maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java create mode 100644 maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeanAnnotationProcessor.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeans.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSources.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWeaver.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/CronCompiler.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendBeansTest.java create mode 100644 scripts/initializr/common/src/main/resources/skill/references/backend.md create mode 100644 scripts/initializr/common/src/main/resources/skill/references/full-stack-loop.md create mode 100644 vm/backend/impl/javase/com/codename1/backend/DevConsole.java create mode 100644 vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java create mode 100644 vm/backend/impl/parparvm/com/codename1/backend/DevConsole.java create mode 100644 vm/backend/src/com/codename1/backend/AsyncResult.java create mode 100644 vm/backend/src/com/codename1/backend/AsyncTask.java create mode 100644 vm/backend/src/com/codename1/backend/CronSchedule.java create mode 100644 vm/backend/src/com/codename1/backend/HttpSession.java create mode 100644 vm/backend/src/com/codename1/backend/ManagedBean.java create mode 100644 vm/backend/src/com/codename1/backend/Management.java create mode 100644 vm/backend/src/com/codename1/backend/RequestLog.java create mode 100644 vm/backend/src/com/codename1/backend/Scheduler.java create mode 100644 vm/backend/src/com/codename1/backend/SessionStore.java create mode 100644 vm/backend/src/com/codename1/backend/Sessions.java create mode 100644 vm/backend/src/com/codename1/backend/TaskExecutor.java create mode 100644 vm/backend/src/com/codename1/backend/Tasks.java create mode 100644 vm/backend/src/com/codename1/backend/TransactionException.java create mode 100644 vm/backend/src/com/codename1/backend/Transactions.java create mode 100644 vm/backend/src/com/codename1/backend/Wiring.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Async.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Autowired.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Bean.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Component.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/ConditionalOnMissingBean.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/ConditionalOnProperty.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Configuration.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/ConfigurationProperties.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Counted.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/EnableAsync.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/EnableScheduling.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Lazy.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/ManagedAttribute.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/ManagedOperation.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/ManagedResource.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/McpParam.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/McpTool.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/PostConstruct.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/PreDestroy.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Primary.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Profile.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Propagation.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Qualifier.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Repository.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/RequestScope.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Scheduled.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Scope.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Service.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/SessionScope.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/ThreadKind.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Timed.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Transactional.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/Value.java create mode 100644 vm/backend/src/com/codename1/backend/mcp/DevTools.java create mode 100644 vm/backend/src/com/codename1/backend/mcp/McpArgs.java create mode 100644 vm/backend/src/com/codename1/backend/mcp/McpServer.java create mode 100644 vm/backend/src/com/codename1/backend/mcp/McpTool.java create mode 100644 vm/backend/src/com/codename1/backend/metrics/Counter.java create mode 100644 vm/backend/src/com/codename1/backend/metrics/Gauge.java create mode 100644 vm/backend/src/com/codename1/backend/metrics/Histogram.java create mode 100644 vm/backend/src/com/codename1/backend/metrics/Instrument.java create mode 100644 vm/backend/src/com/codename1/backend/metrics/MetricReader.java create mode 100644 vm/backend/src/com/codename1/backend/metrics/Metrics.java create mode 100644 vm/backend/src/com/codename1/backend/orm/TransactionSession.java create mode 100644 vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java 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/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/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/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/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/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/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..fdedb628bd6 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java @@ -0,0 +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 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 + 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. + 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/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index e211f5dc05c..20a3c0596f6 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -395,6 +395,195 @@ 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. +=== 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. + +==== 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 -- or `CN1_MAIL_HOST` -- 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`. + +==== 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. + +==== 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. + +==== Testing + +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] +---- + +=== Transactions + +`@Transactional` runs a method in a database transaction: + +[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: the pool's own methods, the daos of an +entity manager, and a `com.codename1.orm.session.Session` injected into the bean. +The connection is borrowed, and the transaction begun, only when the method first +touches the database. The rules are Spring's. An unchecked exception or an +`Error` rolls back and a checked exception commits, which `rollbackFor` and +`noRollbackFor` adjust. `propagation` picks what happens when a transaction is +already open -- join it by default, or `REQUIRES_NEW`, `NESTED` for a savepoint, +`SUPPORTS`, `MANDATORY`, `NOT_SUPPORTED` and `NEVER` -- and `readOnly = true` +starts a transaction that PostgreSQL and MySQL refuse to write in. + +The build doesn't wrap the object in a proxy. It rewrites the compiled method: +the body moves to a method of its own, and the method keeps its name and becomes +three steps -- start the transaction, call that body, commit -- with the rollback +decision written in as `instanceof` tests. That has one consequence Spring users should know about, +and it's a welcome one: the transaction applies however the method is called, +including from `this`, from a private caller and on an object built with `new`, +each of which a Spring proxy skips. + +`Transactions.setRollbackOnly()` rolls back without throwing, for a method that +wants to return a value and still undo its work. + +=== Background work + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Reports.java[tag=backend-background,indent=0] +---- + +An `@Async` method returns at once. Its caller gets a `Future` -- or nothing, for +a `void` method -- that completes when the work does; the body returns +`AsyncResult.of(value)` to supply the result. An exception from a `void` method is +logged, because nobody holds anything to deliver it to. + +`@Scheduled` takes Spring's six-field cron -- seconds first -- or the `@daily` +style macros, or `fixedRate` and `fixedDelay` in milliseconds. A literal cron +expression is parsed by the build, so a typo fails the build instead of producing +a job that never runs. A run never overlaps the previous run of the same job. With +several instances of one server behind a load balancer, each would run every job; +`lock` makes a run first claim a row in a small table in the server's database, +so exactly one instance runs it. + +Background work runs on named executors, sized in configuration with +`cn1.task.executor..threads`. The `thread` attribute chooses the kind of +thread. A platform thread is the default and the right choice for anything that +talks to a database, for the reason given under <>: an +outbound read blocks the host thread under a virtual thread. A virtual thread +suits work that mostly waits on the network, and costs next to nothing to +start. Where there are no virtual threads -- the local JVM run, a TLS server, +Windows -- a virtual task runs on a platform thread instead. + +=== Sessions + +A server keeps state for a client in an `HttpSession`, found again through a +cookie: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Login.java[tag=backend-session,indent=0] +---- + +Nothing is stored and no cookie is sent until something asks for a session with +`getSession(true)`. The cookie is `HttpOnly` and `SameSite=Lax`, and `Secure` on a +TLS server. Sessions live in memory by default; `cn1.session.store=jdbc` keeps +them in the server's database instead, so any instance behind a load balancer can +serve any client, at the price of attributes being limited to what JSON can +store. + === Sharing the contract with the app This is where having the same language on both ends stops being a slogan. An @@ -712,6 +901,88 @@ 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 also exports metrics over OTLP, on the same +endpoint and with the same settings as its traces: request durations by route, +method and status, open connections, database pool use, the task queues, +scheduled job runs, memory, and uptime. `OTEL_METRIC_EXPORT_INTERVAL` sets how +often, a minute by default, and `cn1.otel.metrics.enabled=false` turns metrics off +while leaving traces on. + +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] +---- + +Each numeric `@ManagedAttribute` becomes a gauge named after the resource and the +attribute, `prices.size` here, read when metrics are collected. `@Timed` records +each call's duration as a histogram and `@Counted` counts calls and failures; like +`@Transactional`, they're woven into the method at build time. For anything else, +`Metrics.counter`, `Metrics.histogram` and `Metrics.gauge` create an instrument, +which belongs in a static field so recording a value looks nothing up. + +The management endpoints make the same information available over HTTP, for a +load balancer and for an operator: + +---- +GET /manage/health public; 503 while the server drains +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} +---- + +They're on by default on a development profile. Anywhere else, +`cn1.management.enabled=true` turns them on, and the server refuses to start +unless `cn1.management.token` is set too; every endpoint but health then expects +it as a bearer token. An operation needs the token even on a development profile, +because it changes the running server. + +=== Serving MCP + +The Model Context Protocol lets an AI agent call tools. A backend serves it at +`/mcp`, and there are two reasons to. + +The first 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`, and it refuses a browser +request from any origin other than the server's own host, which keeps a web page +from driving it. + +The second is development. When the server runs through `cn1:backend` on a +development profile, the same endpoint also serves tools for inspecting and +exercising it: `backend_routes`, `backend_beans`, `backend_call` to send it a +request, `backend_requests` for what it served and what failed, `backend_logs`, +`backend_sql` against its database, `backend_jobs`, `backend_metrics` and +`backend_managed`. An agent building the backend uses them the way a developer +uses a debugger and a database console. The server prints the address when it +starts, and Claude Code connects to it 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. None of these tools are compiled into a +server built with `cn1:backend-package`, unless `-Dcn1.backend.devTools=true` +asks for them. + === What it costs Same handler, three ways, plus Go for an outside reference. Two pinned cores, 64 @@ -845,10 +1116,10 @@ no TLS, and what it does need is exactly what a translated binary is good at. * 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 +* 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 diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 9179b5b5598..ff3b8a336de 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -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. +savepoint # ----------------------------------------------------------------------------- # Attaching an agent to a build on a device (MCP-Headless-API.asciidoc). 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..b69c14a3061 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -0,0 +1,457 @@ +/* + * 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("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 + assertEquals(-1, CronSchedule.parse("0 0 0 30 2 *", "UTC").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("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("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("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")); + } + 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)); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a handler's own Set-Cookie survives beside the session's") + void cookiesCoexist() { + HttpServer.Response r = new HttpServer.Response(200, "text/plain", new byte[0], + new LinkedHashMap(java.util.Collections.singletonMap("Set-Cookie", "a=1"))); + Sessions.withHeader(r, "Set-Cookie", "b=2"); + Object both = r.extraHeaders.get("Set-Cookie"); + assertTrue(both instanceof List && ((List)both).size() == 2, String.valueOf(both)); + } + + // ---------------------------------------------------------------- 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 { + McpServer.register(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).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); + // Raw, because HttpURLConnection silently drops a restricted header + // like Origin -- the request would arrive without it and pass. + 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: 127.0.0.1:" + port + + "\r\nOrigin: http://evil.example\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); + } + String answer = new String(raw.toByteArray(), "UTF-8"); + assertTrue(answer.startsWith("HTTP/1.1 403"), "a foreign Origin was served: " + + answer); + } finally { + socket.close(); + } + } finally { + backend.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)); + assertTrue(e.getMessage().contains(McpServer.TOKEN), e.getMessage()); + } + + // ----------------------------------------------------------------- helpers + + /** An application with no beans, for tests that only need the hooks. */ + static final 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 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; + } + + 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/ServerEngineTransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java new file mode 100644 index 00000000000..1e88c3c5822 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java @@ -0,0 +1,225 @@ +/* + * 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); + Transactions.setDefaultDataSource(pool); + 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.Jdbc store = new Sessions.Jdbc(pool); + long now = System.currentTimeMillis(); + HttpSession s = new HttpSession("abc", now, now, 60); + 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 + 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); + store.save(stale, null); + assertTrue(store.purgeExpired(now) >= 1); + assertNull(store.load("old")); + } 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..fbb0ecdeac0 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java @@ -0,0 +1,232 @@ +/* + * 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); + Transactions.setDefaultDataSource(pool); + } + + @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("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("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/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..212933ef4e3 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -0,0 +1,1831 @@ +/* + * 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.Collections; +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; + + 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; + final String name; + 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; + String[] profiles; + 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 != null || !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(); + 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.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 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(); + 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; + } + for (FieldInfo f : cls.getFields()) { + boolean autowired = f.getAnnotation(AUTOWIRED) != null; + AnnotationValues value = f.getAnnotation(VALUE); + if (!autowired && value == null) { + continue; + } + String where = "field " + f.getName() + " of " + cls.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; + } + Point p = new Point(where, Type.getType(f.getDescriptor()), f.getSignature()); + readPoint(p, f.getAnnotations()); + bean.fields.put(f, p); + } + for (MethodInfo m : cls.getMethods()) { + if (m.isConstructor()) { + continue; + } + String where = cls.getSourceName() + "." + m.getName(); + if (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); + for (int i = 0; i < args.length; i++) { + Point p = new Point("parameter " + (i + 1) + " of " + where, args[i], + generics == null ? null : generics[i]); + readPoint(p, parameterAnnotations(m, i)); + call.points.add(p); + } + bean.setters.add(call); + } + if (m.getAnnotation(POST_CONSTRUCT) != null && lifecycle(cls, m, "@PostConstruct")) { + bean.postConstruct.add(m); + } + if (m.getAnnotation(PRE_DESTROY) != null && lifecycle(cls, m, "@PreDestroy")) { + bean.preDestroy.add(m); + } + } + AnnotationValues props = cls.getClassAnnotation(CONFIG_PROPERTIES); + if (props != null) { + bindProperties(bean, props, cls); + } + collectJobs(bean); + collectTools(bean); + collectManaged(bean); + return bean; + } + + 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; + } + 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; + } + bean.onMissing = annotations.get(ON_MISSING) != null; + 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."); + } + bean.profiles = 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); + 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) { + for (MethodInfo lm : bean.cls.getMethods()) { + if (lm.getAnnotation(POST_CONSTRUCT) != null + && lifecycle(bean.cls, lm, "@PostConstruct")) { + bean.postConstruct.add(lm); + } + if (lm.getAnnotation(PRE_DESTROY) != null + && lifecycle(bean.cls, lm, "@PreDestroy")) { + bean.preDestroy.add(lm); + } + } + } + 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; + Set seen = new LinkedHashSet(); + 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 (seen.add(m.getName() + m.getDescriptor())) { + bean.propertySetters.add(m); + } + } + String parent = c.getSuperInternalName(); + c = parent == null || "java/lang/Object".equals(parent) ? null : ctx.lookup(parent); + } + 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 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; + } + for (Bean other : beans) { + if (other != b && !other.onMissing && other.types.contains(b.type)) { + drop.add(b); + break; + } + } + } + for (Bean b : drop) { + beans.remove(b); + byName.remove(b.name); + ctx.getLog().info("cn1: " + b.describe() + " steps aside: another bean has its type"); + } + } + + /// 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. + private void collectJobs(Bean bean) { + AnnotatedClass cls = bean.cls; + for (MethodInfo m : cls.getMethods()) { + 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; + } + Job job = new Job(m, RestClientAnnotationProcessor.simpleName( + cls.getBinaryName().replace('$', '.')) + "." + m.getName()); + 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 : cls.getMethods()) { + 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; + } + 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; + } + tool.paramNames.add(p.getString("value")); + 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 : cls.getMethods()) { + 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; + } + 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", ""); + for (MethodInfo m : cls.getMethods()) { + 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; + } + 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; + } + 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); + names.add(named != null ? named.getString("value") : "arg" + i); + } + if (ok) { + managed.operations.add(m); + managed.operationDescriptions.add(op.getStringOrDefault("description", "")); + managed.operationParams.add(names); + } + } + } + bean.managed = managed; + } + + 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 (!ctx.hasErrors()) { + orderConstruction(); + } + } + + /// 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) && REQUEST_TYPE.equals(type)) { + ctx.error(where, p.where + " asks for the request, but a session bean outlives " + + "the request that built it. Take the HttpSession instead."); + 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) { + 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; + } + } + 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; + } + 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('/', '.')); + b.proxyBinary = qualify(pkg, baseName(b.type) + (b.lazy ? "Cn1Lazy" : "Cn1Scoped")); + } + + /// 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()) + || m.getName().endsWith(BackendWeaver.BODY_SUFFIX) + || 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); + } + String parent = c.getSuperInternalName(); + c = parent == null || "java/lang/Object".equals(parent) ? null : ctx.lookup(parent); + } + return out; + } + + /// 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 + + /// 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. + private void collectAspects() { + for (AnnotatedClass cls : ctx.getClassIndex().values()) { + if (!concerns(cls) || cls.isInterface()) { + continue; + } + AnnotationValues classTx = cls.getClassAnnotation(TRANSACTIONAL); + AnnotationValues classAsync = cls.getClassAnnotation(ASYNC); + Aspects found = null; + for (MethodInfo m : cls.getMethods()) { + if (m.isConstructor() || m.isSynthetic() || "".equals(m.getName()) + || m.getName().endsWith(BackendWeaver.BODY_SUFFIX) + || 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; + } + 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; + 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. + static String baseName(String internal) { + int slash = internal.lastIndexOf('/'); + return internal.substring(slash + 1).replace('$', '_'); + } + + 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/BackendSources.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSources.java new file mode 100644 index 00000000000..3fdef552f18 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSources.java @@ -0,0 +1,831 @@ +/* + * 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") + "." + m.getName() + + BackendWeaver.BODY_SUFFIX + "(" + 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++; + } + sb.append("cn1Error instanceof java.lang.RuntimeException || cn1Error instanceof " + + "java.lang.Error"); + 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"); + fieldAccessor(sb, "com.codename1.backend.TaskExecutor", "E" + index, + "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(" e").append(index).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 "); + if (ret.getSort() != Type.VOID) { + sb.append("return "); + } + sb.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(); + c = parent == null ? null : beans.ctx.lookup(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 + ")"; + case Type.BYTE: return "(byte) " + helper + "intValue(" + args + ")"; + case Type.SHORT: return "(short) " + helper + "intValue(" + args + ")"; + case Type.INT: return helper + "intValue(" + args + ")"; + case Type.LONG: return helper + "longValue(" + args + ")"; + case Type.FLOAT: return "(float) " + helper + "doubleValue(" + 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() {\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(" com.codename1.backend.metrics.Metrics.gauge(") + .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..4c784ebc145 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWeaver.java @@ -0,0 +1,339 @@ +/* + * 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"; + 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. + int bodyAccess = access & ~(Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED + | Opcodes.ACC_PRIVATE | Opcodes.ACC_SYNCHRONIZED + | Opcodes.ACC_VARARGS | Opcodes.ACC_FINAL); + MethodVisitor body = writer.visitMethod(bodyAccess, name + BODY_SUFFIX, + descriptor, signature, exceptions); + MethodVisitor stub = writer.visitMethod(access, 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..ae7b68e0628 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java @@ -0,0 +1,878 @@ +/* + * 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 and handler 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"); + 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"); + 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"); + for (BackendBeans.Bean b : model.order) { + if (b.isEager()) { + initialize(sb, b, b.var, " ", true); + } + } + for (BackendBeans.Bean b : model.beans) { + if (b.managed == null && b.tools.isEmpty()) { + continue; + } + String ref = reference(b); + 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(" com.codename1.backend.Management.register(managed") + .append(b.var).append(");\n"); + sb.append(" managed").append(b.var).append(".registerGauges();\n"); + } + for (BackendBeans.Tool t : b.tools) { + sb.append(" com.codename1.backend.mcp.McpServer.register(new ") + .append(t.adapterBinary).append('(').append(ref).append("));\n"); + } + sb.append(" }\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 = reference(b); + sb.append(" if (").append(ref).append(" != null) {\n"); + sb.append(" handlers.add(new ").append(r.routerBinary).append('(').append(ref) + .append("));\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(); + if (b.profiles != null) { + StringBuilder p = new StringBuilder("com.codename1.backend.Wiring.profiles(config, " + + "new String[] {"); + for (int i = 0; i < b.profiles.length; i++) { + if (i > 0) { + p.append(", "); + } + p.append(BackendSources.quote(b.profiles[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(); + return target + "." + name + "(" + args + ")"; + } + 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; + } + 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; + for (BackendBeans.Point p : c.points) { + skip |= skippable(p); + } + if (skip) { + 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; + } + + /// `@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.single(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 }\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"); + 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"); + List reverse = new ArrayList(model.order); + java.util.Collections.reverse(reverse); + for (BackendBeans.Bean b : reverse) { + if (b.isEager()) { + destroy(sb, b, b.var); + } + } + for (BackendBeans.Bean b : model.beans) { + if (b.lazy) { + destroy(sb, b, b.var + "Real"); + } + } + 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 : b.preDestroy) { + destroyCall(sb, b, ref + "." + callName(m) + "()"); + } + if (b.destroyMethod != null) { + destroyCall(sb, b, ref + "." + b.destroyMethod + "()"); + } + sb.append(" }\n"); + } + + 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) { + sb.append(" public void requestEnded(Object[] beans) {\n"); + for (BackendBeans.Bean b : model.beans) { + if (!BackendBeans.REQUEST.equals(b.scope) || b.preDestroy.isEmpty()) { + 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 : b.preDestroy) { + sb.append(" "); + destroyCall(sb, b, "bean." + callName(m) + "()"); + } + 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(); + if (b.profiles != null) { + sb.append("profile ").append(java.util.Arrays.toString(b.profiles)); + } + 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(); + } + + 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 java.util.List describeRoutes() {\n"); + sb.append(" java.util.List out = new java.util.ArrayList();\n"); + for (String[] r : routes) { + sb.append(" out.add(route(").append(BackendSources.quote(r[0])).append(", ") + .append(BackendSources.quote(r[1])).append(", ").append(BackendSources.quote(r[2])) + .append("));\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"); + sb.append(" synchronized (session) {\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..81c94d8bdc2 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,6 +194,18 @@ 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. @@ -204,9 +229,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(); } @@ -285,16 +307,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 +817,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"; @@ -1041,63 +1055,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. @@ -1167,7 +1124,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 +1211,23 @@ 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 or MCP tools. + if (controllers.isEmpty() && webSockets.isEmpty() && !beans.hasJobs() + && !beans.hasTools()) { // 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 @@ -1304,9 +1269,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 +1286,14 @@ 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; + } daos = hasGeneratedDaos(ctx); + sources.put(wiring, generateWiring(entryPackage)); sources.put(bootstrap, generateBootstrap(entryPackage)); try { List cp = new ArrayList(); @@ -2524,70 +2497,69 @@ 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"); + boolean tools = beans != null && beans.hasTools(); + if (devTools || tools) { + // 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(" }\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"); + 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}); + } + } + Map sockets = new LinkedHashMap(); + for (WebSocketEndpoint e : webSockets.values()) { + sockets.put(e.path, e.binaryName); + routes.add(new String[] {"WEBSOCKET", e.path, 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 +2639,49 @@ private static boolean propertyEnablesTelemetry(ProcessorContext ctx) { || "on".equalsIgnoreCase(v) || "1".equals(v); } + /// 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) { + File base = applicationProperties(ctx); + if (base == null) { + return false; + } + File[] siblings = base.getParentFile() == null ? null : base.getParentFile().listFiles(); + List files = new ArrayList(); + files.add(base); + if (siblings != null) { + for (File f : siblings) { + String n = f.getName(); + if (n.startsWith("application-") && n.endsWith(".properties")) { + files.add(f); + } + } + } + for (File f : files) { + 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 +2723,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..f2ed2b72991 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -0,0 +1,613 @@ +/* + * 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.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()) + .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\"}}"); + 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")); + String metrics = http("GET", port, "/manage/prometheus"); + assertTrue(metrics, metrics.contains("http_server_request_duration_bucket")); + } 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(")); + } + + @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 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; + } + + private ProcessorContext process(File classes) throws Exception { + return process(classes, new RestControllerAnnotationProcessor()); + } + + private ProcessorContext process(File classes, RestControllerAnnotationProcessor proc) + throws Exception { + Map index = ClassScanner.scan(classes); + List cp = new ArrayList(); + for (File f : backendClasspath()) { + cp.add(f.getAbsolutePath()); + } + ProcessorContext ctx = new ProcessorContext(classes, tmp.newFolder(), index, + new SystemStreamLog(), 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..72a66297332 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, 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/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/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 0e49e00c3ec63abfbc9066678fd0bfc68da4836e..eec64cf9c490619557fdba163e7a97c4bd39425d 100644 GIT binary patch delta 5433 zcmaJ_1z1$w)}EOINRKGeodW_Qf|LOWh|-PpNJ)3cAT8YokZwLgIwhqBq@{ZZr9(xK z`se%a{qA$`_s2QUd7gFFKI=Vey?Z@tuf3Os;h(<2E0q|?yo1FIDk@Wa`)@!X9XBBm z{)8NQ`UD>aN|g42J&WkA%gpxN?iV$t(#G-gEpSOzl7v{sQZ@$(^F-x_!uio9s+MWD zXN{8=2j>I(q^1dDd)v|X*!G-UCrhVL{qfsIWTR$w@@FDng?)&N5M4T)bF8yWB~w6J;?z)t3q3D0gU@+hTX)B- zYbC1bxUVyyNV|@pO2{Df5N{=}F#_0mEV`IFu09osx$Zphy zo202L0*mjMO8BtZ-p`{_Ip(>eKy)Ba7EXaKQb^uS-Fs;<%?*mSNHVc<3W%t1EHy)O z-6xPvG=xy;>dzG~k>nUslHACHyJ7Bfn^7%Y$*4M7;}+H8ANpz=8@c230qnl5nFdk& z7Q5CCza)%qB%oL9PDVo=5j@1}x*Xb8npV+J9Z_Ug@}Prwdb&ZU-{INsO|KJ)sj9q) zo!LbP!YBSz01ccY8+|!sEVXuy_7J3pEtMo*7liD-(e9O54_Z^h$*C=>mzdi~p3vf$ zKxwuOp4|!B-Bu)r(^4^Z$i}$1Dh2DvR|+H$`bzxNyAoWRnI#~w9TH$kS*okruTr*> z>TMw68_AHVRIscxAIuij;(^H-k= z_W1Jgv{Aed*Ydc6-(Q|h85CH+gbNH_Wwi^+9{7?ey=zKFt1?PM17)G zanrKiazkITOy%m~7PJTtJQ}!oc?xU#VU*QZbffi`tHm?ESCRCd#WJ}}jX30K<4U7h zv1}Z}FLU*r!$ghdBe)rq{6O46BvUCP@yMdr)3pid$+#_aPS*+9 zZ`k6B`c=s{!(gVkxs@en`Y^cN9;%luc#TTlK!)iuks^m#d@ob0 z6pwXzP3z5qiIXo2_TRbDaS)pbk~a=4XvU}|^WZ*Nq5ZAfQ6{---I*5qY@*goz_nkK z=razijI78~P8r{S`b?KKg_`Dj7(76T<4>&$1SX>Veme~! zt8z8ILHUZoH-mICx-fD^7KyuNBJ{(GOm3}Aaoo3=

GPP1Fk&kzl=8CyVWK{DLQE?2))qrN&>F^r3eDmDc4VRFIy_aLImxs(VFkPU_HUSYR zs86CJu*;$(!PtDLacj;)HL*^xw7&H6H%GJ)u~~q_%-Gb!h7ik zRXoOY1$za2WiN3f*5x&&wUuObwnlAapY$l=4jQk+*7q!fGyDN1?p1@SHS;|rS0vrB z&qyw3#(*m24=uW~7D~p}i5#PvwMBs+zkrge`$OC8L%O2YTH`ow@5Mr%n?3$eM{GG8 zeke>55UiS-m(Egp2e^{Of34s#T2T)GFv`_Fc?*qgM#lco( zkCL|IPIg8E0pmV>p=_Cc-sE5-c({E-?yZs~^VF!@K`P!tXi;SpYH5PjH#l8JEnMD) znaTN!wcJ|aZL{t2ckZOMMK9DmIDs;HhAY*)r8cNY^RwF;GfTaHGrxES^!bCqJ z9hvKH5yU^=x4*uq%Gy^iG!avzja2JwL zGQ1>fw4O8NNnw6TqQW{_yy{^FvP4$ztCSAO;=@-8)g#3lqDHdbdAM|6`LtplYWVZ` zXLBTsC^pI8s?658A|vwBO?%S1z(G1tYby(Rqr&}r&%fziXPf!iD}-VHHut!IuL&6T zaEqI-?%LQv$9oli<4bwgL|+Eclk;RcHk^sQce{e22)(skWRW)Wo~RZkyf82p%D zFB{Id<*rLZlk2GYF%+XbF}CQg7W)cm6cYUaQ?#dies1H<>%y+OLh&VkQu2UDy5JMI zX~|qy-%@5=n?-(ZioU=;r31VFNU(sR0~v2n2!;`Li2f3fTfOaUhTYdbaAdnTwedpB2&# z8KtRcPb+-0bw`_6KU-1lRrNy5GoP*AI9_NyyW9>=TkstHo7>8mSb;UEp|)#2Wskhr)A#A{Dy?cw@xov;wR$>nWidRp`SORl zGNh~nkvWN!{m#bMsO}B9!zI3Nc5&)%3Ch~FI%8H|Egr%JgFVMcq4vy;YVr2;3bT7z zL(?uo?WyB7X{{L*w*K{YY3}UY#wgca_2*bA@HJ{9X;2ZrN2@#`%ba3RSJV0}oS; z)5~a#1;-$(K58$G0dHYnh@%y01=#C7V3Qs{_k5za@HHT5@$u-nP2Ir$aVQZ^-yE}l zR%76JORpS!W#BF?Tp(nAYuhR0ba&{&Jo9PncbJRN$U&j?H)vP?o78fqg!A_{GK1%n zPF0x!OG4jKpH=)b`x+I~4h6!CYYBcxtqqYDUd_`!H7a-C; z=33TjU5ZNErg}fUcI4O2c;~XMCi2?dWQ)dKGnR*l*{r1CDStL^VYU3?>qC0S0fuiQ z$$>n0mdHnMG>(e7w~U1`%7omQvHk3{&DZ??h3PStJ31Z$b4+h-Mb#)=N=Pk9^k5Tm z)a@_(1`satADGv&c|#?7HB;nr7sb*x&eYg)f{#(Ul&%;(kHcXqDndMUWdF$--~CEK z!OF>c;#?n8HY4ivayp{zqVl{sD83^`(QcBrywOqe`B&sa0oxRf_bv@!OCRB6+)+aR zFebhpSL${SGDRYuLu&glaO3iZN8WvwJwpz}CLHZfH?=OH{US8p#f#8rFKF{sj$>H#37nlLjD%)G{CLww)BV|F#mEhNGB)=-&x$2mJl;KOo2}Iwce=8&LCZB ziH|E?l+`Y`Mm}CJHnoZP!p2>a`SN7xZ1}Bd^CDT@<&3sM-`P1N;7!e2M=9lcK{>e# zb6Xehs6JQ?lC`X0!+oIEvQY2GYJwIH!T}Ze=7=h3?F2{jNA;yx4G*1-q*`T-i!N`Ft)q<3S(JE}3Z*690uj#hA9X_XJl;xUP^#b@Yj)*_xw5{DLTZCuSKHoAXvf`45R@WTa8C$YjRfus?SZ% zeCEU-7TV#g&o~^Dd+bj~OWh@litM{aX79^0i`$-F(B565j#%c*c$&1KGidm3;}7QH zjBM~WJ6h_evAX`Sj)qOIp?Ou_=M%R$9(s>PnD7o~S&r(Gnd%2tJ9eq80MbC-; zGrb{?g_@e75C|^-fw27V>CJ0u=JszXZmZTAJ}*JsdVUwDsWx_ISEzEd+9oG@sP)Q^m@>RZ-HY!A3N&F?8x|px3X^M2()vJj8&19<1N>Oby#DF9aAq(5fkv} z(cbvadd*~*z9M%{azQ!eL zI8I3^ZFs{VLwypdhmt&s0@Q3<4vc69kB4`vNz!=vbAZAI>9jHSX_E)VB-B&W2W0G_ z(aVsG>>2;>;=AxVpN%aJe&yrkIA8CX9fk0@lRwBB^G}GMsy>;q9PN?Z5xcV)QOT*% zWF@)!x+Bo)YxY^A-VI{sac|_%pqS|}T5Nn{>bJue@kXjpIK{V{EX#=PfQmuC$y8Cz5^;Q0D>^hf60UiHV@!n)dCh# zts7ii0HlGiZYcP+3wj&enFeTX(){HEL06*+m z56G|t2ou5kp%6=Z=X=ghCg2zbxC;~N2cwn%QJ8T**uMlkgC+HYB+Gy_Y_K0xUj_tV zg#%#dG9Uzljeu>-zylcP2>5*&5Qhy_fcz_f8Y!vZ2}te$Mb@#@f9wG5*0JM8Jz(iN zAVT!62Y?{$Or7j4O~K`9EXvs`KngVW0AST8tatn~03oQj0WiRhdO+I^Knlj*3s!Al z=T@l~+};45z%Cv+`0VoXxj>>`RNOSZBmTPc)cCQLMPjWud=Bg>JHtGKBwqZwow zB1^X7mTiixGh^)Lx+>ki>Fc@AeV*?-=Q+=L&-tBme((F9=lQiU#mD=_tDMl=Lkd-W zE7*JXKjcH95=2la9XL4xG;$a~kXY{V0=v^lqKU1+PkSjA@G5e^J_+S4KkDJ{ouI6; zvhVGZZBMRJt@3cS*UZG1>kT_c9TQ6rU&)}zo6!jR>vdm;6`~y(74Hd6T1pB*Hu%W! z=%My!`Q>9ej+&X(gbBi+Zn{@FWk5ZgIq+tO-4}!)wxD=mS!g^(=!27t0kP5VR*=En zfe^g@C*$JvSDe`Wv_`M4`OO#uNNs<2)#>MH_IBU2>x$OTc&k+LMWUjD6B@4_UX)LH z5YTV>-Luq4k`W5U!#(OsnlrCqU-UFC!Kd+4V10X=@Qc^>u; z^y`OY=`EZtvkT{0xAF}ew1{OG;}YKFgb1&tpIVbI=NaWQTPHEnxOf z?8-JP9OHqUfsF9YfV=dp?u#qGmKPT&&$?-G>qc)6fMRNtT(?s5w{WOQ&PC%a`O>I2>&QO z@_8nO+UJnH9Pzi4W&CFwqV#Pq+2w&W9#&qy@Sk1G?pua)ae1z>_0zlyx3a<_3#6XD z9y9r-gK1J?pM^Dk4~Fk)eR39wdY<_BXBS!Gp=^MC3if3vJa#rWs1`$jKY9yRzN z%9>f=aAr0+Fw?dEo;dw&>2>MNz<%rGxtM`r@=Ar*&u%Y0X{^6$iS$tIOG5>LTjQK$ z!yxx`O%jGsk*p~7M#g)}85XH5VGtTU({FD(bGY@8--yMONj*3Fk$>9O}`Y zcmDJ7BgbVgx z7DabX@yJvZDxD??c-B&>?n0e|ZbAuWxU8D{>~ggx{!g1bw20c~zpN><$7a&}!fGU+ zkepWLb#22eCF5^wv!Xiaa$0=49a2(pI|AdYJjlnBMWe(b^qlI8?P=B2&i3LP8L#IC z2E&AgpRgS|6*s8bX*UO7;OOnWg|y&UBMG|Yc|O6Gg`NKCgE&Q8QjX`5Xp`{p0AAlFOuo~5|Qo@~&_vwj?Jo8eo+ z?$fLAsTw<-VLKM6qZyo$pXS%vviqLG?5~&FXPPW~_>PmeH^V3<|1gE_wu9k$)NMwUZ4e zDWhm|)*VH>q1xD_`XJ=`hAR2E-^a`QQg!2^MuvPPN303MsasOl!gD@0(i@nQ7R_e@ z&#rd9aQ3>?kBytBw(Zs_nwvhgkSO-P?FHu0R=tW5#ngd+;~h?p>h+lKnl+>L)69kd z-dN+ky_V;0LiFa+^9)5ZbywY{lEk7&0beP6iMph@cFl2-{rra)Ck3Mmlh4@QYqmO8 z!)fglzs*0!m(;-ySUC@lAYmPlGY^g;zjr_rHqb*7x*(Pf43Y6(SiW1w!f8-( z1sI6r(g4aI8xW2Q4)}3o=PVEtF>U*I_rIUkxWeTd*amx7z#$~74T`J+8_?GVpjZcx zh1gY~itxOIxvO0L>M!BIDlkKIJK^>1)6a^!^iC hTLXq@p)P<5jl@Qsu;=6duJ8!Amr9^e#Y2F*`VZe3_R;_V 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..99984e2995c --- /dev/null +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -0,0 +1,274 @@ +# 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`). +- **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 or session bean only — `HttpServer.Request` and `HttpSession`. + +**Parameter names do not survive compilation.** `@PathVariable`, `@RequestParam`, +`@RequestHeader` and `@McpParam` always need their name spelled out. + +## 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. + `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()` rolls back without throwing. +- 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. **Rule of thumb: anything that talks to a database or makes an + outbound HTTP call belongs on `PLATFORM`** — on this runtime those calls 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=jdbc` 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 (always on in dev, otherwise `cn1.management.enabled=true` + plus `cn1.management.token`): `/manage/health`, `/manage/metrics`, + `/manage/prometheus`, `/manage/jobs`, `/manage/managed`, and + `POST /manage/managed/{bean}/{operation}`. + +## 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. + +## 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/backend/impl/javase/com/codename1/backend/DevConsole.java b/vm/backend/impl/javase/com/codename1/backend/DevConsole.java new file mode 100644 index 00000000000..e83e00ec04f --- /dev/null +++ b/vm/backend/impl/javase/com/codename1/backend/DevConsole.java @@ -0,0 +1,106 @@ +/* + * 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 {@code System.out} and {@code 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; + System.setOut(new PrintStream(new Tee(System.out, "out"), true)); + System.setErr(new PrintStream(new Tee(System.err, "err"), true)); + } + + /** The newest {@code 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; + } + + 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); + } + } + + 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 3649080602a..ff61a8d8ef4 100644 --- a/vm/backend/impl/javase/com/codename1/backend/Reactor.java +++ b/vm/backend/impl/javase/com/codename1/backend/Reactor.java @@ -70,6 +70,20 @@ 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 2997bc4c5ff..10a5ce691ec 100644 --- a/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java +++ b/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java @@ -40,6 +40,11 @@ public static long create(int fd, int stackBytes) { return 0; } + /** Never a virtual thread on this runtime; see {@link #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; 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..04542a806c2 --- /dev/null +++ b/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java @@ -0,0 +1,108 @@ +/* + * 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.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 -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 { + 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"); + BufferedReader in = new BufferedReader(new InputStreamReader(System.in, + StandardCharsets.UTF_8)); + OutputStream out = System.out; + 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(); + } + } + } + + private static String post(URL url, String token, String body) { + try { + HttpURLConnection c = (HttpURLConnection)url.openConnection(); + 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); + } + return new String(buffer.toByteArray(), StandardCharsets.UTF_8); + } catch (Exception err) { + // The backend is not up: answer the host rather than hang it, under + // the id it asked with -- and not at all for a notification. + java.util.regex.Matcher id = java.util.regex.Pattern + .compile("\"id\"\\s*:\\s*(\"[^\"]*\"|-?[0-9]+)").matcher(body); + if(!id.find()) { + return null; + } + return "{\"jsonrpc\":\"2.0\",\"id\":" + id.group(1) + ",\"error\":{\"code\":-32000," + + "\"message\":\"backend unreachable: " + + String.valueOf(err.getMessage()).replace('"', '\'') + "\"}}"; + } + } +} 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..701818f03cb --- /dev/null +++ b/vm/backend/impl/parparvm/com/codename1/backend/DevConsole.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; + +import java.util.ArrayList; +import java.util.List; + +/** + * The packaged runtime's half of {@link DevConsole}: it cannot redirect the + * process's console, so it captures nothing and says so. The development tools + * are for {@code 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 b0f5ca7d428..e03b09c8e1e 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java +++ b/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java @@ -99,6 +99,30 @@ public void close() { } } + /** + * A non-blocking pipe, {@code {readEnd, writeEnd}}, for waking a thread that + * polls: register the read end, and {@link #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 fbd56bf5e68..94d3953f18c 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java +++ b/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java @@ -54,6 +54,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 + * {@code Tasks.runVirtual(token)}. It has no descriptor -- + * {@link #descriptorOf} answers -1 -- so its host runs it from the ring and + * never parks it on the poller. + * + * @return a handle, or 0 if the stack could not be allocated + */ + public static long createTask(long token, int stackBytes) { + return createTaskImpl(token, stackBytes); + } + /** {@link #resume}: the connection is done and the handle should be freed. */ public static final int FINISHED = 0; /** {@link #resume}: waiting for bytes; its descriptor goes back to the poller. */ @@ -111,6 +123,7 @@ 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 descriptorImpl(long handle); private static native void freeImpl(long handle); diff --git a/vm/backend/native/cn1_backend_server.c b/vm/backend/native/cn1_backend_server.c index 5800f69adfb..0569699bea9 100644 --- a/vm/backend/native/cn1_backend_server.c +++ b/vm/backend/native/cn1_backend_server.c @@ -615,6 +615,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; @@ -702,6 +708,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*)((JAVA_ARRAY)buffer)->data; } @@ -1035,6 +1043,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*)arr->data)[0] = fds[0]; + ((JAVA_ARRAY_INT*)arr->data)[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; @@ -1138,8 +1205,15 @@ extern JAVA_VOID com_codename1_backend_HttpServer_serveVirtual___int(CODENAME_ON 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; }; +/* 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) { @@ -1203,6 +1277,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"); } @@ -1215,6 +1302,7 @@ JAVA_LONG com_codename1_backend_VirtualThread_createImpl___int_int_R_long(CODENA return 0; } a->fd = fd; + a->token = 0; vt = cn1SpawnVirtualThread(cn1BackendVtBody, a, (size_t)stackBytes); if(vt == 0) { static int reported = 0; @@ -1230,6 +1318,27 @@ 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; + } + 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. */ /* @@ -1369,6 +1478,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/src/com/codename1/backend/AsyncResult.java b/vm/backend/src/com/codename1/backend/AsyncResult.java new file mode 100644 index 00000000000..361e895d348 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/AsyncResult.java @@ -0,0 +1,86 @@ +/* + * 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 {@link Future} that is already complete: what an {@code @Async} method + * returns from its body. + * + *

+ *   @Async
+ *   public Future<Report> 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 {@code AsyncResult} and + * {@code 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 {@code value}. */ + public static AsyncResult of(V value) { + return new AsyncResult(value, null); + } + + /** A result that failed with {@code failure}, for a body that reports rather than throws. */ + public static AsyncResult failed(Throwable failure) { + return new AsyncResult(null, failure); + } + + public boolean cancel(boolean mayInterruptIfRunning) { + return false; + } + + public boolean isCancelled() { + return false; + } + + public boolean isDone() { + return true; + } + + public V get() throws ExecutionException { + if(failure != null) { + throw new ExecutionException(failure); + } + return value; + } + + 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..86a698b8d32 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/AsyncTask.java @@ -0,0 +1,204 @@ +/* + * 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 {@code @Async} method, and the {@link Future} its caller holds. + * + *

The build generates a subclass per {@code @Async} method whose fields are the + * call's arguments and whose {@link #call} invokes the method's original body. + * The rewritten method constructs one, hands it to its executor and returns it, + * so the caller's {@code Future} is this object. A method whose body itself + * returns a Future -- {@link 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; + private boolean done; + private boolean cancelled; + private boolean started; + private Object value; + private Throwable failure; + + /** + * @param name what the span is called: the class and the method + * @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(); + } + + /** Runs the method's body. Generated. */ + protected abstract Object call() throws Exception; + + public final void run() { + synchronized(this) { + if(cancelled || started) { + return; + } + started = true; + } + Object result = null; + Throwable error = null; + try { + result = Tracing.inBackground(name, parent, new Tracing.Work() { + public Object run(Span span) throws Exception { + return call(); + } + }); + if(result instanceof Future) { + result = ((Future)result).get(); + } + } catch (ExecutionException err) { + error = err.getCause() != null ? err.getCause() : err; + } catch (Throwable err) { + error = err; + } + synchronized(this) { + value = result; + failure = error; + done = true; + notifyAll(); + } + 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); + } + } + + /** Cancels the call if it has not started. A running call is never interrupted. */ + public synchronized boolean cancel(boolean mayInterruptIfRunning) { + if(started || done) { + return false; + } + cancelled = true; + done = true; + notifyAll(); + return true; + } + + public synchronized boolean isCancelled() { + return cancelled; + } + + public synchronized boolean isDone() { + return done; + } + + public Object get() throws InterruptedException, ExecutionException { + if(VirtualThread.isVirtual()) { + awaitCooperatively(Long.MAX_VALUE); + return completed(); + } + synchronized(this) { + while(!done) { + wait(); + } + return result(); + } + } + + public Object get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + long 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(); + } + } + + /** + * 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. + * + * @return false when the deadline passed first + */ + private boolean awaitCooperatively(long deadline) { + while(!isDone()) { + if(System.currentTimeMillis() >= deadline) { + return false; + } + VirtualThread.yieldNow(); + } + 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; + } + + 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 c75192c6407..4e7cb314d33 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -84,15 +84,23 @@ public final class Backend { * 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; 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) { + this.metricReader = metricReader; 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. */ @@ -125,6 +133,11 @@ 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; + } + /** Blocks until the server stops. */ public void awaitTermination() { server.awaitTermination(); @@ -138,10 +151,35 @@ public void awaitTermination() { * served with it. */ public void stop() { + // 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 (RuntimeException 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(shutdownMillis); + // @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. + if(application != null) { + try { + application.stopped(); + } catch (RuntimeException err) { + System.err.println("Destroying the application's beans failed: " + err); + } + } if(dataSource != null) { dataSource.close(); } + if(metricReader != null) { + metricReader.shutdown(shutdownMillis); + } // 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. @@ -197,6 +235,100 @@ 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 {@code 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 {@link 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; {@code beans} are its + * {@code @RequestScope} beans, whose destroy methods run here. + */ + void requestEnded(Object[] beans); + + /** The scheduler running this application's {@code @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 + * {@link Application#tracksCurrentRequest}. + */ + public static HttpServer.Request currentRequest() { + return (HttpServer.Request)CURRENT_REQUEST.get(); + } + + /** What an {@link Application} is built from. */ + public static final class Environment { + private final Config config; + private final DataSource dataSource; + private final EntityManager entities; + + Environment(Config config, DataSource dataSource, EntityManager entities) { + this.config = config; + this.dataSource = dataSource; + this.entities = entities; + } + + 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. * @@ -236,6 +368,11 @@ public static final class Builder { private boolean handlersNeedADatabase; private boolean quiet; private Tracer tracer; + private Application application; + private com.codename1.backend.metrics.MetricReader metricReader; + private boolean mcp; + private com.codename1.backend.mcp.McpServer.Extension mcpDevTools; + private String serviceName; Builder(Config config) { this.config = config; @@ -253,6 +390,15 @@ public Builder handler(HttpServer.Handler handler) { return this; } + /** + * The build-generated wiring of this server's beans. See + * {@link 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 {@link Handlers}. */ public Builder handlers(Handlers factory) { this.factory = factory; @@ -431,6 +577,34 @@ public Builder tracing(Tracer tracer) { return this; } + /** + * Exports metrics with this reader, once {@code 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 {@code @McpTool} + * methods and, when {@code devTools} is given and the profile is a + * development one, the development tools. The build calls this; see + * {@link com.codename1.backend.mcp.McpServer}. + */ + public Builder mcp(com.codename1.backend.mcp.McpServer.Extension devTools) { + this.mcp = true; + this.mcpDevTools = devTools; + 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; @@ -504,6 +678,36 @@ private Backend startWith(DataSource pool, boolean tracing) 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. + Management management = Management.fromConfig(config); + if(management != null) { + routers.add(management); + } + // The pool a @Transactional method uses before it has asked for one + // by name -- a savepoint, or the transaction's session. + Transactions.setDefaultDataSource(pool); + Tasks.configure(config); + if(application != null) { + HttpServer.Handler[] built = application.create( + new Environment(config, pool, manager)); + if(built != null) { + for(int iter = 0 ; iter < built.length ; iter++) { + if(built[iter] != null) { + routers.add(built[iter]); + } + } + } + } + // After the application: its @McpTool methods are registered while its + // beans are built, and whether the endpoint has anything to serve + // depends on them. + com.codename1.backend.mcp.McpServer mcpServer = mcp + ? com.codename1.backend.mcp.McpServer.fromConfig(config, mcpDevTools, + serviceName) : null; + if(mcpServer != null) { + routers.add(0, mcpServer); + } if(factory != null) { HttpServer.Handler[] built = factory.create(pool, manager); if(built != null) { @@ -529,8 +733,12 @@ private Backend startWith(DataSource pool, boolean tracing) 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."); @@ -607,22 +815,63 @@ public HttpServer.Response handle(HttpServer.Request request) { // context exists to leak there. This is a packaged-runtime path. boolean ownsContext = context != null && tls == null; HttpServer server; + if(application != null) { + Sessions.configure(config, context != null, pool); + } + final Application app = application; + final boolean track = application != null && application.tracksCurrentRequest(); try { server = HttpServer.start(host, listenPort, listenBacklog, workerCount, new HttpServer.Handler() { public HttpServer.Response handle(HttpServer.Request request) throws Exception { - for(int iter = 0 ; iter < chain.length ; iter++) { - HttpServer.Response response = chain[iter].handle(request); - if(response != null) { - return response; + long started = com.codename1.backend.metrics.Metrics + .requestStarted(); + Object previous = null; + if(track) { + previous = CURRENT_REQUEST.get(); + CURRENT_REQUEST.set(request); + } + long startedMillis = RequestLog.enabled + ? System.currentTimeMillis() : 0L; + try { + HttpServer.Response response = null; + try { + for(int iter = 0 ; iter < chain.length ; iter++) { + response = chain[iter].handle(request); + if(response != null) { + break; + } + } + } catch (Exception err) { + RequestLog.record(request, 500, startedMillis, err); + throw err; + } + RequestLog.record(request, response == null ? 404 + : response.getStatus(), startedMillis, null); + // Null is a 404 from here, which is what a + // router answers for a path it does not route. + HttpSession session = request.resolvedSession(); + if(session != null) { + response = Sessions.finish(session, response); + } + com.codename1.backend.metrics.Metrics.requestEnded(started, + request.getMethod(), + response == null ? 404 : response.getStatus()); + return response; + } finally { + if(track) { + CURRENT_REQUEST.set(previous); + } + if(app != null) { + Object[] beans = request.takeScopedBeans(); + if(beans != null) { + app.requestEnded(beans); + } } } - // Null is a 404 from here, which is what a router - // answers for a path it does not route. - return null; } - }, context, webSocketEndpoints == null ? null + }, context, webSocketEndpoints == null && application == null ? null : new HttpServer.WebSocketRoutes() { public void register(HttpServer.WebSocketRegistry registry) throws Exception { @@ -630,7 +879,12 @@ public void register(HttpServer.WebSocketRegistry registry) // and for the same reason: an endpoint that needs // the database declares it rather than reaching // for a static. - webSocketEndpoints.register(registry, pool, manager); + if(webSocketEndpoints != null) { + webSocketEndpoints.register(registry, pool, manager); + } + if(application != null) { + application.registerWebSockets(registry); + } } }); } catch (Exception err) { @@ -642,8 +896,44 @@ public void register(HttpServer.WebSocketRegistry registry) // 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. + boolean measuring = management != null; + if(metricReader != null) { + try { + measuring |= metricReader.open(config); + } catch (IOException err) { + server.stop(0); + throw err; + } + } + if(measuring) { + com.codename1.backend.metrics.Metrics.enableServer(server, pool); + } Backend backend = new Backend(server, pool, manager, config, drain, - tracing ? tracer : null); + tracing ? tracer : null, application, + metricReader != null && measuring ? metricReader : null); + if(management != null) { + management.attach(backend); + } + if(mcpServer != null) { + mcpServer.attach(backend); + if(!quiet) { + // The line an agent's setup instructions point at. + System.out.println("cn1: MCP endpoint at http" + + (context != null ? "s" : "") + "://127.0.0.1:" + server.getPort() + + mcpServer.getPath() + + (mcpServer.hasDevTools() ? " (with development tools)" : "")); + } + } + if(application != null) { + try { + application.started(backend); + } catch (Exception err) { + // A job or exporter that cannot start is a server that is not + // what its build says it is; stop the one that is listening. + backend.stop(); + throw err; + } + } if(!quiet) { announce(backend, listenPort, context != null); } diff --git a/vm/backend/src/com/codename1/backend/Config.java b/vm/backend/src/com/codename1/backend/Config.java index ebb2ce623de..e752c25cf0b 100644 --- a/vm/backend/src/com/codename1/backend/Config.java +++ b/vm/backend/src/com/codename1/backend/Config.java @@ -159,6 +159,10 @@ 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; @@ -320,6 +324,24 @@ public String describe() { return out.toString(); } + /** + * Every key the properties files 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 = 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); 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..1b4f313a0b3 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/CronSchedule.java @@ -0,0 +1,482 @@ +/* + * 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 {@code @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. {@link #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 {@code *} (or {@code ?} for the two day fields), a + * value, a range {@code a-b}, a step {@code *}{@code /n} or {@code a-b/n} or + * {@code a/n}, or a comma-separated list of those. Months and days take their + * three-letter English names; Sunday is 0 or 7. {@code L} as the day of month is + * the last day of the month. The macros {@code @yearly}, {@code @annually}, + * {@code @monthly}, {@code @weekly}, {@code @daily}, {@code @midnight} and + * {@code @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 far to look for a match before concluding there is none (Feb 30th). */ + private static final int SEARCH_DAYS = 366 * 5; + + /** + * The masks the build computed. Called by generated code. + * + * @param zone a zone ID, a fixed offset such as {@code +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(seconds == 0 || minutes == 0 || hours == 0 || months == 0 || daysOfWeek == 0 + || (daysOfMonth == 0 && !lastDayOfMonth)) { + throw new IllegalArgumentException("A cron field allows no value: " + expression); + } + } + + /** 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; + } + 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, + field(fields[4], 1, 12, MONTHS, false, expression, "month"), + dow, last, zone, expression); + } + + 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 {@code 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 candidate = toUtc(candidateLocal); + if(candidate > afterMillis) { + return candidate; + } + // A DST fold mapped this back to or before the start: step past it. + t = candidate <= t ? t + 1000L : candidate + 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 {@code 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 {@code 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)); + } + + 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}; + } + + public String toString() { + return expression + (zone.equals("UTC") ? "" : " (" + zone + ")"); + } +} diff --git a/vm/backend/src/com/codename1/backend/DataSource.java b/vm/backend/src/com/codename1/backend/DataSource.java index bfe14955df5..f8f7604413c 100644 --- a/vm/backend/src/com/codename1/backend/DataSource.java +++ b/vm/backend/src/com/codename1/backend/DataSource.java @@ -234,7 +234,25 @@ 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. @@ -311,7 +329,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); + } + + /** {@link #release}, for a connection that is certainly not a transaction's. */ + synchronized void releaseToPool(Database db) { if(db == null) { return; } @@ -352,6 +380,13 @@ public Object withConnection(Work body) throws Exception { * on SQLite it will simply block against the write lock the first one holds. */ public Object inTransaction(final 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. + return body.run(joined); + } return withConnection(new Work() { public Object run(final Database db) throws Exception { return db.transaction(new Database.Work() { diff --git a/vm/backend/src/com/codename1/backend/Database.java b/vm/backend/src/com/codename1/backend/Database.java index bd4ca0d0081..131d518e20c 100644 --- a/vm/backend/src/com/codename1/backend/Database.java +++ b/vm/backend/src/com/codename1/backend/Database.java @@ -779,6 +779,61 @@ public synchronized void beginTransaction() throws IOException { transactionOwner = Thread.currentThread(); } + /** + * Begins a transaction like {@link #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 + * {@link #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(); diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index ec02a04d38a..0585ebc7ba1 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -122,6 +122,11 @@ public static final class Request { private byte[] canonicalTarget; private int canonicalLength; private boolean canonicalChecked; + /** This request's session once looked up; see {@link #getSession(boolean)}. */ + private HttpSession session; + private boolean sessionResolved; + /** The request's {@code @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) { @@ -576,6 +581,68 @@ void releaseRetained() { this.slices = null; this.body = null; this.headers = null; + this.session = null; + this.sessionResolved = false; + this.scopedBeans = 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 + * {@code create} set and no valid cookie, a new one the response will + * send a cookie for. Null when there is none and {@code create} is false. + * See {@link Sessions} for the cookie and where sessions are kept. + */ + public HttpSession getSession(boolean create) { + if(sessionResolved && (session != null || !create)) { + return session; + } + try { + session = Sessions.find(sessionResolved ? null + : Sessions.cookieValue(getHeader("cookie"), Sessions.getCookieName()), + create); + } catch (IOException err) { + throw new IllegalStateException("The session store failed: " + + err.getMessage()); + } + sessionResolved = true; + 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; + } + + /** + * This request's {@code @RequestScope} beans, grown to hold at least + * {@code 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; } /** @@ -607,6 +674,9 @@ 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; } /** @@ -1878,6 +1948,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++) { @@ -2378,6 +2457,9 @@ private void closePollers() { VtHost[] hosts = vtHosts; if(hosts != null) { for(int iter = 0 ; iter < hosts.length ; iter++) { + if(hosts[iter] != null) { + releaseTaskInbox(hosts[iter]); + } // 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. @@ -2510,6 +2592,133 @@ 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 {@link #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; + } + + /** + * Runs {@code task} on a virtual thread of the running server's hosts. + * Answers false, having done nothing, when there is none to run it on; the + * caller then runs it on a platform thread. + * + *

The task is queued on a host and the host woken through its pipe; the + * host 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 = ACTIVE_SERVER; + if(task == null || server == null || !server.running || server.vtHosts == null) { + return false; + } + VtHost[] hosts = server.vtHosts; + long token; + VtHost host; + synchronized(VIRTUAL_TASKS) { + token = ++nextVirtualTask; + VIRTUAL_TASKS.put(new Long(token), task); + int index = nextTaskHost; + nextTaskHost = index + 1 >= hosts.length ? 0 : index + 1; + host = hosts[index]; + } + if(host == null) { + takeVirtualTask(token); + return false; + } + synchronized(host.inbox) { + host.inbox.add(new Long(token)); + } + 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(); + } + for(int iter = 0 ; iter < tokens.length ; iter++) { + Runnable task = takeVirtualTask(tokens[iter].longValue()); + if(task != null) { + Tasks.platform(task); + } + } + if(host.wakeRead >= 0) { + ServerSocket.closeFd(host.wakeRead); + ServerSocket.closeFd(host.wakeWrite); + host.wakeRead = -1; + host.wakeWrite = -1; + } + } + + /** The task a virtual thread's body runs, handed over once. */ + static Runnable takeVirtualTask(long token) { + synchronized(VIRTUAL_TASKS) { + return (Runnable)VIRTUAL_TASKS.remove(new Long(token)); + } + } + + /** + * Gives the tasks queued on {@code 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(int iter = 0 ; iter < tokens.length ; iter++) { + long token = tokens[iter].longValue(); + long handle = VirtualThread.createTask(token, VT_STACK_BYTES); + if(handle == 0) { + Runnable task = takeVirtualTask(token); + if(task != null) { + Tasks.platform(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) { + VirtualThread.free(handle); + return; + } + me.ringAdd(handle); + } + /** * What a connection's virtual thread runs. Reached from native code only, * which is also what keeps it from being dead-code eliminated. @@ -2571,6 +2780,16 @@ 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 java.util.ArrayList inbox = new java.util.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; } @@ -2873,6 +3092,7 @@ private void runVirtualThreadHost(int index) { 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. @@ -2900,6 +3120,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 { @@ -2926,7 +3151,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; } @@ -5311,21 +5541,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)); + } } } } @@ -7323,29 +7560,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..c07361f0cca --- /dev/null +++ b/vm/backend/src/com/codename1/backend/HttpSession.java @@ -0,0 +1,230 @@ +/* + * 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. + * + *

+ *   @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 + * {@code getSession(true)}, so a server that never does sets no cookie and keeps + * nothing. The cookie is {@code HttpOnly}, {@code SameSite=Lax} and, on a TLS + * server, {@code Secure}; its name, lifetime and store are configured under + * {@code cn1.session.*}. See {@link Sessions}. + * + *

Attributes live in the {@link SessionStore}. The in-memory store keeps any + * object; the database store keeps what {@link Json} can write -- strings, + * numbers, booleans, and maps and lists of those -- because it has to read them + * back in another process. + * + *

A {@code @SessionScope} bean lives on the session object itself and is + * never written to a store, so a session read back from the database store + * starts with a fresh one. + */ +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; + 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; + 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); + } + dirty = true; + } + + public synchronized void removeAttribute(String name) { + checkValid(); + if(attributes.remove(name) != null) { + 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(); + beans = null; + 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. + * + * @return the new id + */ + public String changeSessionId() { + String next = Sessions.newId(); + synchronized(this) { + checkValid(); + if(previousId == null) { + previousId = id; + } + id = next; + dirty = true; + } + return next; + } + + private void checkValid() { + if(invalid) { + throw new IllegalStateException("This session has been invalidated"); + } + } + + // ---------------------------------------------------------------- store use + + synchronized void touch(long now) { + lastAccessed = now; + } + + synchronized boolean isExpired(long now) { + return maxInactiveSeconds > 0 && now - lastAccessed > maxInactiveSeconds * 1000L; + } + + synchronized void markNew() { + fresh = true; + dirty = true; + } + + synchronized boolean isDirty() { + return dirty; + } + + synchronized void clean() { + dirty = false; + fresh = false; + previousId = null; + } + + /** The id the client held before {@link #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 {@code @SessionScope} beans of this session, by the slot the build gave + * each. Called by generated code. + */ + public synchronized Object[] scopedBeans(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/ManagedBean.java b/vm/backend/src/com/codename1/backend/ManagedBean.java new file mode 100644 index 00000000000..a7c473544bd --- /dev/null +++ b/vm/backend/src/com/codename1/backend/ManagedBean.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.backend; + +import java.util.Map; + +/** + * A {@code @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..1d914529692 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Management.java @@ -0,0 +1,307 @@ +/* + * 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. + * + *

+ *   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; {@code cn1.management.enabled} + * turns it on or off explicitly, and {@code cn1.management.path} moves it. + * Everything but health needs {@code 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 static final List BEANS = new ArrayList(); + + private final String path; + private final byte[] token; + private final boolean development; + private Backend backend; + + 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.get(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.get(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; + } + + /** Registers a managed bean. Generated code calls this at start-up. */ + public static synchronized void register(ManagedBean bean) { + for(int iter = 0 ; iter < BEANS.size() ; iter++) { + if(((ManagedBean)BEANS.get(iter)).getObjectName().equals(bean.getObjectName())) { + BEANS.set(iter, bean); + return; + } + } + BEANS.add(bean); + } + + /** The registered managed beans. */ + public static synchronized List beans() { + return new ArrayList(BEANS); + } + + /** Every managed bean with its attributes' current values and its operations. */ + public static List describeBeans() { + List out = new ArrayList(); + List all = beans(); + for(int iter = 0 ; iter < all.size() ; iter++) { + ManagedBean bean = (ManagedBean)all.get(iter); + 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 + */ + public static Object invoke(String objectName, String operation, Map arguments) + throws Exception { + List all = beans(); + for(int iter = 0 ; iter < all.size() ; iter++) { + ManagedBean bean = (ManagedBean)all.get(iter); + 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); + } + + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + String target = request.getTarget(); + if(target == null || !target.startsWith(path)) { + return null; + } + int query = target.indexOf('?'); + String rest = (query < 0 ? target : target.substring(0, query)).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()); + } + return null; + } + if(operation) { + String[] parts = split(rest.substring("/managed/".length())); + if(parts == null) { + return request.respondJson(404, error("Expected /managed/{bean}/{operation}")); + } + Map arguments = new LinkedHashMap(); + String body = request.getBody(); + if(body != null && body.trim().length() > 0) { + arguments = Json.parseObject(body); + } + try { + Map result = new LinkedHashMap(); + // Bean and operation names are Java identifiers, so there is + // nothing to percent-decode: an escaped one matches nothing. + result.put("result", invoke(parts[0], parts[1], arguments)); + return request.respondJson(200, result); + } catch (IllegalArgumentException err) { + return request.respondJson(404, 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(); + up = "ok".equals(server.get("status")); + out.put("status", up ? "UP" : "DRAINING"); + out.put("uptimeSeconds", server.get("uptimeSeconds")); + if(backend.getDataSource() != null) { + Map db = new LinkedHashMap(); + db.put("open", new Integer(backend.getDataSource().getOpenCount())); + db.put("idle", new Integer(backend.getDataSource().getIdleCount())); + out.put("database", db); + } + } else { + out.put("status", "UP"); + } + 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"); + } + } +} 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..eb1c4a2988f --- /dev/null +++ b/vm/backend/src/com/codename1/backend/RequestLog.java @@ -0,0 +1,111 @@ +/* + * 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 { + static boolean enabled; + private static Map[] ring = new Map[0]; + private static int next; + private static int size; + + private RequestLog() { + } + + /** Starts keeping the last {@code capacity} requests. */ + public static synchronized void enable(int capacity) { + ring = new Map[capacity < 1 ? 1 : capacity]; + next = 0; + size = 0; + enabled = true; + } + + static void record(HttpServer.Request request, int status, long startedMillis, + Throwable error) { + if(!enabled) { + return; + } + Map entry = new LinkedHashMap(); + entry.put("time", new Long(startedMillis)); + entry.put("method", request.getMethod()); + entry.put("target", request.getTarget()); + entry.put("status", new Integer(status)); + entry.put("millis", new Long(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(RequestLog.class) { + ring[next] = entry; + next = (next + 1) % ring.length; + if(size < ring.length) { + size++; + } + } + } + + /** The newest {@code limit} requests, newest first; only failures when asked. */ + public static 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..41b51e2bb3e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -0,0 +1,484 @@ +/* + * 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 {@code @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 {@code lock}, a run first claims a row in {@code 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 {@code 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; + private Thread thread; + private boolean started; + private boolean stopped; + private boolean lockTableReady; + + /** + * @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(int iter = 0 ; iter < bytes.length ; iter++) { + sb.append("0123456789abcdef".charAt((bytes[iter] >> 4) & 15)); + sb.append("0123456789abcdef".charAt(bytes[iter] & 15)); + } + return sb.toString(); + } + + /** Registers a cron job. Generated code calls this before {@link #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 {@code 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 {@code 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"); + } + jobs.add(job); + } + + /** Starts the timetable. */ + public synchronized void start() { + if(started) { + return; + } + started = true; + long now = System.currentTimeMillis(); + for(int iter = 0 ; iter < jobs.size() ; iter++) { + Job job = (Job)jobs.get(iter); + 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 = now + Math.max(0, job.initialDelay); + } + } + if(jobs.isEmpty()) { + return; + } + thread = new Thread(new Runnable() { + public void run() { + loop(); + } + }, "cn1-scheduler"); + thread.setDaemon(true); + thread.start(); + } + + /** + * Stops starting runs and waits up to {@code waitMillis} for the ones in + * progress. + */ + public void stop(long waitMillis) { + synchronized(this) { + stopped = true; + notifyAll(); + } + long 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(int iter = 0 ; iter < jobs.size() ; iter++) { + if(((Job)jobs.get(iter)).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(int iter = 0 ; iter < jobs.size() ; iter++) { + Job job = (Job)jobs.get(iter); + 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; + } + job.running = true; + job.next = job.kind == FIXED_DELAY ? Long.MAX_VALUE : following(job, now); + try { + job.executor.execute(new Runnable() { + 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 = 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. + while(next <= now) { + next += job.period; + } + return next; + } + return now + job.period; + } + + private void runJob(final Job job) { + long start = System.currentTimeMillis(); + String error = null; + boolean ran = false; + try { + if(job.lock == null || claim(job, start)) { + ran = true; + Tracing.inBackground("scheduled " + job.name, null, new Tracing.Work() { + public Object run(Span span) throws Exception { + job.body.run(); + return null; + } + }); + } + } catch (Throwable err) { + error = String.valueOf(err); + System.err.println("Scheduled job " + job.name + " failed: " + err); + } finally { + if(ran && job.lock != null) { + release(job); + } + long end = System.currentTimeMillis(); + if(ran) { + 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 = end + job.period; + } + notifyAll(); + } + } + } + + private boolean claim(Job job, long now) throws IOException { + prepareLockTable(); + long until = now + job.lockAtMostFor; + int updated = locks.execute("UPDATE " + LOCK_TABLE + " SET lock_until = ?, locked_at = ?, " + + "locked_by = ? WHERE name = ? AND lock_until <= ?", + new Object[] {new Long(until), new Long(now), instance, job.lock, new Long(now)}); + if(updated > 0) { + return true; + } + try { + locks.execute("INSERT INTO " + LOCK_TABLE + " (name, lock_until, locked_at, " + + "locked_by) VALUES (?, ?, ?, ?)", + new Object[] {job.lock, new Long(until), new Long(now), instance}); + return true; + } catch (IOException held) { + // The row exists and its claim has not expired: another instance + // is running this job. + return false; + } + } + + private void release(Job job) { + try { + locks.execute("UPDATE " + LOCK_TABLE + " SET lock_until = ? WHERE name = ? AND " + + "locked_by = ?", new Object[] {new Long(System.currentTimeMillis()), + job.lock, instance}); + } 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); + } + } + + 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(int iter = 0 ; iter < jobs.size() ; iter++) { + final Job job = (Job)jobs.get(iter); + if(job.name.equals(name)) { + if(job.running || job.executor == null) { + return false; + } + job.running = true; + job.executor.execute(new Runnable() { + public void run() { + runJob(job); + } + }); + 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(int iter = 0 ; iter < jobs.size() ; iter++) { + Job job = (Job)jobs.get(iter); + 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", new Long(job.runs)); + m.put("failures", new Long(job.failures)); + m.put("skipped", new Long(job.skipped)); + if(job.next != Long.MAX_VALUE) { + m.put("nextRunEpochMillis", new Long(job.next)); + } + if(job.lastStart != 0) { + m.put("lastRunEpochMillis", new Long(job.lastStart)); + m.put("lastDurationMillis", new Long(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..185104f07f6 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/SessionStore.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; + +import java.io.IOException; + +/** + * Where {@link HttpSession}s are kept between requests. + * + *

Two are provided, chosen by {@code cn1.session.store}: {@code memory} (the + * default) and {@code jdbc}, 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 {@link 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, {@code 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 {@code 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..cd28542b10d --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -0,0 +1,423 @@ +/* + * 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.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.codename1.backend.sql.Dialect; + +/** + * How a server keeps {@link HttpSession}s: the cookie, the lifetime and the + * store, read from configuration when the server starts. + * + *

+ *   cn1.session.cookie=CN1SESSION      # the cookie's name
+ *   cn1.session.timeout=1800           # seconds of inactivity, 0 for never
+ *   cn1.session.store=memory           # or jdbc
+ *   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 {@code getSession}, and a request that never + * calls it costs one field check when it ends. + */ +public final class Sessions { + private static String cookieName = "CN1SESSION"; + private static int timeoutSeconds = 1800; + private static String sameSite = "Lax"; + private static boolean secure; + private static SessionStore store = new Memory(); + private static long lastPurge; + private static final long PURGE_INTERVAL = 60000L; + + private Sessions() { + } + + /** + * Reads {@code cn1.session.*}. Called by the server when it starts. + * + * @param tls whether the server terminates TLS, for {@code secure=auto} + * @param pool the database, for {@code store=jdbc} + */ + public static synchronized void configure(Config config, boolean tls, DataSource pool) + throws IOException { + cookieName = config.get("cn1.session.cookie", "CN1SESSION"); + timeoutSeconds = config.getInt("cn1.session.timeout", 1800); + 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"); + } + sameSite = site; + String secureSetting = config.get("cn1.session.secure", "auto"); + secure = "auto".equalsIgnoreCase(secureSetting) ? tls + : "true".equalsIgnoreCase(secureSetting); + if("None".equalsIgnoreCase(sameSite) && !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("jdbc".equalsIgnoreCase(kind)) { + if(pool == null) { + throw new IOException("cn1.session.store=jdbc needs a database, and this " + + "server has none"); + } + store = new Jdbc(pool); + } else if("memory".equalsIgnoreCase(kind)) { + if(!(store instanceof Memory)) { + store = new Memory(); + } + } else { + throw new IOException("cn1.session.store is \"" + kind + + "\"; it must be memory or jdbc"); + } + } + + /** Replaces the store, for one of the application's own. */ + public static synchronized void setStore(SessionStore replacement) { + if(replacement == null) { + throw new IllegalArgumentException("No store"); + } + store = replacement; + } + + /** The store sessions are kept in. */ + public static synchronized SessionStore getStore() { + return store; + } + + /** The name of the session cookie. */ + public static 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()); + } + } + + /** + * The request's session, creating one when {@code create} is set. What + * {@code Request.getSession} calls. + */ + static HttpSession find(String cookieValue, boolean create) throws IOException { + SessionStore s; + int timeout; + synchronized(Sessions.class) { + s = store; + timeout = timeoutSeconds; + } + long now = System.currentTimeMillis(); + purgeIfDue(s, now); + if(cookieValue != null && cookieValue.length() > 0) { + HttpSession found = s.load(cookieValue); + if(found != null && found.isValid() && !found.isExpired(now)) { + found.touch(now); + return found; + } + if(found != null) { + s.delete(cookieValue); + } + } + if(!create) { + return null; + } + HttpSession created = new HttpSession(newId(), now, now, timeout); + created.markNew(); + return created; + } + + private static void purgeIfDue(SessionStore s, long now) { + synchronized(Sessions.class) { + if(now - lastPurge < PURGE_INTERVAL) { + return; + } + lastPurge = now; + } + try { + 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. + */ + static HttpServer.Response finish(HttpSession session, HttpServer.Response response) + throws IOException { + if(session == null) { + return response; + } + SessionStore s = getStore(); + String cookie = null; + String previous = session.previousId(); + if(!session.isValid()) { + s.delete(session.getId()); + if(previous != null) { + s.delete(previous); + } + cookie = cookie("", 0); + } else if(session.isDirty()) { + boolean announce = session.isNew() || previous != null; + s.save(session, previous); + if(announce) { + cookie = cookie(session.getId(), -1); + } + session.clean(); + } else if(s instanceof Jdbc) { + ((Jdbc)s).touchIfStale(session); + } + if(cookie == null || response == null) { + return response; + } + return withHeader(response, "Set-Cookie", cookie); + } + + private static 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(); + } + + /** + * {@code response} with one more header. The handler's own header map is + * never modified -- it may be a constant shared by every response -- and a + * header it 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 keys = copy.keySet().iterator(); + while(keys.hasNext()) { + Object key = keys.next(); + if(key != null && name.equalsIgnoreCase(String.valueOf(key))) { + Object existing = copy.get(key); + 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); + response.extraHeaders = copy; + return response; + } + } + copy.put(name, value); + response.extraHeaders = copy; + return response; + } + + /** The value of the cookie called {@code 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(); + + public synchronized HttpSession load(String id) { + return (HttpSession)sessions.get(id); + } + + public synchronized void save(HttpSession session, String previousId) { + if(previousId != null) { + sessions.remove(previousId); + } + sessions.put(session.getId(), session); + } + + public synchronized void delete(String id) { + sessions.remove(id); + } + + public synchronized int purgeExpired(long now) { + int purged = 0; + Iterator it = sessions.values().iterator(); + while(it.hasNext()) { + HttpSession s = (HttpSession)it.next(); + if(s.isExpired(now) || !s.isValid()) { + it.remove(); + purged++; + } + } + return purged; + } + + public synchronized int size() { + return sessions.size(); + } + } + + /** + * Sessions in the server's database, in {@code cn1_http_session}, so every + * instance of a server sees every session. Attributes are stored as JSON. + */ + public static final class Jdbc 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 boolean ready; + + public Jdbc(DataSource pool) { + this.pool = pool; + } + + 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) + ")", null); + ready = true; + } + + public HttpSession load(String id) throws IOException { + prepare(); + Map row = pool.queryOne("SELECT created, last_accessed, max_inactive, attributes " + + "FROM " + TABLE + " WHERE id = ?", new Object[] {id}); + if(row == null) { + return null; + } + HttpSession s = new HttpSession(id, number(row.get("created")), + number(row.get("last_accessed")), (int)number(row.get("max_inactive"))); + 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; + } + + public void save(HttpSession session, String previousId) throws IOException { + prepare(); + String json = Json.write(session.attributesCopy()); + Object[] values = new Object[] {new Long(session.getLastAccessedTime()), + new Integer(session.getMaxInactiveInterval()), json, session.getId()}; + int updated = pool.execute("UPDATE " + TABLE + " SET last_accessed = ?, " + + "max_inactive = ?, attributes = ? WHERE id = ?", values); + if(updated == 0) { + pool.execute("INSERT INTO " + TABLE + " (id, created, last_accessed, " + + "max_inactive, attributes) VALUES (?, ?, ?, ?, ?)", + new Object[] {session.getId(), new Long(session.getCreationTime()), + new Long(session.getLastAccessedTime()), + new Integer(session.getMaxInactiveInterval()), json}); + } + session.storedAccessed = session.getLastAccessedTime(); + if(previousId != null) { + delete(previousId); + } + } + + 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 < TOUCH_INTERVAL) { + return; + } + pool.execute("UPDATE " + TABLE + " SET last_accessed = ? WHERE id = ?", + new Object[] {new Long(now), session.getId()}); + session.storedAccessed = now; + } + + public void delete(String id) throws IOException { + prepare(); + pool.execute("DELETE FROM " + TABLE + " WHERE id = ?", new Object[] {id}); + } + + public int purgeExpired(long now) throws IOException { + prepare(); + return pool.execute("DELETE FROM " + TABLE + " WHERE max_inactive > 0 AND " + + "last_accessed + max_inactive * 1000 < ?", new Object[] {new Long(now)}); + } + + public int size() { + return -1; + } + } +} 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..54e5127168f --- /dev/null +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -0,0 +1,221 @@ +/* + * 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 {@code cn1.task.executor..threads} and + * {@code cn1.task.executor..kind} ({@code platform} or {@code virtual}), + * and obtained through {@link 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 { + private final String name; + private final 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; + + TaskExecutor(String name, boolean virtual, int size) { + this.name = name; + this.virtual = virtual; + this.size = size < 1 ? 1 : size; + } + + public String getName() { + return name; + } + + /** Whether this executor asks for virtual threads. */ + public boolean isVirtual() { + return virtual; + } + + public int getSize() { + return size; + } + + /** Tasks waiting for a thread. */ + public synchronized int getQueueDepth() { + return queue.size(); + } + + /** 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 {@code task} on this executor. + * + * @throws IllegalStateException once the server has stopped it + */ + public void execute(Runnable task) { + if(task == null) { + return; + } + if(virtual && HttpServer.submitVirtualTask(new Counted(this, task))) { + return; + } + synchronized(this) { + if(shutdown) { + throw new IllegalStateException("Executor " + name + " has been shut down"); + } + queue.addLast(task); + if(workers == null) { + startWorkers(); + } + notify(); + } + } + + private void startWorkers() { + workers = new Thread[size]; + for(int iter = 0 ; iter < size ; iter++) { + Thread t = new Thread(new Runnable() { + 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; + try { + task.run(); + ok = true; + } catch (Throwable err) { + System.err.println("Task " + task + " on executor " + name + " failed: " + err); + } finally { + synchronized(this) { + active--; + completed++; + if(!ok) { + failed++; + } + if(shutdown && active == 0 && queue.isEmpty()) { + notifyAll(); + } + } + } + } + + synchronized void recordFailure() { + failed++; + } + + /** + * Stops taking tasks and waits up to {@code waitMillis} for the queued and + * running ones to finish. + */ + public synchronized void shutdown(long waitMillis) { + shutdown = true; + notifyAll(); + long deadline = System.currentTimeMillis() + Math.max(0, waitMillis); + while((active > 0 || !queue.isEmpty()) && waitMillis > 0) { + long left = deadline - System.currentTimeMillis(); + if(left <= 0) { + break; + } + try { + wait(left); + } catch (InterruptedException err) { + break; + } + } + } + + public String toString() { + return name + (virtual ? " (virtual)" : " (" + size + " threads)"); + } + + /** A task on a virtual thread, counted like one on the pool. */ + 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; + } + + public void run() { + owner.runCountedVirtual(task); + } + } + + private void runCountedVirtual(Runnable task) { + synchronized(this) { + active++; + } + 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..02018532198 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -0,0 +1,176 @@ +/* + * 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; + +/** + * Where background work runs: the named {@link TaskExecutor}s, and two + * shorthands for running something once. + * + *

+ *   Tasks.platform(new Runnable() { public void run() { rebuildIndex(); } });
+ *   Tasks.virtual(new Runnable() { public void run() { pingWebhooks(); } });
+ * 
+ * + *

The build's generated code asks for an executor once per {@code @Async} + * method and keeps it, so this map is read once per method, not once per call. + * + *

Which thread

+ * + *

A virtual thread is the cheaper one, and the right one for work that waits + * on sockets. It is the wrong one for work that talks to a database: on this + * runtime a database read blocks the HOST thread under the virtual thread, and + * every other virtual thread on that host with it. See + * {@code 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 {@code @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"; + + private static final Map EXECUTORS = new LinkedHashMap(); + private static Config config; + private static boolean reportedAuto; + + private Tasks() { + } + + /** + * The configuration executors are sized from. Set by the server when it + * starts; before that -- a test calling an {@code @Async} method directly -- + * executors take their defaults. + */ + public static synchronized void configure(Config configuration) { + config = configuration; + } + + /** + * The executor called {@code name}, created on first use. + * + * @param kind {@link #AUTO}, {@link #VIRTUAL} or {@link #PLATFORM}: what the + * code asked for, which {@code cn1.task.executor..kind} + * overrides + */ + public static synchronized TaskExecutor executor(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; + TaskExecutor existing = (TaskExecutor)EXECUTORS.get(key); + if(existing != null) { + return existing; + } + String prefix = "cn1.task.executor." + key + "."; + int threads = SCHEDULING.equals(key) ? 2 : 8; + String configured = null; + if(config != null) { + try { + threads = config.getInt(prefix + "threads", threads); + configured = config.get(prefix + "kind"); + } catch (java.io.IOException err) { + throw new IllegalStateException("Executor " + key + " cannot be configured: " + + err.getMessage()); + } + } + boolean virtual; + if("virtual".equalsIgnoreCase(configured)) { + virtual = true; + } else if("platform".equalsIgnoreCase(configured)) { + virtual = false; + } else if(kind == AUTO) { + virtual = HttpServer.acceptsVirtualTasks(); + if(!reportedAuto) { + reportedAuto = true; + System.out.println("cn1: background tasks marked AUTO run on " + + (virtual ? "virtual" : "platform") + " threads"); + } + } else { + virtual = kind == VIRTUAL; + } + TaskExecutor created = new TaskExecutor(key, virtual, threads); + EXECUTORS.put(key, created); + return created; + } + + /** Runs {@code 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 {@code 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) { + Runnable task = HttpServer.takeVirtualTask(token); + if(task == null) { + return; + } + try { + task.run(); + } catch (Throwable err) { + // The top of a virtual thread: nothing above this can catch it. + System.err.println("A virtual-thread task failed: " + err); + } + } + + /** Every executor created so far, for a listing. */ + public static synchronized List executors() { + return new ArrayList(EXECUTORS.values()); + } + + /** + * Stops every executor, waiting up to {@code waitMillis} in total for what + * is running, and forgets them, so a server started again in the same + * process -- a test -- gets fresh ones. + */ + public static void shutdown(long waitMillis) { + List all; + synchronized(Tasks.class) { + all = new ArrayList(EXECUTORS.values()); + EXECUTORS.clear(); + } + long deadline = System.currentTimeMillis() + Math.max(0, waitMillis); + for(int iter = 0 ; iter < all.size() ; iter++) { + long left = deadline - System.currentTimeMillis(); + ((TaskExecutor)all.get(iter)).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 3994a7c5074..2aadcf600f5 100644 --- a/vm/backend/src/com/codename1/backend/Tracing.java +++ b/vm/backend/src/com/codename1/backend/Tracing.java @@ -266,6 +266,47 @@ public static Object inSpan(String name, Work work) throws Exception { * 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. */ + /** + * The current span when tracing is on, for handing to work that runs on + * another thread; null otherwise. + */ + static Span captureParent() { + return tracer == null ? null : currentOrNull(); + } + + /** + * Runs {@code work} on this thread as a span whose parent is {@code parent}, + * captured on the thread that scheduled it -- an {@code @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 { + Tracer t = tracer; + if(t == null || isSuppressed()) { + return work.run(NOOP); + } + Span span = null; + try { + span = t.startSpan(name, Span.KIND_INTERNAL, parent, null, null); + if(span != null) { + enter(span); + } + } catch (RuntimeException 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; + } finally { + finish(span); + } + } + public static Span startSpan(String name) { Tracer t = tracer; if(t == null || isSuppressed()) { @@ -314,6 +355,9 @@ public static boolean isSuppressed() { * 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) { 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..93cc060dde4 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/TransactionException.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.codename1.backend; + +/** + * A transaction could not begin, commit or roll back the way a + * {@code @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 {@code 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..a925dd8cb64 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Transactions.java @@ -0,0 +1,563 @@ +/* + * 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 {@code @Transactional} method runs in, bound to the calling + * thread. + * + *

The build rewrites every {@code @Transactional} method into a call to + * {@link #begin}, the original body, then {@link #commit} -- or + * {@link #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. {@link 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 + * {@link 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 + * {@code @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: {@link #used} is set by the first {@link #begin} of the process, + * on the thread that is then inside it, so every other thread reading a stale + * {@code false} is correct -- none of them is in a transaction. + * + *

Propagation

+ * + *

Spring's seven, one for one; the constants below are the ordinals of + * {@code 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 {@link 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; + + /** + * The pool a transaction uses when the first thing it needs is a connection + * nobody asked for by pool -- a savepoint, or a session. Set by the server + * when it opens its database. + */ + private static DataSource defaultPool; + + 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; + boolean rollbackOnly; + int savepoints; + 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 {@link #begin} did, which the matching {@link #commit} or + * {@link #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; + + 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; + } + } + + /** Sets the pool a transaction uses when nothing else names one. */ + public static void setDefaultDataSource(DataSource pool) { + defaultPool = pool; + } + + /** 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; + return p != null && p.rollbackOnly; + } + + /** + * 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"); + } + p.rollbackOnly = true; + } + + /** + * Starts what a {@code @Transactional} method asked for. Called by woven code. + * + * @param propagation one of the constants above + * @param readOnly the annotation's readOnly + * @param timeoutSeconds the annotation's timeout, zero or less for none + */ + public static Transaction begin(int propagation, boolean readOnly, int timeoutSeconds) { + used = true; + 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) { + Database db; + try { + db = materialize(current, current.pool != null ? current.pool : defaultPool); + } catch (IOException err) { + throw new TransactionException("Could not begin the transaction a NESTED " + + "method needs: " + err.getMessage(), err); + } + if(db == null) { + throw new TransactionException.IllegalState("A NESTED method needs a database to " + + "set its savepoint in, and this server has none."); + } + String name = "cn1_sp_" + (++current.savepoints); + try { + flushSession(current); + db.savepoint(name); + } catch (IOException err) { + 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; + switch(tx.kind) { + case KIND_SAVEPOINT: + 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 {@code 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; + switch(tx.kind) { + case KIND_JOINED: + tx.physical.rollbackOnly = true; + return; + case KIND_SAVEPOINT: + 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); + } + return; + case KIND_NEW: + try { + rollbackPhysical(tx.physical); + } finally { + restore(tx); + } + return; + default: + restore(tx); + } + } + + /** 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.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()); + } + } + } + + /** + * The transaction's connection for {@code 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 {@link 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) { + return null; + } + 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); + } catch (IOException err) { + p.pool.releaseToPool(db); + throw err; + } + p.db = db; + return db; + } + + /** Whether {@code db} is the connection of the calling thread's transaction on {@code 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; + } + + /** Whether the calling thread's transaction is on {@code 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); + } + + /** + * 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 {@code 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. + */ + public static void markRollbackOnly(DataSource pool) { + Physical p = used ? (Physical)CURRENT.get() : null; + if(p != null && (p.pool == null || p.pool == pool)) { + 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..a749bcfde5c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Wiring.java @@ -0,0 +1,268 @@ +/* + * 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 {@code @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. {@code slot} is + * the number the build gave the bean. + */ + public interface Scope { + Object get(int slot); + } + + /** + * Resolves every {@code ${key}} and {@code ${key:fallback}} in + * {@code expression} against the configuration, keeping the text around + * them. + * + * @param 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()); + } + 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()); + } + } + + /** Whether any of the profiles, each optionally negated with {@code !}, is active. */ + public static boolean profiles(Config config, String[] profiles) { + String active = config.getProfile(); + for(int iter = 0 ; iter < profiles.length ; iter++) { + String p = profiles[iter].trim(); + if(p.startsWith("!")) { + if(!p.substring(1).trim().equalsIgnoreCase(active)) { + return true; + } + } else if(p.equalsIgnoreCase(active)) { + return true; + } + } + return false; + } + + /** {@code @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()); + } + if(value == null) { + return matchIfMissing; + } + if(havingValue == null || havingValue.length() == 0) { + return !"false".equalsIgnoreCase(value.trim()); + } + return havingValue.equalsIgnoreCase(value.trim()); + } + + /** + * The one bean of {@code candidates} that exists, when all of them are + * conditional. More than one existing is ambiguous; none is a missing + * dependency when {@code required}. + */ + public static Object single(Object[] candidates, boolean required, String what) { + Object found = null; + for(int iter = 0 ; iter < candidates.length ; iter++) { + if(candidates[iter] != null) { + if(found != null) { + throw new IllegalStateException(what + ": more than one conditional bean " + + "is active for it; mark one @Primary or use @Qualifier"); + } + found = candidates[iter]; + } + } + 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 {@code items} that exist, for a {@code List} injection point. */ + public static List list(Object[] items) { + List out = new ArrayList(items.length); + for(int iter = 0 ; iter < items.length ; iter++) { + if(items[iter] != null) { + out.add(items[iter]); + } + } + 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"); + } + } + + public static long toLong(String value, String where) { + try { + return Long.parseLong(value.trim()); + } catch (RuntimeException err) { + throw bad(value, where, "a whole number"); + } + } + + 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"); + } + } + + 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; {@code values} is the enum's values(). */ + public static Object toEnum(Object[] values, String value, String where) { + String v = value.trim(); + for(int iter = 0 ; iter < values.length ; iter++) { + if(((Enum)values[iter]).name().equals(v)) { + return values[iter]; + } + } + for(int iter = 0 ; iter < values.length ; iter++) { + if(((Enum)values[iter]).name().equalsIgnoreCase(v)) { + return values[iter]; + } + } + throw bad(value, where, "one of the enum's constants"); + } + + private static IllegalStateException bad(String value, String where, String what) { + return new IllegalStateException(where + " is \"" + value + "\", which is not " + what); + } + + /** 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..6a603dde5f6 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ConditionalOnMissingBean.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; + +/// 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. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface ConditionalOnMissingBean { +} 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/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/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/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/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/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/ThreadKind.java b/vm/backend/src/com/codename1/backend/annotations/ThreadKind.java new file mode 100644 index 00000000000..e765cfc76e1 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ThreadKind.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; + +/// 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. But on this runtime only the SERVED socket parks a virtual +/// thread: a database query or an outbound HTTP call blocks the host thread +/// under it, and with it every other virtual thread that host is running. Work +/// that talks to a database 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..ea4c00b5e64 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mcp/DevTools.java @@ -0,0 +1,474 @@ +/* + * 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.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.DevConsole; +import com.codename1.backend.Management; +import com.codename1.backend.RequestLog; +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. + * + * + * + * + * + * + * + * + * + * + * + * + * + *
backend_routesevery route the build generated
backend_beansevery bean, its scope and what it was given
backend_configthe profile and the configured keys, secrets masked
backend_callsends the server an HTTP request and returns the response
backend_requeststhe last requests served, and what failed and why
backend_logsthe last console lines
backend_sqlruns SQL against the server's database
backend_schemathe entities and their tables and columns
backend_jobs / backend_run_jobthe scheduled jobs; run one now
backend_metricsevery metric's current value
backend_managed / backend_invokethe managed beans; call an operation
+ * + *

Linked only into a development build -- the entry point {@code 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; + + public void install(McpServer server, Backend running) { + this.backend = running; + RequestLog.enable(200); + DevConsole.install(); + McpServer.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()) { + Object run(Map a) { + return application() == null ? new ArrayList() + : application().describeRoutes(); + } + }); + McpServer.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()) { + Object run(Map a) { + return application() == null ? new ArrayList() + : application().describeBeans(); + } + }); + McpServer.register(new Tool("backend_config", + "Shows the active profile and every configured key, with values that " + + "look secret masked.", schema()) { + Object run(Map a) throws Exception { + return config(); + } + }); + McpServer.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"})) { + Object run(Map a) throws Exception { + return call(a); + } + }); + McpServer.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)) { + Object run(Map a) { + return RequestLog.recent(intArg(a, "limit", 20), boolArg(a, "failuresOnly")); + } + }); + McpServer.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)) { + 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)); + } + }); + McpServer.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"})) { + Object run(Map a) throws Exception { + return sql(a); + } + }); + McpServer.register(new Tool("backend_schema", + "Lists the entities the build generated persistence for, with their " + + "tables and columns.", schema()) { + Object run(Map a) { + return schemaOf(); + } + }); + McpServer.register(new Tool("backend_jobs", + "Lists the scheduled jobs: schedule, runs, failures, last and next run.", + schema()) { + Object run(Map a) { + Scheduler s = application() == null ? null : application().getScheduler(); + return s == null ? new ArrayList() : s.describe(); + } + }); + McpServer.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"})) { + 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"; + } + }); + McpServer.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)) { + Object run(Map a) { + return metrics(stringArg(a, "prefix")); + } + }); + McpServer.register(new Tool("backend_managed", + "Lists the @ManagedResource beans with their attributes' current values " + + "and their operations.", schema()) { + Object run(Map a) { + return Management.describeBeans(); + } + }); + McpServer.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"})) { + Object run(Map a) throws Exception { + Object args = a.get("arguments"); + return Management.invoke(stringArg(a, "bean"), stringArg(a, "operation"), + args instanceof Map ? (Map)args : new LinkedHashMap()); + } + }); + } + + private Backend.Application application() { + return backend == 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(int iter = 0 ; iter < keys.size() ; iter++) { + String key = String.valueOf(keys.get(iter)); + 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; + String[] marks = {"password", "secret", "token", "key", "credential"}; + for(int iter = 0 ; iter < marks.length ; iter++) { + if(containsIgnoreCase(k, marks[iter])) { + 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 call(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(); + String scheme = "http"; + Web.Result result = Web.request(asciiUpper(method), scheme + "://127.0.0.1:" + port + + path, headers, body == null ? null : McpServer.utf8(body)); + Map out = new LinkedHashMap(); + out.put("status", new Integer(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; + } + + 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", new Integer(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 = pool.query(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(int iter = 0 ; iter < all.length ; iter++) { + EntityDefinition d = all[iter]; + Map m = new LinkedHashMap(); + m.put("entity", d.type().getName()); + m.put("table", d.table()); + List columns = new ArrayList(); + ColumnDefinition[] cols = d.columns(); + for(int c = 0 ; c < cols.length ; c++) { + Map col = new LinkedHashMap(); + col.put("field", cols[c].getField()); + col.put("column", cols[c].getColumn()); + col.put("type", cols[c].getDeclaredType()); + if(cols[c].isId()) { + col.put("id", Boolean.TRUE); + } + if(cols[c].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(int iter = 0 ; iter < required.length ; iter++) { + r.add(required[iter]); + } + 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; + } + + public String name() { + return name; + } + + public String description() { + return description; + } + + public Map inputSchema() { + return schema; + } + + 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..f082d625365 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java @@ -0,0 +1,245 @@ +/* + * 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 + * {@link 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(int iter = 0 ; iter < values.length ; iter++) { + list.add(values[iter]); + } + 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(int iter = 0 ; iter < required.length ; iter++) { + list.add(required[iter]); + } + 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); + } + + public static String string(Map args, String name, boolean required) { + Object v = raw(args, name, required); + 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; + } + + 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 + : new Integer(intValue(args, name, required)); + } + + public static Long longObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : new Long(longValue(args, name, required)); + } + + public static Short shortObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : new Short((short)intValue(args, name, required)); + } + + public static Byte byteObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : new Byte((byte)intValue(args, name, required)); + } + + public static Double doubleObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : new Double(doubleValue(args, name, required)); + } + + public static Float floatObject(Map args, String name, boolean required) { + return raw(args, name, required) == null ? null + : new Float((float)doubleValue(args, name, required)); + } + + 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 + : new Character(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"); + } + + /** The constant of {@code 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; + } + for(int iter = 0 ; iter < values.length ; iter++) { + if(String.valueOf(values[iter]).equals(s)) { + return values[iter]; + } + } + StringBuilder allowed = new StringBuilder(); + for(int iter = 0 ; iter < values.length ; iter++) { + allowed.append(iter == 0 ? "" : ", ").append(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 Number) { + double d = ((Number)v).doubleValue(); + if(d != Math.floor(d)) { + throw new IllegalArgumentException("\"" + name + "\" must be a whole number"); + } + return ((Number)v).longValue(); + } + try { + return Long.parseLong(String.valueOf(v).trim()); + } catch (NumberFormatException err) { + throw new IllegalArgumentException("\"" + name + "\" must be a whole number"); + } + } + + private static double toDouble(Object v, String name) { + if(v instanceof Number) { + return ((Number)v).doubleValue(); + } + try { + return Double.parseDouble(String.valueOf(v).trim()); + } catch (NumberFormatException err) { + throw new IllegalArgumentException("\"" + name + "\" must be a number"); + } + } +} 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..fd367fef263 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -0,0 +1,452 @@ +/* + * 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 {@code cn1.mcp.path} ({@code /mcp} by default). + * + *

It serves two kinds of tools. The application's own -- every + * {@code @McpTool} method, registered by the generated entry point -- and, on a + * development profile of a development build, the {@link 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. + * + *

+ *   claude mcp add --transport http backend http://127.0.0.1:8080/mcp
+ * 
+ * + *

Security

+ * + *

Outside a development profile the endpoint needs + * {@code 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 + * {@code Origin} is not the server's own host, localhost, or one listed in + * {@code cn1.mcp.allowedOrigins} is refused, 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"}; + + private static final List TOOLS = new ArrayList(); + + private final String path; + private final byte[] token; + private final String[] allowedOrigins; + private final String serverName; + private Backend backend; + + /** 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) { + 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. + * + * @param devTools the development tools, which the build passes only in a + * development build; they are installed only on a development profile + * or with {@code cn1.mcp.devTools=true} + */ + public static McpServer fromConfig(Config config, Extension devTools, String serverName) + throws IOException { + boolean development = config.isDevelopmentProfile(); + Extension extension = devTools != null + && config.getBoolean(DEV_TOOLS, development) ? devTools : null; + boolean anyTools; + synchronized(McpServer.class) { + anyTools = !TOOLS.isEmpty(); + } + if(!config.getBoolean(ENABLED, anyTools || extension != null)) { + return null; + } + String token = config.get(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.get(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); + } + + /** Registers a tool, replacing one of the same name. */ + public static 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 registered tool. */ + public static 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) { + this.backend = running; + if(devTools != null) { + devTools.install(this, running); + } + } + + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + String target = request.getTarget(); + if(target == null) { + return null; + } + int query = target.indexOf('?'); + String p = query < 0 ? target : target.substring(0, query); + if(!p.equals(path)) { + return null; + } + 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; + List answers = new ArrayList(); + for(int iter = 0 ; iter < batch.size() ; iter++) { + Object answer = dispatch(batch.get(iter)); + 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"); + Object methodValue = m.get("method"); + if(!(methodValue instanceof String)) { + // A response to something the server sent, or garbage; the server + // sends nothing, so either way there is nothing to answer. + return id == null ? null : rpcError(id, -32600, "Invalid request"); + } + String method = (String)methodValue; + Map params = m.get("params") instanceof Map ? (Map)m.get("params") : new LinkedHashMap(); + if(id == null) { + return null; + } + 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(int iter = 0 ; iter < PROTOCOL_VERSIONS.length ; iter++) { + if(PROTOCOL_VERSIONS[iter].equals(requested)) { + version = PROTOCOL_VERSIONS[iter]; + } + } + 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(int iter = 0 ; iter < all.size() ; iter++) { + McpTool tool = (McpTool)all.get(iter); + 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"); + } + Map arguments = params.get("arguments") instanceof Map ? (Map)params.get("arguments") + : new LinkedHashMap(); + List all = tools(); + for(int iter = 0 ; iter < all.size() ; iter++) { + McpTool tool = (McpTool)all.get(iter); + 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 (Exception 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. + text.put("text", err instanceof IllegalArgumentException + ? err.getMessage() : 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(int iter = 0 ; iter < allowedOrigins.length ; iter++) { + if(allowedOrigins[iter].equals(origin) || "*".equals(allowedOrigins[iter])) { + return true; + } + } + String host = hostOf(origin); + if("localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) + || "[::1]".equals(host)) { + return true; + } + String own = request.getHeader("host"); + if(own != null) { + int colon = own.lastIndexOf(':'); + String ownHost = colon > 0 && own.indexOf(']') < colon ? own.substring(0, colon) + : own; + return ownHost.equalsIgnoreCase(host); + } + return false; + } + + 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; + } + + 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", new Integer(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"); + } + } +} 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..1d482cf76bd --- /dev/null +++ b/vm/backend/src/com/codename1/backend/mcp/McpTool.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.mcp; + +import java.util.Map; + +/** + * One tool on the server's MCP endpoint. + * + *

An {@code @McpTool} method becomes one of these through a class the build + * generates, whose schema is a constant and whose {@link #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 {@link 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 {@link 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/metrics/Counter.java b/vm/backend/src/com/codename1/backend/metrics/Counter.java new file mode 100644 index 00000000000..d36e5561aa8 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/Counter.java @@ -0,0 +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 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() { + total.incrementAndGet(); + } + + /** + * Adds {@code 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"); + } + total.addAndGet(amount); + } + + public long get() { + return total.get(); + } + + 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..f8a86cfeac4 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/Gauge.java @@ -0,0 +1,92 @@ +/* + * 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 + * {@code @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 {@code attributes} and {@code value}; see + * {@link #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 {@link MultiSource}. */ + public static java.util.Map point(String key, Object label, double value) { + 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. */ + public double read() { + if(source == null) { + return Double.NaN; + } + try { + return source.read(); + } catch (RuntimeException err) { + return Double.NaN; + } + } + + public List points() { + if(multi != null) { + try { + List points = multi.read(); + return points == null ? new java.util.ArrayList() : points; + } catch (RuntimeException err) { + 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..19f9254eca2 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -0,0 +1,215 @@ +/* + * 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. */ + public static final double[] DEFAULT_BOUNDS = {0, 5, 10, 25, 50, 75, 100, 250, 500, 750, + 1000, 2500, 5000, 7500, 10000}; + + /** 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(); + private int seriesCount; + private Series overflow; + + Histogram(String name, String description, String unit, double[] bounds, + String[] labels) { + super(name, description, unit, HISTOGRAM); + this.bounds = bounds == null ? DEFAULT_BOUNDS : bounds; + this.labels = labels == null ? new String[0] : labels; + this.plain = new Series(null, this.bounds.length + 1); + } + + /** The label keys this histogram was created with. */ + public String[] getLabelKeys() { + return labels; + } + + /** 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) { + Series s; + synchronized(this) { + s = find(first, second, third); + } + s.record(value, bounds); + } + + private Series find(Object first, Object second, Object third) { + Object key = first == null ? "" : first; + List list = (List)byFirst.get(key); + if(list != null) { + for(int iter = 0 ; iter < list.size() ; iter++) { + Series s = (Series)list.get(iter); + if(same(s.values[1], second) && same(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; + } + + private static boolean same(Object a, Object b) { + return a == b || (a != null && a.equals(b)); + } + + public List points() { + List out = new ArrayList(); + if(plain.count() > 0 || byFirst.isEmpty()) { + out.add(plain.point(bounds, null, false)); + } + synchronized(this) { + java.util.Iterator lists = byFirst.values().iterator(); + while(lists.hasNext()) { + List list = (List)lists.next(); + for(int iter = 0 ; iter < list.size() ; iter++) { + out.add(((Series)list.get(iter)).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 + ? new Long(((Integer)v).longValue()) : v); + } + } + } + p.put("attributes", attributes); + p.put("count", new Long(count)); + p.put("sum", new Double(sum)); + if(count > 0) { + p.put("min", new Double(min)); + p.put("max", new Double(max)); + } + List b = new ArrayList(bounds.length); + for(int iter = 0 ; iter < bounds.length ; iter++) { + b.add(new Double(bounds[iter])); + } + p.put("bounds", b); + List c = new ArrayList(buckets.length); + for(int iter = 0 ; iter < buckets.length ; iter++) { + c.add(new Long(buckets[iter])); + } + 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..110928fccb0 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/Instrument.java @@ -0,0 +1,90 @@ +/* + * 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 {@link 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; + } + + /** {@link #COUNTER}, {@link #UP_DOWN_COUNTER}, {@link #GAUGE} or {@link #HISTOGRAM}. */ + public int getKind() { + return kind; + } + + /** + * The current points, each a map with {@code attributes} (a map, possibly + * empty) and either {@code value} or, for a histogram, {@code count}, + * {@code sum}, {@code min}, {@code max}, {@code bounds} and {@code buckets}. + */ + public abstract List points(); + + static Map point(Map attributes, double value) { + Map p = new LinkedHashMap(); + p.put("attributes", attributes == null ? new LinkedHashMap() : attributes); + p.put("value", new Double(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..0b1375490e9 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/MetricReader.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.metrics; + +import java.io.IOException; + +import com.codename1.backend.Config; + +/** + * Something that reads {@link 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. + */ + boolean open(Config config) throws IOException; + + /** Sends what is left and stops, waiting up to {@code 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..4e80e6cc88a --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -0,0 +1,450 @@ +/* + * 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.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The server's metrics: every {@link Instrument} by name, and the ones the + * server records about itself. + * + *

+ *   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 {@code metrics} and {@code 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 {@link #enableServer} has run -- the generated entry point calls it when + * metrics are on -- every request is measured in + * {@code 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 {@link #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 {@code 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); + return g; + } + + /** A gauge with several labelled values. See {@link 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); + return g; + } + + private static synchronized void replaceGauge(Gauge g) { + 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"); + } + INSTRUMENTS.put(g.getName(), g); + } + + 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"); + } + 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); + } + INSTRUMENTS.put(name, created); + return created; + } + + /** Every instrument, in registration order. */ + public static synchronized List instruments() { + return new ArrayList(INSTRUMENTS.values()); + } + + /** The instrument called {@code 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 + + /** + * Starts recording the server's own metrics. Called once the server is + * listening; the pool may be null. + */ + public static void enableServer(final com.codename1.backend.HttpServer server, + final com.codename1.backend.DataSource 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(server, "http.server.active_requests", "activeRequests", + "Requests being served", "{request}"); + serverMetric(server, "http.server.open_connections", "openConnections", + "Open client connections", "{connection}"); + serverMetric(server, "cn1.server.websocket_connections", "webSocketConnections", + "Open websocket connections", "{connection}"); + serverMetric(server, "cn1.server.requests_served", "requestsServed", + "Requests answered since start", "{request}"); + serverMetric(server, "cn1.server.connections_refused", "connectionsRefused", + "Connections refused for being over the limit", "{connection}"); + if(pool != null) { + gauge("db.client.connection.count", "Open database connections", "{connection}", + new Gauge.Source() { + public double read() { + return pool.getOpenCount(); + } + }); + gauge("db.client.connection.idle", "Idle database connections", "{connection}", + new Gauge.Source() { + public double read() { + return pool.getIdleCount(); + } + }); + } + gauge("cn1.task.queue_depth", "Tasks waiting for a thread, by executor", "{task}", + new Gauge.MultiSource() { + public List read() { + List out = new ArrayList(); + List all = com.codename1.backend.Tasks.executors(); + for(int iter = 0 ; iter < all.size() ; iter++) { + com.codename1.backend.TaskExecutor e = + (com.codename1.backend.TaskExecutor)all.get(iter); + out.add(Gauge.point("cn1.executor", e.getName(), e.getQueueDepth())); + } + return out; + } + }); + gauge("process.runtime.memory.used", "Heap in use", "By", new Gauge.Source() { + public double read() { + Runtime r = Runtime.getRuntime(); + return r.totalMemory() - r.freeMemory(); + } + }); + gauge("process.uptime", "Seconds since the process started", "s", new Gauge.Source() { + public double read() { + return (System.currentTimeMillis() - STARTED) / 1000.0; + } + }); + serverEnabled = true; + } + + private static void serverMetric(final com.codename1.backend.HttpServer server, + String name, final String key, String description, + String unit) { + gauge(name, description, unit, new Gauge.Source() { + public double read() { + Object v = server.getMetrics().get(key); + return v instanceof Number ? ((Number)v).doubleValue() : Double.NaN; + } + }); + } + + /** 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 {@link #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) { + if(!serverEnabled || started == 0L) { + return; + } + Object route = ROUTE.get(); + ROUTE.set(null); + requestDuration.record((System.nanoTime() - started) / 1000000.0, route, method, + new Integer(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: + * {@code name -> {kind, description, unit, points}}. + */ + public static Map snapshot() { + Map out = new LinkedHashMap(); + List all = instruments(); + for(int iter = 0 ; iter < all.size() ; iter++) { + Instrument i = (Instrument)all.get(iter); + 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(int iter = 0 ; iter < all.size() ; iter++) { + Instrument i = (Instrument)all.get(iter); + 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(int p = 0 ; p < points.size() ; p++) { + Map point = (Map)points.get(p); + 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); + sb.append(' ').append(number(((Number)point.get("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(promName(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(); + } + + 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/orm/EntityManager.java b/vm/backend/src/com/codename1/backend/orm/EntityManager.java index 3986e3bd0c6..4dd3ac297d4 100644 --- a/vm/backend/src/com/codename1/backend/orm/EntityManager.java +++ b/vm/backend/src/com/codename1/backend/orm/EntityManager.java @@ -281,6 +281,12 @@ public Object run(Database inner) throws Exception { } }); } + 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 body.run(new EntityManager(null, joined, self.dialect, self.tables, true)); + } return pool.withConnection(new DataSource.Work() { public Object run(final Database db) throws Exception { return db.transaction(new Database.Work() { 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..59765fc9e58 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/orm/TransactionSession.java @@ -0,0 +1,168 @@ +/* + * 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 {@link 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 {@code @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 + * {@code 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()."); + } + + public JpqlQuery createQuery(String statement, Class resultType) { + return current().createQuery(statement, resultType); + } + + public JpqlQuery createQuery(String statement) { + return current().createQuery(statement); + } + + public void beginTransaction() { + throw boundary("Beginning a transaction"); + } + + public void commitTransaction() { + throw boundary("Committing"); + } + + public void rollbackTransaction() { + throw boundary("Rolling back"); + } + + public boolean isTransactionActive() { + return Transactions.isActive(); + } + + public boolean isRollbackOnly() { + return Transactions.isRollbackOnly(); + } + + public boolean contains(Object entity) { + return Transactions.isActive() && current().contains(entity); + } + + public void detach(Object entity) { + current().detach(entity); + } + + public void clear() { + current().clear(); + } + + public void close() { + throw boundary("Closing the session"); + } + + public T find(Class type, Object id) { + return current().find(type, id); + } + + public T find(Class type, Object id, LockMode mode) { + return current().find(type, id, mode); + } + + public void lock(Object entity, LockMode mode) { + current().lock(entity, mode); + } + + public void persist(T entity) { + current().persist(entity); + } + + public T merge(T entity) { + return current().merge(entity); + } + + public void remove(Object entity) { + current().remove(entity); + } + + public void refresh(Object entity) { + current().refresh(entity); + } + + public void flush() { + current().flush(); + } + + public boolean increment(Class type, Object id, String field, long amount) { + return current().increment(type, id, field, amount); + } + + public Query query(Class type) { + return current().query(type); + } + + public void createTables() { + current().createTables(); + } + + public void validateSchema() { + current().validateSchema(); + } + + public long count(Object entity, String field) { + return current().count(entity, field); + } + + public boolean isLoaded(Object entity, String field) { + return current().isLoaded(entity, field); + } + + 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..72869cb4ee5 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -0,0 +1,293 @@ +/* + * 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 {@link Metrics} over OTLP/HTTP, every + * {@code 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: + * + * + * + * + * + * + * + * + * + * + *
keyenvironmentdefault
cn1.otel.metrics.endpointOTEL_EXPORTER_OTLP_METRICS_ENDPOINTcn1.otel.endpoint + /v1/metrics
cn1.otel.metrics.headersOTEL_EXPORTER_OTLP_METRICS_HEADERScn1.otel.headers
cn1.otel.metrics.protocolOTEL_EXPORTER_OTLP_METRICS_PROTOCOLcn1.otel.protocol
cn1.otel.metrics.enabledtrue
+ * + *

{@code 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; + private boolean protobuf; + private int intervalMillis; + private Map resource; + private Thread thread; + private final Object lock = new Object(); + private boolean stopping; + private long exports; + private long failures; + private String lastError; + + public OtlpMetricExporter(String defaultServiceName) { + this.defaultServiceName = defaultServiceName; + } + + public boolean open(Config config) throws IOException { + if(config.getBoolean(OtlpTracer.DISABLED, false) || !config.getBoolean(ENABLED, true)) { + return false; + } + String protocol = config.get(PROTOCOL, config.get(OtlpTracer.PROTOCOL, + "http/protobuf")).trim(); + if("http/protobuf".equals(protocol)) { + protobuf = true; + } else if("http/json".equals(protocol)) { + protobuf = 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"); + } + endpoint = target.trim(); + if(!OtlpTracer.hasHttpAuthority(endpoint)) { + throw new IOException("The metrics endpoint must be an http or https URL naming " + + "a host and is '" + BatchExporter.redact(endpoint) + "'"); + } + headers = new ArrayList(); + String own = config.get(HEADERS); + OtlpTracer.parseHeaders(own != null ? own : config.get(OtlpTracer.HEADERS), headers); + intervalMillis = OtlpTracer.positive(config, INTERVAL, 60000); + resource = OtlpTracer.resource(config, defaultServiceName); + thread = new Thread(new Runnable() { + public void run() { + loop(); + } + }, "cn1-otel-metrics"); + thread.setDaemon(true); + thread.start(); + return true; + } + + private void loop() { + while(true) { + synchronized(lock) { + long deadline = System.currentTimeMillis() + intervalMillis; + while(!stopping) { + long left = deadline - System.currentTimeMillis(); + if(left <= 0) { + break; + } + try { + lock.wait(left); + } catch (InterruptedException err) { + return; + } + } + if(stopping) { + return; + } + } + 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; + } + } + return true; + } catch (Exception err) { + synchronized(lock) { + failures++; + lastError = BatchExporter.bounded("could not export metrics: " + + err.getMessage()); + if(failures == 1 || failures % 100 == 0) { + System.err.println(lastError); + } + } + return false; + } + } + + public void shutdown(int timeoutMillis) { + synchronized(lock) { + if(stopping) { + return; + } + stopping = true; + lock.notifyAll(); + } + if(thread != null && timeoutMillis > 0) { + // One last export, so the counts of the final minute are not lost. + export(); + } + } + + /** 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", new Long(exports)); + out.put("failures", new Long(failures)); + if(lastError != null) { + out.put("lastError", lastError); + } + } + return out; + } + + /** The ExportMetricsServiceRequest tree for these instruments, at {@code 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(int iter = 0 ; iter < instruments.size() ; iter++) { + Instrument instrument = (Instrument)instruments.get(iter); + 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(int p = 0 ; p < points.size() ; p++) { + Map point = (Map)points.get(p); + 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", point.get("sum")); + List buckets = (List)point.get("buckets"); + List counts = new ArrayList(buckets.size()); + for(int b = 0 ; b < buckets.size() ; b++) { + counts.add(String.valueOf(buckets.get(b))); + } + dp.put("bucketCounts", counts); + dp.put("explicitBounds", point.get("bounds")); + if(point.get("min") != null) { + dp.put("min", point.get("min")); + dp.put("max", point.get("max")); + } + } else { + Object value = point.get("value"); + if(value instanceof Double && ((Double)value).isNaN()) { + continue; + } + dp.put("asDouble", 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", new Integer(2)); + metric.put("histogram", data); + break; + default: + data.put("aggregationTemporality", new Integer(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"; + } +} diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java index a5695969896..148c1d0ef79 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java @@ -63,6 +63,10 @@ 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; /** One field: its JSON name, its number, its kind, and for a message its type. */ private static final class Field { @@ -152,6 +156,11 @@ 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 {@code opentelemetry/proto/metrics/v1/metrics.proto}'s. + */ + private static final Message METRICS_EXPORT; static { Field arrayValue = f("arrayValue", 5, MESSAGE); @@ -221,6 +230,57 @@ 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), + f("asDouble", 4, DOUBLE), f("flags", 8, VARINT) + }); + 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) { @@ -252,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 @@ -506,6 +573,30 @@ 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(int iter = 0 ; iter < items.size() ; iter++) { + Object item = items.get(iter); + 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); } diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java b/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java index 577c174d7b3..279babf4abf 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java @@ -142,6 +142,33 @@ public static OtlpTracer open(Config config, String defaultServiceName) throws I return tracer.open(config) ? tracer : null; } + /** + * The OTLP resource this process reports as: {@code 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; + } + public boolean open(Config config) throws IOException { if(config.getBoolean(DISABLED, false)) { return false; @@ -184,24 +211,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)); @@ -646,7 +656,7 @@ static boolean isIpv4(String s) { } /** {@code value} trimmed, or null when that leaves nothing. */ - private static String nonBlank(String value) { + static String nonBlank(String value) { if(value == null) { return null; } @@ -888,6 +898,11 @@ static List keyValues(Map attributes) { * 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('#'); @@ -901,10 +916,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); @@ -933,7 +948,7 @@ private static Set splitSet(String list) { } /** OTEL_EXPORTER_OTLP_HEADERS: {@code 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/sql/Dialect.java b/vm/backend/src/com/codename1/backend/sql/Dialect.java index 9cfc5837ccf..0037cb45340 100644 --- a/vm/backend/src/com/codename1/backend/sql/Dialect.java +++ b/vm/backend/src/com/codename1/backend/sql/Dialect.java @@ -1147,4 +1147,24 @@ 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 afc3979a453..bcb44567b0c 100644 --- a/vm/backend/src/com/codename1/backend/sql/MySql.java +++ b/vm/backend/src/com/codename1/backend/sql/MySql.java @@ -431,6 +431,25 @@ 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 + * {@link #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 aaf1da3529c..62617bc6ac0 100644 --- a/vm/backend/src/com/codename1/impl/orm/BackendSqlAccess.java +++ b/vm/backend/src/com/codename1/impl/orm/BackendSqlAccess.java @@ -38,6 +38,12 @@ public final class BackendSqlAccess implements SqlAccess { private final Database supplied; private final Dialect dialect; private Database transaction; + /** + * Whether {@link #transaction} is a {@code @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; this.supplied=supplied; this.dialect=dialect; } @@ -84,7 +90,8 @@ public String orderBy(String expression,boolean ascending,int kind) { public String limit(int count,int offset) { return dialect.limit(count,offset); } private Database connection() throws IOException { Database db=transaction!=null?transaction:supplied!=null?supplied:pool.borrow(); - if(db!=transaction && db.isInTransaction()) { + if(db!=transaction && db.isInTransaction() + && !(pool!=null && com.codename1.backend.Transactions.isJoined(pool,db))) { if(db!=supplied) { db.close();pool.release(db); } throw new IOException("Database is in another transaction"); } @@ -144,6 +151,12 @@ public Object nextIdentifier(int strategy,String name,int kind) throws IOExcepti } 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=com.codename1.backend.Transactions.joined(pool); + if(joined!=null) { transaction=joined; joinedTransaction=true; return; } + } Database db=supplied!=null?supplied:pool.borrow(); try { if("sqlite".equals(dialect())) db.execute("PRAGMA foreign_keys = ON",new Object[0]); @@ -153,14 +166,21 @@ public void begin() throws IOException { public boolean isTransactionActive() { return transaction!=null && transaction.isInTransaction(); } public void commit() throws IOException { if(transaction==null) throw new IOException("No transaction"); + if(joinedTransaction) { unpin(); return; } transaction.commitTransaction(); unpin(); } public void rollback() throws IOException { if(transaction==null) throw new IOException("No transaction"); + if(joinedTransaction) { com.codename1.backend.Transactions.markRollbackOnly(pool); unpin(); return; } transaction.rollbackTransaction(); unpin(); } - private void unpin() { Database db=transaction; transaction=null; release(db); } + private void unpin() { Database db=transaction; transaction=null; joinedTransaction=false; release(db); } 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. + com.codename1.backend.Transactions.markRollbackOnly(pool); unpin(); return; + } if(transaction!=null) { boolean rolledBack=false; try { transaction.rollbackTransaction();rolledBack=true; } From e31da12f35520a3f8612a5714d404a597df295af Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:48:36 +0300 Subject: [PATCH 02/48] Backend: fix review findings in sessions, tasks, MCP, metrics and wiring - Sessions: the session cookie goes on a copy of the handler's Response, never into it (it may be a shared constant); cn1.session.secure refuses anything but auto/true/false. - TaskExecutor: virtual submissions are refused after shutdown and counted active before the hand-off; @Async accessors re-fetch a shut-down executor. - MCP: only loopback or listed origins pass (the Host match let DNS rebinding through); byte/short tool arguments are range-checked; backend_call uses https against a TLS server. - Request metrics are recorded in a finally, so failures count and the route label is cleared. - Factory beans inherit their configuration class's @Profile and @ConditionalOnProperty; request-scoped beans' destroyMethod runs. - Scheduler lock: an INSERT failure with no existing row is rethrown. - OTLP histogram bucket_counts stays fixed64 (per metrics.proto), now held by a test decoding with the generated opentelemetry-proto classes. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/developer-guide/Backend.asciidoc | 7 +- .../backend/ApplicationRuntimeTest.java | 107 ++++++++++++++---- .../backend/otel/OtlpMetricsProtoTest.java | 75 ++++++++++++ .../maven/processors/BackendBeans.java | 15 ++- .../maven/processors/BackendSources.java | 24 +++- .../maven/processors/BackendWiringWriter.java | 23 +++- .../maven/processors/BackendBeansTest.java | 64 +++++++++++ .../src/com/codename1/backend/Backend.java | 14 ++- .../src/com/codename1/backend/HttpServer.java | 21 ++++ .../src/com/codename1/backend/Scheduler.java | 20 +++- .../src/com/codename1/backend/Sessions.java | 31 +++-- .../com/codename1/backend/TaskExecutor.java | 40 +++++-- .../com/codename1/backend/mcp/DevTools.java | 4 +- .../com/codename1/backend/mcp/McpArgs.java | 26 ++++- .../com/codename1/backend/mcp/McpServer.java | 24 ++-- .../codename1/backend/otel/OtlpSchema.java | 5 + 16 files changed, 415 insertions(+), 85 deletions(-) create mode 100644 maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index 20a3c0596f6..5ce70ef7eb6 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -961,9 +961,10 @@ include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/b 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`, and it refuses a browser -request from any origin other than the server's own host, which keeps a web page -from driving it. +the endpoint refuses to start without `cn1.mcp.token`. 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. The second is development. When the server runs through `cn1:backend` on a development profile, the same endpoint also serves tools for inspecting and diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index b69c14a3061..adf93b725e6 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -259,13 +259,57 @@ public HttpServer.Response handle(HttpServer.Request request) } @Test - @DisplayName("a handler's own Set-Cookie survives beside the session's") + @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"))); - Sessions.withHeader(r, "Set-Cookie", "b=2"); - Object both = r.extraHeaders.get("Set-Cookie"); + 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)); + assertTrue(refused.getMessage().contains("auto, true or false"), + refused.getMessage()); + p.setProperty("cn1.session.secure", "FALSE"); + Sessions.configure(Config.of(p, "test"), true, null); + } finally { + // Process-wide settings: put the defaults back for the next test. + Sessions.configure(Config.of(new Properties(), "test"), false, 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)); } // ---------------------------------------------------------------- metrics @@ -336,27 +380,17 @@ public HttpServer.Response handle(HttpServer.Request request) { 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); - // Raw, because HttpURLConnection silently drops a restricted header - // like Origin -- the request would arrive without it and pass. - 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: 127.0.0.1:" + port - + "\r\nOrigin: http://evil.example\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); - } - String answer = new String(raw.toByteArray(), "UTF-8"); - assertTrue(answer.startsWith("HTTP/1.1 403"), "a foreign Origin was served: " - + answer); - } finally { - socket.close(); - } + 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(); } @@ -431,6 +465,31 @@ private static String read(HttpURLConnection c) throws IOException { return status >= 400 ? "HTTP " + status + ": " + body : body; } + /** + * 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); 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..9aa224a6b36 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -0,0 +1,75 @@ +/* + * 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.assertNotNull; + +/** + * 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()); + } +} 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 index 212933ef4e3..475b0790e83 100644 --- 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 @@ -234,7 +234,10 @@ static final class Bean { boolean primary; boolean lazy; boolean onMissing; - String[] profiles; + /// 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(); @@ -259,7 +262,7 @@ static final class Bean { String proxyBinary; boolean isConditional() { - return profiles != null || !propertyConditions.isEmpty(); + return !profiles.isEmpty() || !propertyConditions.isEmpty(); } /// Whether it is reached through a generated stand-in rather than held. @@ -631,7 +634,7 @@ private void readModifiers(Bean bean, Map annotations, if (values.isEmpty()) { ctx.error(where, "@Profile on " + bean.name + " names no profile."); } - bean.profiles = values.toArray(new String[values.size()]); + bean.profiles.add(values.toArray(new String[values.size()])); } AnnotationValues prop = annotations.get(ON_PROPERTY); if (prop != null) { @@ -725,6 +728,12 @@ private Bean factoryBean(Bean owner, MethodInfo m) { bean.initMethod = emptyToNull(values.getString("initMethod")); bean.destroyMethod = emptyToNull(values.getString("destroyMethod")); readModifiers(bean, m.getAnnotations(), cls); + // 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); 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 index 3fdef552f18..23c0fae42d6 100644 --- 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 @@ -246,6 +246,15 @@ private void metrics(StringBuilder sb, AnnotatedClass cls, BackendBeans.Aspect a /// 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) { + fieldAccessor(sb, type, field, create, null); + } + + /** + * @param stale a condition on {@code value} under which the cached object is + * thrown away and fetched again, or null when it never goes stale + */ + private void fieldAccessor(StringBuilder sb, String type, String field, String create, + String stale) { // 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. @@ -256,7 +265,8 @@ private void fieldAccessor(StringBuilder sb, String type, String field, String c .append("() {\n"); pendingMembers.append(" ").append(type).append(" value = ").append(field) .append(";\n"); - pendingMembers.append(" if (value == null) {\n value = ").append(create) + pendingMembers.append(" if (value == null").append(stale == null ? "" : " || " + stale) + .append(") {\n value = ").append(create) .append(";\n ").append(field).append(" = value;\n }\n"); pendingMembers.append(" return value;\n }\n\n"); } @@ -270,7 +280,11 @@ private void async(StringBuilder sb, AnnotatedClass cls, BackendBeans.Aspect asp String thread = BackendBeans.enumName(aspect.async.get("thread"), "PLATFORM"); fieldAccessor(sb, "com.codename1.backend.TaskExecutor", "E" + index, "com.codename1.backend.Tasks.executor(" + quote(executor) - + ", com.codename1.backend.Tasks." + thread + ")"); + + ", com.codename1.backend.Tasks." + thread + ")", + // Tasks.shutdown() stops and forgets every executor when the + // server stops, so a server started again in the same process + // must not keep submitting to the stopped one it cached. + "value.isShutdown()"); 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 ") @@ -452,8 +466,10 @@ String convert(Type t, String map, String name, boolean required) { switch (t.getSort()) { case Type.BOOLEAN: return helper + "booleanValue(" + args + ")"; case Type.CHAR: return helper + "charValue(" + args + ")"; - case Type.BYTE: return "(byte) " + helper + "intValue(" + args + ")"; - case Type.SHORT: return "(short) " + helper + "intValue(" + 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 "(float) " + helper + "doubleValue(" + args + ")"; 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 index ae7b68e0628..9c2cfc116cd 100644 --- 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 @@ -244,14 +244,14 @@ private void closeCondition(StringBuilder sb, BackendBeans.Bean b) { private String condition(BackendBeans.Bean b) { List parts = new ArrayList(); - if (b.profiles != null) { + for (String[] group : b.profiles) { StringBuilder p = new StringBuilder("com.codename1.backend.Wiring.profiles(config, " + "new String[] {"); - for (int i = 0; i < b.profiles.length; i++) { + for (int i = 0; i < group.length; i++) { if (i > 0) { p.append(", "); } - p.append(BackendSources.quote(b.profiles[i])); + p.append(BackendSources.quote(group[i])); } parts.add(p.append("})").toString()); } @@ -654,7 +654,11 @@ private static void destroyCall(StringBuilder sb, BackendBeans.Bean b, String ca private void requestEnded(StringBuilder sb) { sb.append(" public void requestEnded(Object[] beans) {\n"); for (BackendBeans.Bean b : model.beans) { - if (!BackendBeans.REQUEST.equals(b.scope) || b.preDestroy.isEmpty()) { + // 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 (!BackendBeans.REQUEST.equals(b.scope) + || (b.preDestroy.isEmpty() && b.destroyMethod == null)) { continue; } String type = typeOf(b); @@ -666,6 +670,10 @@ private void requestEnded(StringBuilder sb) { 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"); @@ -712,8 +720,11 @@ private void describeBeans(StringBuilder sb) { private static String conditionText(BackendBeans.Bean b) { StringBuilder sb = new StringBuilder(); - if (b.profiles != null) { - sb.append("profile ").append(java.util.Arrays.toString(b.profiles)); + 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) { 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 index f2ed2b72991..647b27fbb18 100644 --- 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 @@ -381,6 +381,70 @@ public void profilesPickTheBeanAtStartUp() throws Exception { } } + @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 everyScopeAndBindingWorksAtRunTime() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 4e7cb314d33..7e5dd2305b2 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -834,6 +834,9 @@ public HttpServer.Response handle(HttpServer.Request 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; try { @@ -855,11 +858,16 @@ public HttpServer.Response handle(HttpServer.Request request) if(session != null) { response = Sessions.finish(session, response); } - com.codename1.backend.metrics.Metrics.requestEnded(started, - request.getMethod(), - response == null ? 404 : response.getStatus()); + 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); if(track) { CURRENT_REQUEST.set(previous); } diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 0585ebc7ba1..7ba1bcac71d 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -1026,6 +1026,22 @@ public Response(int status, String contentType, byte[] body, Map extraHeaders) { * 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. */ + /** + * 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; + } + public static Response file(int status, String contentType, int fd, long offset, long length, Map extraHeaders) { return new Response(status, contentType, null, fd, offset, length, extraHeaders); @@ -2009,6 +2025,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(); } diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/backend/Scheduler.java index 41b51e2bb3e..2f6a1b56808 100644 --- a/vm/backend/src/com/codename1/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -391,10 +391,22 @@ private boolean claim(Job job, long now) throws IOException { + "locked_by) VALUES (?, ?, ?, ?)", new Object[] {job.lock, new Long(until), new Long(now), instance}); return true; - } catch (IOException held) { - // The row exists and its claim has not expired: another instance - // is running this job. - return false; + } 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 false; + } + throw failed; } } diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index cd28542b10d..a174f0c1a9c 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -76,9 +76,20 @@ public static synchronized void configure(Config config, boolean tls, DataSource + "\"; it must be Lax, Strict or None"); } sameSite = site; - String secureSetting = config.get("cn1.session.secure", "auto"); - secure = "auto".equalsIgnoreCase(secureSetting) ? tls - : "true".equalsIgnoreCase(secureSetting); + String secureSetting = config.get("cn1.session.secure", "auto").trim(); + if("auto".equalsIgnoreCase(secureSetting)) { + secure = tls; + } else if("true".equalsIgnoreCase(secureSetting)) { + secure = true; + } else if("false".equalsIgnoreCase(secureSetting)) { + 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(sameSite) && !secure) { // Browsers drop a SameSite=None cookie that is not Secure, so the // session would silently never come back. @@ -224,9 +235,11 @@ private static synchronized String cookie(String value, int maxAge) { } /** - * {@code response} with one more header. The handler's own header map is - * never modified -- it may be a constant shared by every response -- and a - * header it already sets under the same name is kept beside this one. + * {@code 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) { @@ -245,13 +258,11 @@ static HttpServer.Response withHeader(HttpServer.Response response, String name, } both.add(value); copy.put(key, both); - response.extraHeaders = copy; - return response; + return response.withHeaders(copy); } } copy.put(name, value); - response.extraHeaders = copy; - return response; + return response.withHeaders(copy); } /** The value of the cookie called {@code name} in a Cookie header, or null. */ diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index 54e5127168f..9c7d88beb9b 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -96,8 +96,25 @@ public void execute(Runnable task) { if(task == null) { return; } - if(virtual && HttpServer.submitVirtualTask(new Counted(this, task))) { - return; + if(virtual) { + // 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. + synchronized(this) { + if(shutdown) { + throw new IllegalStateException("Executor " + name + " has been shut down"); + } + active++; + } + if(HttpServer.submitVirtualTask(new Counted(this, task))) { + return; + } + synchronized(this) { + active--; + } } synchronized(this) { if(shutdown) { @@ -172,6 +189,11 @@ synchronized void recordFailure() { failed++; } + /** Whether {@link #shutdown(long)} has been called; it then refuses tasks. */ + public synchronized boolean isShutdown() { + return shutdown; + } + /** * Stops taking tasks and waits up to {@code waitMillis} for the queued and * running ones to finish. @@ -197,7 +219,10 @@ public String toString() { return name + (virtual ? " (virtual)" : " (" + size + " threads)"); } - /** A task on a virtual thread, counted like one on the pool. */ + /** + * 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; @@ -208,14 +233,7 @@ private static final class Counted implements Runnable { } public void run() { - owner.runCountedVirtual(task); - } - } - - private void runCountedVirtual(Runnable task) { - synchronized(this) { - active++; + owner.runCounted(task); } - runCounted(task); } } diff --git a/vm/backend/src/com/codename1/backend/mcp/DevTools.java b/vm/backend/src/com/codename1/backend/mcp/DevTools.java index ea4c00b5e64..b0d2cacc5a1 100644 --- a/vm/backend/src/com/codename1/backend/mcp/DevTools.java +++ b/vm/backend/src/com/codename1/backend/mcp/DevTools.java @@ -277,7 +277,9 @@ private Map call(Map a) throws Exception { headers.add("Content-Type: application/json"); } int port = backend.getServer().getPort(); - String scheme = "http"; + // 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"; Web.Result result = Web.request(asciiUpper(method), scheme + "://127.0.0.1:" + port + path, headers, body == null ? null : McpServer.utf8(body)); Map out = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java index f082d625365..82ee034df70 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java @@ -104,6 +104,28 @@ public static int intValue(Map args, String name, boolean required) { 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); @@ -150,12 +172,12 @@ public static Long longObject(Map args, String name, boolean required) { public static Short shortObject(Map args, String name, boolean required) { return raw(args, name, required) == null ? null - : new Short((short)intValue(args, name, required)); + : new Short(shortValue(args, name, required)); } public static Byte byteObject(Map args, String name, boolean required) { return raw(args, name, required) == null ? null - : new Byte((byte)intValue(args, name, required)); + : new Byte(byteValue(args, name, required)); } public static Double doubleObject(Map args, String name, boolean required) { diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index fd367fef263..d0fdd07e91f 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -54,8 +54,9 @@ * {@code 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 - * {@code Origin} is not the server's own host, localhost, or one listed in - * {@code cn1.mcp.allowedOrigins} is refused, which is what the MCP + * {@code Origin} is not a loopback address or one listed in + * {@code 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. * @@ -376,19 +377,14 @@ private boolean originAllowed(HttpServer.Request request) { 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); - if("localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) - || "[::1]".equals(host)) { - return true; - } - String own = request.getHeader("host"); - if(own != null) { - int colon = own.lastIndexOf(':'); - String ownHost = colon > 0 && own.indexOf(']') < colon ? own.substring(0, colon) - : own; - return ownHost.equalsIgnoreCase(host); - } - return false; + return "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) + || "[::1]".equals(host); } private static String hostOf(String origin) { diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java index 148c1d0ef79..529fea57475 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java @@ -235,6 +235,11 @@ private static Field rep(String name, int number) { attributes(7), f("startTimeUnixNano", 2, FIXED64), f("timeUnixNano", 3, FIXED64), f("asDouble", 4, DOUBLE), 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), From 28cad305bb88085ac66b31cd561f3e4a09a6c605 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:26:36 +0300 Subject: [PATCH 03/48] Backend: per-server sessions and registries, bean lifecycle fixes; split the guide Review fixes: - Sessions, MCP tools and managed beans are per server (Backend.getSessions, Environment.registerTool/registerManaged, Builder.mcpTool); two servers in one process no longer share cookies, sessions or tools. - @SessionScope beans are kept by the server for the session's lifetime and destroyed on invalidate, expiry and stop; a negative session timeout fails. - Transactions: no process-wide default pool; a NESTED method before the first statement sets its savepoint after BEGIN. setRollbackOnly in the method that began the transaction rolls back without throwing. - Backend.stop runs destroy callbacks once; a failed start destroys built beans; management routes precede application handlers; session-store failures are logged as 500s; metrics shutdown is bounded by its timeout. - Weaving keeps synchronized on the body, so a synchronized @Async method holds its monitor where it runs. - Injection points and lifecycle methods inherited from base classes are wired; @ConditionalOnMissingBean matches the bean's exposed types and takes explicit ones. Docs: the backend chapter is split into nine chapters (web, beans, data and transactions, sessions, scheduling, observability, MCP, operations) with diagrams and compiled samples. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../developerguide/backend/Products.java | 91 ++ .../developerguide/backend/beans/Account.java | 52 + .../developerguide/backend/beans/Audit.java | 28 + .../backend/beans/AuditLog.java | 47 + .../backend/beans/CardPayments.java | 37 + .../developerguide/backend/beans/Cart.java | 46 + .../developerguide/backend/beans/CartApi.java | 51 + .../backend/beans/Checkout.java | 55 + .../backend/beans/Defaults.java | 38 + .../developerguide/backend/beans/Digest.java | 54 + .../backend/beans/ExpensiveIndex.java | 52 + .../developerguide/backend/beans/Imports.java | 63 + .../backend/beans/InvoicePayments.java | 33 + .../backend/beans/LogAudit.java | 30 + .../backend/beans/MailSettings.java | 52 + .../backend/beans/OrderMetrics.java | 63 + .../developerguide/backend/beans/Orders.java | 57 + .../developerguide/backend/beans/Outbox.java | 63 + .../backend/beans/PaymentDeclined.java | 30 + .../backend/beans/PaymentProvider.java | 28 + .../backend/beans/ReminderService.java | 55 + .../backend/beans/RequestClock.java | 38 + .../backend/beans/SearchClient.java | 52 + .../backend/beans/SearchConfig.java | 37 + .../backend/beans/SearchIndexer.java | 36 + .../developerguide/backend/beans/Signups.java | 5 +- docs/developer-guide/Backend-Beans.asciidoc | 446 +++++++ docs/developer-guide/Backend-Data.asciidoc | 392 ++++++ docs/developer-guide/Backend-MCP.asciidoc | 174 +++ .../Backend-Observability.asciidoc | 298 +++++ .../Backend-Operations.asciidoc | 177 +++ .../Backend-Scheduling.asciidoc | 257 ++++ .../developer-guide/Backend-Sessions.asciidoc | 196 +++ docs/developer-guide/Backend-Web.asciidoc | 364 ++++++ docs/developer-guide/Backend.asciidoc | 1061 ++--------------- docs/developer-guide/developer-guide.asciidoc | 16 + .../img/backend-build-pipeline.svg | 67 ++ .../img/backend-request-lifecycle.svg | 62 + .../img/backend-schedule-timelines.svg | 102 ++ .../img/backend-session-lifecycle.svg | 45 + .../img/backend-transaction-propagation.svg | 44 + docs/developer-guide/languagetool-accept.txt | 4 +- .../backend/ApplicationRuntimeTest.java | 45 +- .../backend/ServerEngineTransactionsTest.java | 1 - .../codename1/backend/TransactionsTest.java | 53 +- .../maven/processors/BackendBeans.java | 153 ++- .../maven/processors/BackendWeaver.java | 14 +- .../maven/processors/BackendWiringWriter.java | 15 +- .../maven/processors/BackendBeansTest.java | 129 ++ .../resources/skill/references/backend.md | 2 +- .../src/com/codename1/backend/Backend.java | 179 ++- .../src/com/codename1/backend/HttpServer.java | 9 +- .../com/codename1/backend/HttpSession.java | 24 +- .../src/com/codename1/backend/Management.java | 37 +- .../src/com/codename1/backend/Sessions.java | 241 +++- .../com/codename1/backend/Transactions.java | 111 +- .../annotations/ConditionalOnMissingBean.java | 7 + .../com/codename1/backend/mcp/DevTools.java | 30 +- .../com/codename1/backend/mcp/McpServer.java | 45 +- .../backend/otel/OtlpMetricExporter.java | 22 +- 60 files changed, 4894 insertions(+), 1121 deletions(-) create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Products.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Account.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Audit.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AuditLog.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/CardPayments.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Cart.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/CartApi.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Checkout.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Defaults.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Digest.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ExpensiveIndex.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Imports.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/InvoicePayments.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/LogAudit.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/MailSettings.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OrderMetrics.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Orders.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Outbox.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PaymentDeclined.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/PaymentProvider.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ReminderService.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RequestClock.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchClient.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchConfig.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SearchIndexer.java create mode 100644 docs/developer-guide/Backend-Beans.asciidoc create mode 100644 docs/developer-guide/Backend-Data.asciidoc create mode 100644 docs/developer-guide/Backend-MCP.asciidoc create mode 100644 docs/developer-guide/Backend-Observability.asciidoc create mode 100644 docs/developer-guide/Backend-Operations.asciidoc create mode 100644 docs/developer-guide/Backend-Scheduling.asciidoc create mode 100644 docs/developer-guide/Backend-Sessions.asciidoc create mode 100644 docs/developer-guide/Backend-Web.asciidoc create mode 100644 docs/developer-guide/img/backend-build-pipeline.svg create mode 100644 docs/developer-guide/img/backend-request-lifecycle.svg create mode 100644 docs/developer-guide/img/backend-schedule-timelines.svg create mode 100644 docs/developer-guide/img/backend-session-lifecycle.svg create mode 100644 docs/developer-guide/img/backend-transaction-propagation.svg 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/beans/Account.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Account.java new file mode 100644 index 00000000000..62a7b16b833 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Account.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.HttpServer; +import com.codename1.backend.HttpSession; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.RestController; + +@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[] + +// 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/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/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..539b5b30e03 --- /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, rollbackFor = IOException.class) + 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/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/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/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..14e0554cd45 --- /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 = {IOException.class, 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/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/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/Signups.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java index fdedb628bd6..6c8659b43cb 100644 --- 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 @@ -49,11 +49,12 @@ void createTable() throws IOException { // end::backend-bean-service[] // tag::backend-transactional[] - @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. + // because both run in the one transaction this method began. IOException + // is checked, so it takes rollbackFor to make it roll back. mailer.send(email, "Welcome", "Thanks for signing up."); } // end::backend-transactional[] diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc new file mode 100644 index 00000000000..2fcd60b2d5d --- /dev/null +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -0,0 +1,446 @@ +[[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. 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` or `@SessionScope` 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 -- or `CN1_MAIL_HOST` -- 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. +Because the build sees both branches, it also checks both: every bean that needs a +conditional one must be covered whichever way the condition goes, by another bean +of the same type under the other condition or by being conditional itself. + +`@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. + +==== 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 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. +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 and a bean with `@Scheduled` methods must be singletons, +since each serves the whole life of the server rather than one request. + +=== 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: + +[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. +. `@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..22c662de706 --- /dev/null +++ b/docs/developer-guide/Backend-Data.asciidoc @@ -0,0 +1,392 @@ +[[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 + +`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: + +[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 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. 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 of its public +methods. + +==== 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. `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] +==== +Every `DataSource`, `Database` and dao method throws `IOException`, which is +checked. Under the default rule, a statement that fails and propagates out of a +`@Transactional` method therefore commits whatever the statements before it did. +Spring doesn't have this problem, because its JDBC layer throws unchecked +exceptions. Add `rollbackFor = IOException.class` to every `@Transactional` +method that writes through the pool, as the examples in this chapter do. +==== + +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. + +`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 commit.** Covered above, and worth repeating: without + `rollbackFor = IOException.class`, a failed statement that propagates out of the + method commits the ones before it. +* **`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..4689dfa88f2 --- /dev/null +++ b/docs/developer-guide/Backend-MCP.asciidoc @@ -0,0 +1,174 @@ +[[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`. 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. + +==== 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, an optional +`description` that goes into the schema, and `required`, true by default. 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. + +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. + +A program that builds its server itself adds a tool that isn't a bean method by +implementing `com.codename1.backend.mcp.McpTool` -- a name, a description, an +input schema and a `call` method -- and passing it to the builder with +`mcpTool(tool)`, beside `mcp(...)`. + +==== 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. + +| `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. +|=== + +Each server has its own tools, even with several in one process, so a server +restarted in a test serves the tools its own beans provide and nothing a previous +one registered. + +=== 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. Only reading statements run unless + `write` is true. + +| `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..bca13f48fe4 --- /dev/null +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -0,0 +1,298 @@ +[[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_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.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 +---- + +A server started without the builder -- one that calls `HttpServer.start` or runs +the Lambda loop itself -- installs the tracer in one line: + +[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 +---- + +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. 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`. + +`@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 the server drains +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. + +[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. +|=== + +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, from `Backend.getManagedBeans()`. diff --git a/docs/developer-guide/Backend-Operations.asciidoc b/docs/developer-guide/Backend-Operations.asciidoc new file mode 100644 index 00000000000..cdbdc377d50 --- /dev/null +++ b/docs/developer-guide/Backend-Operations.asciidoc @@ -0,0 +1,177 @@ +[[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 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. diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc new file mode 100644 index 00000000000..c4301343ea7 --- /dev/null +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -0,0 +1,257 @@ +[[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. 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. + +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. +|=== + +The choice matters because of how virtual threads work on this runtime, explained +under <>. A virtual thread parks only on the +socket it serves. An outbound read -- a database query, a call to another service +-- blocks the host thread running it, and with it every connection that host +serves. Work that talks to a database or makes outbound calls belongs on +`PLATFORM`, which is why it's the default. `VIRTUAL` suits work that's cheap to +start and mostly computes, or that there is a great deal of. + +`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. + +==== 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. 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 + +`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. + +=== Pitfalls + +* **Database work on a virtual thread** blocks the host thread under it. Leave such + work on `PLATFORM`. +* **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..01431fc9aaf --- /dev/null +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -0,0 +1,196 @@ +[[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 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] +---- + +[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, which it updates at most once a minute. + +=== 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. + +| `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 `jdbc`. 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. +|=== + +Every server has its own settings and its own sessions, even when two run in one +process. That matters because cookies aren't scoped by port: a browser sends a +cookie for `localhost` to every port on it, and two servers sharing one store +would accept each other's sessions. `Backend.getSessions()` returns a running +server's sessions, 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=jdbc` 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. + +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=jdbc` 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 inject the `HttpSession` it belongs to. It can't inject +the request, because it outlives the request that built it, and the build refuses +that with a message saying so. + +=== 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..47ada1a04ff --- /dev/null +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -0,0 +1,364 @@ +[[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` + +| 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, which produces maps, lists, strings, numbers and +booleans, so that's what a body binds to. A `List` would arrive as a list +of maps and fail the first time an element is used as an `Order`, so the build +refuses it. For typed bodies, declare the API as a contract, as described under +<>, which generates the codecs on both sides. + +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 implementing `Json.Writable` +| 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 class the router doesn't know how to +encode 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. + +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. + +==== 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. diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index 5ce70ef7eb6..5cb72c325d1 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,42 @@ 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. + === Virtual threads and the request loop The concurrency model is the part most worth understanding, because it's what @@ -128,6 +193,34 @@ Host count follows cores rather than the `workers` argument. On two pinned cores 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 +293,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,945 +306,48 @@ 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: - -[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] ----- - -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. - -=== 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. - -==== 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 -- or `CN1_MAIL_HOST` -- 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`. - -==== 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. - -==== 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. - -==== Testing - -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] ----- - -=== Transactions - -`@Transactional` runs a method in a database transaction: - -[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: the pool's own methods, the daos of an -entity manager, and a `com.codename1.orm.session.Session` injected into the bean. -The connection is borrowed, and the transaction begun, only when the method first -touches the database. The rules are Spring's. An unchecked exception or an -`Error` rolls back and a checked exception commits, which `rollbackFor` and -`noRollbackFor` adjust. `propagation` picks what happens when a transaction is -already open -- join it by default, or `REQUIRES_NEW`, `NESTED` for a savepoint, -`SUPPORTS`, `MANDATORY`, `NOT_SUPPORTED` and `NEVER` -- and `readOnly = true` -starts a transaction that PostgreSQL and MySQL refuse to write in. - -The build doesn't wrap the object in a proxy. It rewrites the compiled method: -the body moves to a method of its own, and the method keeps its name and becomes -three steps -- start the transaction, call that body, commit -- with the rollback -decision written in as `instanceof` tests. That has one consequence Spring users should know about, -and it's a welcome one: the transaction applies however the method is called, -including from `this`, from a private caller and on an object built with `new`, -each of which a Spring proxy skips. - -`Transactions.setRollbackOnly()` rolls back without throwing, for a method that -wants to return a value and still undo its work. - -=== Background work - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Reports.java[tag=backend-background,indent=0] ----- - -An `@Async` method returns at once. Its caller gets a `Future` -- or nothing, for -a `void` method -- that completes when the work does; the body returns -`AsyncResult.of(value)` to supply the result. An exception from a `void` method is -logged, because nobody holds anything to deliver it to. - -`@Scheduled` takes Spring's six-field cron -- seconds first -- or the `@daily` -style macros, or `fixedRate` and `fixedDelay` in milliseconds. A literal cron -expression is parsed by the build, so a typo fails the build instead of producing -a job that never runs. A run never overlaps the previous run of the same job. With -several instances of one server behind a load balancer, each would run every job; -`lock` makes a run first claim a row in a small table in the server's database, -so exactly one instance runs it. - -Background work runs on named executors, sized in configuration with -`cn1.task.executor..threads`. The `thread` attribute chooses the kind of -thread. A platform thread is the default and the right choice for anything that -talks to a database, for the reason given under <>: an -outbound read blocks the host thread under a virtual thread. A virtual thread -suits work that mostly waits on the network, and costs next to nothing to -start. Where there are no virtual threads -- the local JVM run, a TLS server, -Windows -- a virtual task runs on a platform thread instead. - -=== Sessions - -A server keeps state for a client in an `HttpSession`, found again through a -cookie: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Login.java[tag=backend-session,indent=0] ----- - -Nothing is stored and no cookie is sent until something asks for a session with -`getSession(true)`. The cookie is `HttpOnly` and `SameSite=Lax`, and `Secure` on a -TLS server. Sessions live in memory by default; `cn1.session.store=jdbc` keeps -them in the server's database instead, so any instance behind a load balancer can -serve any client, at the price of attributes being limited to what JSON can -store. - -=== 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. +| `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/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. +| `cn1.server.backlog` +| The listen backlog, 512 by default. -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. +| `cn1.server.shutdownTimeoutMillis` +| How long a stop waits for the requests in flight, 10 seconds by default. Zero + means don't wait. -==== Sending from somewhere else +| `cn1.server.tls.http2` +| Whether a TLS server offers HTTP/2 through ALPN. True by default. -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. +| `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`). -Sending is the other way round: a session may be written to from any thread, which -is what makes a broadcast possible. +| `cn1.datasource.pool.borrowTimeoutMillis` +| How long a request waits for a free connection before it fails, 10 seconds by + default. -[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. - -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 ----- - -A server started without the builder -- one that calls `HttpServer.start` or runs -the Lambda loop itself -- installs the tracer in one line: - -[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 ----- - -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 also exports metrics over OTLP, on the same -endpoint and with the same settings as its traces: request durations by route, -method and status, open connections, database pool use, the task queues, -scheduled job runs, memory, and uptime. `OTEL_METRIC_EXPORT_INTERVAL` sets how -often, a minute by default, and `cn1.otel.metrics.enabled=false` turns metrics off -while leaving traces on. - -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] ----- +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. -Each numeric `@ManagedAttribute` becomes a gauge named after the resource and the -attribute, `prices.size` here, read when metrics are collected. `@Timed` records -each call's duration as a histogram and `@Counted` counts calls and failures; like -`@Transactional`, they're woven into the method at build time. For anything else, -`Metrics.counter`, `Metrics.histogram` and `Metrics.gauge` create an instrument, -which belongs in a static field so recording a value looks nothing up. - -The management endpoints make the same information available over HTTP, for a -load balancer and for an operator: - ----- -GET /manage/health public; 503 while the server drains -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} ----- - -They're on by default on a development profile. Anywhere else, -`cn1.management.enabled=true` turns them on, and the server refuses to start -unless `cn1.management.token` is set too; every endpoint but health then expects -it as a bearer token. An operation needs the token even on a development profile, -because it changes the running server. - -=== Serving MCP - -The Model Context Protocol lets an AI agent call tools. A backend serves it at -`/mcp`, and there are two reasons to. - -The first 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: +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/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`. 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. - -The second is development. When the server runs through `cn1:backend` on a -development profile, the same endpoint also serves tools for inspecting and -exercising it: `backend_routes`, `backend_beans`, `backend_call` to send it a -request, `backend_requests` for what it served and what failed, `backend_logs`, -`backend_sql` against its database, `backend_jobs`, `backend_metrics` and -`backend_managed`. An agent building the backend uses them the way a developer -uses a debugger and a database console. The server prints the address when it -starts, and Claude Code connects to it 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. None of these tools are compiled into a -server built with `cn1:backend-package`, unless `-Dcn1.backend.devTools=true` -asks for them. - -=== 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"] +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/ServerSnippets.java[tag=backend-builder,indent=0] ---- -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 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. +`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. 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..60e211e0f9b --- /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 + + null: next + + 2 Trace relay + /otel/v1/traces, when enabled + + null: next + + 3 Management + /manage/..., when enabled + + null: next + + 4 Your routes + generated routers, handler() ones + + null: next + + 5 Static files + cn1.static.root, always last + null: 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; 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..8dc1e518f8d --- /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 + starts on a fixed grid + + + + + + + + + + + + + + 0s + + + 20s + + + 40s + + + 60s + + + 80s + + + 100s + fixedDelay = 10000 + waits after each end + + + + + + + + + + + 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..8100005dcbd --- /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=jdbc + 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 ff3b8a336de..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. @@ -773,7 +773,7 @@ Podman cron # The SQL construct a NESTED transaction rolls back to. SQL's own keyword, and the # term every database's documentation uses. -savepoint +savepoints? # ----------------------------------------------------------------------------- # Attaching an agent to a build on a device (MCP-Headless-API.asciidoc). diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index adf93b725e6..6408798bdd8 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -282,14 +282,18 @@ void secureSettingIsValidated() throws Exception { p.setProperty("cn1.session.secure", "tru"); try { IOException refused = assertThrows(IOException.class, - () -> Sessions.configure(Config.of(p, "test"), true, null)); + () -> 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); + 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 { - // Process-wide settings: put the defaults back for the next test. - Sessions.configure(Config.of(new Properties(), "test"), false, null); + p.clear(); } } @@ -337,7 +341,7 @@ void metrics() { @Test @DisplayName("the MCP endpoint lists and calls a tool, and reports a bad call as a tool error") void mcp() throws Exception { - McpServer.register(new McpTool() { + McpTool echo = new McpTool() { public String name() { return "echo"; } @@ -358,12 +362,12 @@ public Object call(Map arguments) { } 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).handler(new HttpServer.Handler() { + .mcp(null).mcpTool(echo).handler(new HttpServer.Handler() { public HttpServer.Response handle(HttpServer.Request request) { return null; } @@ -394,6 +398,28 @@ public HttpServer.Response handle(HttpServer.Request request) { } 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 @@ -402,7 +428,7 @@ void mcpNeedsATokenInProduction() { Properties settings = new Properties(); settings.setProperty(McpServer.ENABLED, "true"); IOException e = assertThrows(IOException.class, - () -> McpServer.fromConfig(Config.of(settings, "prod"), null, null)); + () -> McpServer.fromConfig(Config.of(settings, "prod"), null, null, null)); assertTrue(e.getMessage().contains(McpServer.TOKEN), e.getMessage()); } @@ -433,6 +459,9 @@ public boolean tracksCurrentRequest() { public void requestEnded(Object[] beans) { } + public void sessionEnded(Object[] beans) { + } + public Scheduler getScheduler() { return null; } diff --git a/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java index 1e88c3c5822..115f4df48ca 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java @@ -55,7 +55,6 @@ private static DataSource open(String engine) throws IOException { 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); - Transactions.setDefaultDataSource(pool); return pool; } diff --git a/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java index fbb0ecdeac0..ca1987326d2 100644 --- a/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java @@ -54,7 +54,6 @@ void open(@TempDir File dir) throws IOException { // 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); - Transactions.setDefaultDataSource(pool); } @AfterEach @@ -151,6 +150,58 @@ void nestedSavepoint() throws Exception { 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("MANDATORY refuses to run alone and NEVER refuses to run inside one") void mandatoryAndNever() throws Exception { 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 index 475b0790e83..3d538f1d3c8 100644 --- 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 @@ -34,6 +34,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -234,6 +235,8 @@ static final class Bean { 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. @@ -517,13 +520,61 @@ private Bean classBean(AnnotatedClass cls) { if (!chooseConstructor(bean)) { return null; } - for (FieldInfo f : cls.getFields()) { + // 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); + } + AnnotationValues props = cls.getClassAnnotation(CONFIG_PROPERTIES); + if (props != null) { + bindProperties(bean, props, cls); + } + collectJobs(bean); + collectTools(bean); + collectManaged(bean); + return bean; + } + + /// 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) { + for (FieldInfo f : declaring.getFields()) { boolean autowired = f.getAnnotation(AUTOWIRED) != null; AnnotationValues value = f.getAnnotation(VALUE); if (!autowired && value == null) { continue; } - String where = "field " + f.getName() + " of " + cls.getSourceName(); + 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."); @@ -534,15 +585,48 @@ private Bean classBean(AnnotatedClass cls) { + "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 : cls.getMethods()) { - if (m.isConstructor()) { + 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; } - String where = cls.getSourceName() + "." + m.getName(); + 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 (m.getAnnotation(AUTOWIRED) != null) { if (m.isStatic()) { ctx.error(cls, "@Autowired method " + where + " is static; the build " @@ -560,21 +644,14 @@ private Bean classBean(AnnotatedClass cls) { } bean.setters.add(call); } - if (m.getAnnotation(POST_CONSTRUCT) != null && lifecycle(cls, m, "@PostConstruct")) { + if (m.getAnnotation(POST_CONSTRUCT) != null + && lifecycle(declaring, m, "@PostConstruct")) { bean.postConstruct.add(m); } - if (m.getAnnotation(PRE_DESTROY) != null && lifecycle(cls, m, "@PreDestroy")) { + if (m.getAnnotation(PRE_DESTROY) != null && lifecycle(declaring, m, "@PreDestroy")) { bean.preDestroy.add(m); } } - AnnotationValues props = cls.getClassAnnotation(CONFIG_PROPERTIES); - if (props != null) { - bindProperties(bean, props, cls); - } - collectJobs(bean); - collectTools(bean); - collectManaged(bean); - return bean; } private boolean lifecycle(AnnotatedClass cls, MethodInfo m, String what) { @@ -627,7 +704,15 @@ private void readModifiers(Bean bean, Map annotations, // A request, session or prototype bean is already built on demand. bean.lazy = false; } - bean.onMissing = annotations.get(ON_MISSING) != null; + 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")); @@ -863,8 +948,19 @@ private void dropSteppedAside() { if (!b.onMissing) { continue; } + List wanted = stepAsideTypes(b); for (Bean other : beans) { - if (other != b && !other.onMissing && other.types.contains(b.type)) { + 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; } @@ -877,6 +973,29 @@ private void dropSteppedAside() { } } + /// 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() { 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 index 4c784ebc145..6a653785273 100644 --- 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 @@ -164,13 +164,19 @@ public MethodVisitor visitMethod(int access, String name, String descriptor, // 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_SYNCHRONIZED - | Opcodes.ACC_VARARGS | Opcodes.ACC_FINAL); + | Opcodes.ACC_PRIVATE | Opcodes.ACC_VARARGS | Opcodes.ACC_FINAL); MethodVisitor body = writer.visitMethod(bodyAccess, name + BODY_SUFFIX, descriptor, signature, exceptions); - MethodVisitor stub = writer.visitMethod(access, 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); } 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 index 9c2cfc116cd..6805b5f2f98 100644 --- 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 @@ -202,12 +202,12 @@ private void create(StringBuilder sb, List routers) { sb.append(" ").append(b.managed.adapterBinary).append(" managed") .append(b.var).append(" = new ").append(b.managed.adapterBinary).append('(') .append(ref).append(");\n"); - sb.append(" com.codename1.backend.Management.register(managed") + sb.append(" environment.registerManaged(managed") .append(b.var).append(");\n"); sb.append(" managed").append(b.var).append(".registerGauges();\n"); } for (BackendBeans.Tool t : b.tools) { - sb.append(" com.codename1.backend.mcp.McpServer.register(new ") + sb.append(" environment.registerTool(new ") .append(t.adapterBinary).append('(').append(ref).append("));\n"); } sb.append(" }\n"); @@ -652,12 +652,19 @@ private static void destroyCall(StringBuilder sb, BackendBeans.Bean b, String ca } private void requestEnded(StringBuilder sb) { - sb.append(" public void requestEnded(Object[] beans) {\n"); + 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"); for (BackendBeans.Bean b : model.beans) { // 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 (!BackendBeans.REQUEST.equals(b.scope) + if (!scope.equals(b.scope) || (b.preDestroy.isEmpty() && b.destroyMethod == null)) { continue; } 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 index 647b27fbb18..88f5421b741 100644 --- 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 @@ -445,6 +445,135 @@ public void aRequestScopedFactoryBeanIsDestroyedByItsDestroyMethod() throws Exce } } + @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("work$cn1body"); + 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 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 everyScopeAndBindingWorksAtRunTime() throws Exception { Map s = new LinkedHashMap(); diff --git a/scripts/initializr/common/src/main/resources/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md index 99984e2995c..2b4e67dcf52 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/backend.md +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -119,7 +119,7 @@ public class Transfers { - **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()` rolls back without throwing. +- `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. diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 7e5dd2305b2..4c6182d9fa7 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -88,12 +88,19 @@ public final class Backend { 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 {@link #getManagedBeans}. */ + private final List managedBeans; + /** This server's session settings and store. */ + private final Sessions sessions; private Backend(HttpServer server, DataSource dataSource, EntityManager entities, Config config, int shutdownMillis, Tracer ownTracer, Application application, - com.codename1.backend.metrics.MetricReader metricReader) { + com.codename1.backend.metrics.MetricReader metricReader, + List managedBeans, Sessions sessions) { this.metricReader = metricReader; + this.managedBeans = managedBeans; + this.sessions = sessions; this.server = server; this.dataSource = dataSource; this.entities = entities; @@ -138,6 +145,20 @@ 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); + } + + /** 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(); @@ -151,6 +172,40 @@ public void awaitTermination() { * 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) { + while(stopping) { + try { + wait(); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return; + } + } + if(stopped) { + return; + } + stopping = true; + } + try { + stopOnce(); + } finally { + synchronized(this) { + stopping = false; + stopped = true; + notifyAll(); + } + } + } + + private boolean stopping; + private boolean stopped; + + private void stopOnce() { // Scheduled jobs first, so none starts while the server drains; the // jobs already running are waited for with the requests. if(application != null) { @@ -166,7 +221,9 @@ public void stop() { Tasks.shutdown(shutdownMillis); // @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. + // 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(); @@ -277,6 +334,13 @@ public interface Application { */ void requestEnded(Object[] beans); + /** + * A session has ended -- invalidated, expired, or the server stopped; + * {@code beans} are its {@code @SessionScope} beans, whose destroy + * methods run here. + */ + void sessionEnded(Object[] beans); + /** The scheduler running this application's {@code @Scheduled} jobs, or null. */ Scheduler getScheduler(); @@ -308,10 +372,38 @@ public static final class Environment { private final DataSource dataSource; private final EntityManager entities; - Environment(Config config, DataSource dataSource, EntityManager entities) { + private final List tools; + private final List managed; + + 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 {@code @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) { + tools.add(tool); + } + + /** Registers a managed bean with this server. Generated code calls this. */ + public void registerManaged(ManagedBean bean) { + for(int iter = 0 ; iter < managed.size() ; iter++) { + if(((ManagedBean)managed.get(iter)).getObjectName() + .equals(bean.getObjectName())) { + managed.set(iter, bean); + return; + } + } + managed.add(bean); } public Config getConfig() { @@ -364,6 +456,9 @@ 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; private boolean createTablesGiven; private boolean handlersNeedADatabase; private boolean quiet; @@ -599,6 +694,18 @@ public Builder mcp(com.codename1.backend.mcp.McpServer.Extension devTools) { return this; } + /** + * Adds a tool of the program's own to the MCP endpoint, beside the + * {@code @McpTool} methods the build found. Needs {@link #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; @@ -660,6 +767,18 @@ private Backend startTraced(boolean tracing) throws Exception { // same as whether anything was configured: .dataSource(url) // makes the builder open one, and reading dataSourceGiven here // left exactly that case leaking on a failed start. + Application built = createdApplication; + createdApplication = null; + 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 (RuntimeException destroyErr) { + System.err.println("Destroying the application's beans failed: " + + destroyErr); + } + } if(pool != null && dataSource == null) { pool.close(); } @@ -682,15 +801,24 @@ private Backend startWith(DataSource pool, boolean tracing) throws Exception { // a catch-all controller route must not answer a health check. Management management = Management.fromConfig(config); if(management != null) { - routers.add(management); + // 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); } - // The pool a @Transactional method uses before it has asked for one - // by name -- a savepoint, or the transaction's session. - Transactions.setDefaultDataSource(pool); Tasks.configure(config); + // 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(); if(application != null) { HttpServer.Handler[] built = application.create( - new Environment(config, pool, manager)); + new Environment(config, pool, manager, tools, managedBeans)); if(built != null) { for(int iter = 0 ; iter < built.length ; iter++) { if(built[iter] != null) { @@ -704,7 +832,7 @@ private Backend startWith(DataSource pool, boolean tracing) throws Exception { // depends on them. com.codename1.backend.mcp.McpServer mcpServer = mcp ? com.codename1.backend.mcp.McpServer.fromConfig(config, mcpDevTools, - serviceName) : null; + serviceName, tools) : null; if(mcpServer != null) { routers.add(0, mcpServer); } @@ -815,9 +943,9 @@ public HttpServer.Response handle(HttpServer.Request request) { // context exists to leak there. This is a packaged-runtime path. boolean ownsContext = context != null && tls == null; HttpServer server; - if(application != null) { - Sessions.configure(config, context != null, pool); - } + final Sessions sessions = application != null + ? Sessions.configure(config, context != null, pool, application) + : new Sessions(); final Application app = application; final boolean track = application != null && application.tracksCurrentRequest(); try { @@ -828,6 +956,11 @@ public HttpServer.Response handle(HttpServer.Request request) long started = com.codename1.backend.metrics.Metrics .requestStarted(); 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; if(track) { previous = CURRENT_REQUEST.get(); CURRENT_REQUEST.set(request); @@ -846,18 +979,23 @@ public HttpServer.Response handle(HttpServer.Request request) break; } } + // 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. + HttpSession session = request.resolvedSession(); + if(session != null) { + response = sessions.finish(session, response); + } } catch (Exception err) { RequestLog.record(request, 500, startedMillis, err); throw err; } - RequestLog.record(request, response == null ? 404 - : response.getStatus(), startedMillis, null); // Null is a 404 from here, which is what a // router answers for a path it does not route. - HttpSession session = request.resolvedSession(); - if(session != null) { - response = Sessions.finish(session, response); - } + RequestLog.record(request, response == null ? 404 + : response.getStatus(), startedMillis, null); status = response == null ? 404 : response.getStatus(); return response; } finally { @@ -918,7 +1056,10 @@ public void register(HttpServer.WebSocketRegistry registry) } Backend backend = new Backend(server, pool, manager, config, drain, tracing ? tracer : null, application, - metricReader != null && measuring ? metricReader : null); + metricReader != null && measuring ? metricReader : null, + managedBeans, sessions); + // Backend.stop() tears the beans down from here on. + createdApplication = null; if(management != null) { management.attach(backend); } diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 7ba1bcac71d..b6c59ff9592 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -122,6 +122,8 @@ 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; /** This request's session once looked up; see {@link #getSession(boolean)}. */ private HttpSession session; private boolean sessionResolved; @@ -584,6 +586,7 @@ void releaseRetained() { this.session = null; this.sessionResolved = false; this.scopedBeans = null; + this.sessions = null; } /** The session of this request, creating one if it has none. */ @@ -601,9 +604,10 @@ public HttpSession getSession(boolean create) { if(sessionResolved && (session != null || !create)) { return session; } + Sessions owner = sessions != null ? sessions : Sessions.standalone(); try { - session = Sessions.find(sessionResolved ? null - : Sessions.cookieValue(getHeader("cookie"), Sessions.getCookieName()), + session = owner.find(sessionResolved ? null + : Sessions.cookieValue(getHeader("cookie"), owner.getCookieName()), create); } catch (IOException err) { throw new IllegalStateException("The session store failed: " @@ -677,6 +681,7 @@ void reset(Conn conn, String method, String target, String version, byte[] raw, this.session = null; this.sessionResolved = false; this.scopedBeans = null; + this.sessions = null; } /** diff --git a/vm/backend/src/com/codename1/backend/HttpSession.java b/vm/backend/src/com/codename1/backend/HttpSession.java index c07361f0cca..50e82cfd693 100644 --- a/vm/backend/src/com/codename1/backend/HttpSession.java +++ b/vm/backend/src/com/codename1/backend/HttpSession.java @@ -51,9 +51,10 @@ * numbers, booleans, and maps and lists of those -- because it has to read them * back in another process. * - *

A {@code @SessionScope} bean lives on the session object itself and is - * never written to a store, so a session read back from the database store - * starts with a fresh one. + *

A {@code @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; @@ -137,7 +138,8 @@ public synchronized void invalidate() { checkValid(); invalid = true; attributes.clear(); - beans = null; + // The @SessionScope beans stay until the request ends, when the server + // runs their destroy methods; dropping them here would skip those. dirty = true; } @@ -213,6 +215,20 @@ synchronized void loadAttributes(Map values) { } } + /** The session-scoped beans this object holds, or null. */ + synchronized Object[] beansOrNull() { + return beans; + } + + /** Hands this object the beans the server kept for its session, unless it has its own. */ + synchronized void attachBeans(Object[] kept) { + if(kept == null) { + beans = null; + } else if(beans == null) { + beans = kept; + } + } + /** * The {@code @SessionScope} beans of this session, by the slot the build gave * each. Called by generated code. diff --git a/vm/backend/src/com/codename1/backend/Management.java b/vm/backend/src/com/codename1/backend/Management.java index 1d914529692..d9e6642071a 100644 --- a/vm/backend/src/com/codename1/backend/Management.java +++ b/vm/backend/src/com/codename1/backend/Management.java @@ -55,8 +55,6 @@ public final class Management implements HttpServer.Handler { public static final String PATH = "cn1.management.path"; public static final String TOKEN = "cn1.management.token"; - private static final List BEANS = new ArrayList(); - private final String path; private final byte[] token; private final boolean development; @@ -101,26 +99,18 @@ void attach(Backend running) { this.backend = running; } - /** Registers a managed bean. Generated code calls this at start-up. */ - public static synchronized void register(ManagedBean bean) { - for(int iter = 0 ; iter < BEANS.size() ; iter++) { - if(((ManagedBean)BEANS.get(iter)).getObjectName().equals(bean.getObjectName())) { - BEANS.set(iter, bean); - return; - } - } - BEANS.add(bean); + /** The managed beans of the server this endpoint belongs to. */ + private List beans() { + return backend == null ? new ArrayList() : backend.getManagedBeans(); } - /** The registered managed beans. */ - public static synchronized List beans() { - return new ArrayList(BEANS); - } - - /** Every managed bean with its attributes' current values and its operations. */ - public static List describeBeans() { + /** + * Each of {@code all} -- a server's managed beans, from + * {@link Backend#getManagedBeans} -- with its attributes' current values and + * its operations. + */ + public static List describeBeans(List all) { List out = new ArrayList(); - List all = beans(); for(int iter = 0 ; iter < all.size() ; iter++) { ManagedBean bean = (ManagedBean)all.get(iter); Map m = new LinkedHashMap(); @@ -168,9 +158,8 @@ public static List describeBeans() { * * @throws IllegalArgumentException when there is no such bean or operation */ - public static Object invoke(String objectName, String operation, Map arguments) - throws Exception { - List all = beans(); + public static Object invoke(List all, String objectName, String operation, + Map arguments) throws Exception { for(int iter = 0 ; iter < all.size() ; iter++) { ManagedBean bean = (ManagedBean)all.get(iter); if(!bean.getObjectName().equals(objectName)) { @@ -225,7 +214,7 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { : scheduler.describe()); } if("/managed".equals(rest)) { - return request.respondJson(200, describeBeans()); + return request.respondJson(200, describeBeans(beans())); } return null; } @@ -243,7 +232,7 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { Map result = new LinkedHashMap(); // Bean and operation names are Java identifiers, so there is // nothing to percent-decode: an escaped one matches nothing. - result.put("result", invoke(parts[0], parts[1], arguments)); + result.put("result", invoke(beans(), parts[0], parts[1], arguments)); return request.respondJson(200, result); } catch (IllegalArgumentException err) { return request.respondJson(404, error(err.getMessage())); diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index a174f0c1a9c..24f561403be 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -24,6 +24,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; @@ -48,41 +49,73 @@ * calls it costs one field check when it ends. */ public final class Sessions { - private static String cookieName = "CN1SESSION"; - private static int timeoutSeconds = 1800; - private static String sameSite = "Lax"; - private static boolean secure; - private static SessionStore store = new Memory(); - private static long lastPurge; + 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; - private Sessions() { + /** + * 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 {@code cn1.session.*}. Called by the server when it starts. + * Reads {@code 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. * * @param tls whether the server terminates TLS, for {@code secure=auto} * @param pool the database, for {@code store=jdbc} + * @param application destroys the session-scoped beans, or null */ - public static synchronized void configure(Config config, boolean tls, DataSource pool) - throws IOException { - cookieName = config.get("cn1.session.cookie", "CN1SESSION"); - timeoutSeconds = config.getInt("cn1.session.timeout", 1800); + 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"); + 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"); } - sameSite = site; + out.sameSite = site; String secureSetting = config.get("cn1.session.secure", "auto").trim(); if("auto".equalsIgnoreCase(secureSetting)) { - secure = tls; + out.secure = tls; } else if("true".equalsIgnoreCase(secureSetting)) { - secure = true; + out.secure = true; } else if("false".equalsIgnoreCase(secureSetting)) { - secure = false; + 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 @@ -90,7 +123,7 @@ public static synchronized void configure(Config config, boolean tls, DataSource throw new IOException("cn1.session.secure is \"" + secureSetting + "\"; it must be auto, true or false"); } - if("None".equalsIgnoreCase(sameSite) && !secure) { + 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; " @@ -102,19 +135,29 @@ public static synchronized void configure(Config config, boolean tls, DataSource throw new IOException("cn1.session.store=jdbc needs a database, and this " + "server has none"); } - store = new Jdbc(pool); - } else if("memory".equalsIgnoreCase(kind)) { - if(!(store instanceof Memory)) { - store = new Memory(); - } - } else { + out.store = new Jdbc(pool); + } else if(!"memory".equalsIgnoreCase(kind)) { throw new IOException("cn1.session.store is \"" + kind + "\"; it must be memory or jdbc"); } + return out; + } + + private static Sessions standalone; + + /** + * The sessions of a bare HttpServer that no Backend started, which has no + * per-server settings to read. A Backend always hands its requests its own. + */ + static synchronized Sessions standalone() { + if(standalone == null) { + standalone = new Sessions(); + } + return standalone; } /** Replaces the store, for one of the application's own. */ - public static synchronized void setStore(SessionStore replacement) { + public synchronized void setStore(SessionStore replacement) { if(replacement == null) { throw new IllegalArgumentException("No store"); } @@ -122,12 +165,12 @@ public static synchronized void setStore(SessionStore replacement) { } /** The store sessions are kept in. */ - public static synchronized SessionStore getStore() { + public synchronized SessionStore getStore() { return store; } /** The name of the session cookie. */ - public static synchronized String getCookieName() { + public synchronized String getCookieName() { return cookieName; } @@ -147,10 +190,10 @@ static String newId() { * The request's session, creating one when {@code create} is set. What * {@code Request.getSession} calls. */ - static HttpSession find(String cookieValue, boolean create) throws IOException { + HttpSession find(String cookieValue, boolean create) throws IOException { SessionStore s; int timeout; - synchronized(Sessions.class) { + synchronized(this) { s = store; timeout = timeoutSeconds; } @@ -160,10 +203,18 @@ static HttpSession find(String cookieValue, boolean create) throws IOException { HttpSession found = s.load(cookieValue); if(found != null && found.isValid() && !found.isExpired(now)) { found.touch(now); + Held held; + synchronized(this) { + held = (Held)beans.get(cookieValue); + } + if(held != null) { + found.attachBeans(held.beans); + } return found; } if(found != null) { s.delete(cookieValue); + destroy(take(cookieValue), found.beansOrNull()); } } if(!create) { @@ -174,12 +225,30 @@ static HttpSession find(String cookieValue, boolean create) throws IOException { return created; } - private static void purgeIfDue(SessionStore s, long now) { - synchronized(Sessions.class) { + private void purgeIfDue(SessionStore s, long now) { + List expired = null; + synchronized(this) { if(now - lastPurge < PURGE_INTERVAL) { return; } lastPurge = now; + Iterator it = beans.values().iterator(); + while(it.hasNext()) { + Held h = (Held)it.next(); + if(h.maxInactiveSeconds > 0 + && now - h.lastAccessed > h.maxInactiveSeconds * 1000L) { + it.remove(); + if(expired == null) { + expired = new ArrayList(); + } + expired.add(h.beans); + } + } + } + if(expired != null) { + for(int iter = 0 ; iter < expired.size() ; iter++) { + destroy((Object[])expired.get(iter), null); + } } try { s.purgeExpired(now); @@ -192,7 +261,7 @@ private static void purgeIfDue(SessionStore s, long now) { * Stores what the request did to its session and adds the cookie the client * needs. Called by the server after the handler returns. */ - static HttpServer.Response finish(HttpSession session, HttpServer.Response response) + HttpServer.Response finish(HttpSession session, HttpServer.Response response) throws IOException { if(session == null) { return response; @@ -202,19 +271,27 @@ static HttpServer.Response finish(HttpSession session, HttpServer.Response respo String previous = session.previousId(); if(!session.isValid()) { s.delete(session.getId()); + Object[] kept = take(session.getId()); if(previous != null) { s.delete(previous); + destroy(take(previous), null); } + // The beans end with the session, not with whatever next finds it gone. + destroy(kept, session.beansOrNull()); + session.attachBeans(null); cookie = cookie("", 0); - } else if(session.isDirty()) { - boolean announce = session.isNew() || previous != null; - s.save(session, previous); - if(announce) { - cookie = cookie(session.getId(), -1); + } else { + if(session.isDirty()) { + boolean announce = session.isNew() || previous != null; + s.save(session, previous); + if(announce) { + cookie = cookie(session.getId(), -1); + } + session.clean(); + } else if(s instanceof Jdbc) { + ((Jdbc)s).touchIfStale(session); } - session.clean(); - } else if(s instanceof Jdbc) { - ((Jdbc)s).touchIfStale(session); + keep(session, previous); } if(cookie == null || response == null) { return response; @@ -222,7 +299,84 @@ static HttpServer.Response finish(HttpSession session, HttpServer.Response respo return withHeader(response, "Set-Cookie", cookie); } - private static synchronized String cookie(String value, int maxAge) { + /** Records the session's beans under its current id, after a request used it. */ + private void keep(HttpSession session, String previousId) { + Object[] live = session.beansOrNull(); + synchronized(this) { + Held held = previousId == null ? null : (Held)beans.remove(previousId); + if(live == null && held == null) { + held = (Held)beans.get(session.getId()); + if(held == null) { + return; + } + } + if(held == null) { + held = (Held)beans.get(session.getId()); + } + if(held == null) { + held = new Held(); + } + if(live != null) { + held.beans = live; + } + 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()); + } else { + beans.put(session.getId(), held); + return; + } + } + destroy(live, null); + } + + private synchronized Object[] take(String id) { + Held held = (Held)beans.remove(id); + return held == null ? null : held.beans; + } + + /** Runs the destroy methods of one session's beans; the second array may repeat the first. */ + private void destroy(Object[] first, Object[] second) { + if(application == null) { + return; + } + if(first != null) { + ended(first); + } + if(second != null && second != first) { + ended(second); + } + } + + 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()); + beans.clear(); + } + for(int iter = 0 ; iter < all.size() ; iter++) { + destroy(((Held)all.get(iter)).beans, null); + } + } + + private synchronized String cookie(String value, int maxAge) { StringBuilder sb = new StringBuilder(cookieName).append('=').append(value) .append("; Path=/; HttpOnly; SameSite=").append(sameSite); if(secure) { @@ -234,6 +388,13 @@ private static synchronized String cookie(String value, int 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; + } + /** * {@code 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 diff --git a/vm/backend/src/com/codename1/backend/Transactions.java b/vm/backend/src/com/codename1/backend/Transactions.java index a925dd8cb64..62f2cc988ac 100644 --- a/vm/backend/src/com/codename1/backend/Transactions.java +++ b/vm/backend/src/com/codename1/backend/Transactions.java @@ -80,13 +80,6 @@ public final class Transactions { */ static boolean used; - /** - * The pool a transaction uses when the first thing it needs is a connection - * nobody asked for by pool -- a savepoint, or a session. Set by the server - * when it opens its database. - */ - private static DataSource defaultPool; - private static final int KIND_NONE = 0; private static final int KIND_JOINED = 1; private static final int KIND_NEW = 2; @@ -101,8 +94,25 @@ static final class Physical { 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; + /** How many joined or nested calls are running inside the one that began it. */ + int participants; 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) { @@ -142,11 +152,6 @@ public boolean isNewTransaction() { } } - /** Sets the pool a transaction uses when nothing else names one. */ - public static void setDefaultDataSource(DataSource pool) { - defaultPool = pool; - } - /** Whether the calling thread is inside a transaction. */ public static boolean isActive() { return used && CURRENT.get() != null; @@ -155,7 +160,7 @@ public static boolean isActive() { /** Whether the calling thread's transaction has been marked rollback-only. */ public static boolean isRollbackOnly() { Physical p = used ? (Physical)CURRENT.get() : null; - return p != null && p.rollbackOnly; + return p != null && (p.rollbackOnly || p.localRollback); } /** @@ -169,7 +174,15 @@ public static void setRollbackOnly() { throw new TransactionException.IllegalState("setRollbackOnly() outside a " + "transaction: there is nothing to roll back"); } - p.rollbackOnly = true; + if(p.participants == 0) { + // The method that began the transaction: it returns normally and + // its work is undone, with nothing thrown. + p.localRollback = true; + } else { + // A joined method: whoever began the transaction must learn that + // what it thinks it committed was not saved. + p.rollbackOnly = true; + } } /** @@ -181,6 +194,14 @@ public static void setRollbackOnly() { */ 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.participants++; + } + return tx; + } + + private static Transaction open(int propagation, boolean readOnly, int timeoutSeconds) { Physical current = (Physical)CURRENT.get(); switch(propagation) { case SUPPORTS: @@ -225,18 +246,20 @@ private static Transaction fresh(boolean readOnly, int timeoutSeconds, Physical } private static Transaction nested(Physical current) { - Database db; - try { - db = materialize(current, current.pool != null ? current.pool : defaultPool); - } catch (IOException err) { - throw new TransactionException("Could not begin the transaction a NESTED " - + "method needs: " + err.getMessage(), err); - } + String name = "cn1_sp_" + (++current.savepoints); + Database db = current.db; if(db == null) { - throw new TransactionException.IllegalState("A NESTED method needs a database to " - + "set its savepoint in, and this server has none."); + // 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); } - String name = "cn1_sp_" + (++current.savepoints); try { flushSession(current); db.savepoint(name); @@ -256,8 +279,13 @@ public static void commit(Transaction tx) { 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; + } try { flushSession(tx.physical); tx.physical.db.releaseSavepoint(tx.savepoint); @@ -307,11 +335,16 @@ public static void afterThrow(Transaction tx, boolean rollback) { 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; + } try { tx.physical.db.rollbackToSavepoint(tx.savepoint); tx.physical.db.releaseSavepoint(tx.savepoint); @@ -338,6 +371,19 @@ public static void afterThrow(Transaction tx, boolean rollback) { } } + /** 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.participants--; + } + } + + /** 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) { @@ -352,6 +398,10 @@ private static void finishNew(Physical p) { + "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 " @@ -481,7 +531,20 @@ private static Database materialize(Physical p, DataSource pool) throws IOExcept 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; } diff --git a/vm/backend/src/com/codename1/backend/annotations/ConditionalOnMissingBean.java b/vm/backend/src/com/codename1/backend/annotations/ConditionalOnMissingBean.java index 6a603dde5f6..e59d644a7e1 100644 --- a/vm/backend/src/com/codename1/backend/annotations/ConditionalOnMissingBean.java +++ b/vm/backend/src/com/codename1/backend/annotations/ConditionalOnMissingBean.java @@ -32,7 +32,14 @@ /// 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/mcp/DevTools.java b/vm/backend/src/com/codename1/backend/mcp/DevTools.java index b0d2cacc5a1..43edd4d3a9c 100644 --- a/vm/backend/src/com/codename1/backend/mcp/DevTools.java +++ b/vm/backend/src/com/codename1/backend/mcp/DevTools.java @@ -69,7 +69,7 @@ public void install(McpServer server, Backend running) { this.backend = running; RequestLog.enable(200); DevConsole.install(); - McpServer.register(new Tool("backend_routes", + 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()) { @@ -78,7 +78,7 @@ Object run(Map a) { : application().describeRoutes(); } }); - McpServer.register(new Tool("backend_beans", + 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()) { @@ -87,14 +87,14 @@ Object run(Map a) { : application().describeBeans(); } }); - McpServer.register(new Tool("backend_config", + server.register(new Tool("backend_config", "Shows the active profile and every configured key, with values that " + "look secret masked.", schema()) { Object run(Map a) throws Exception { return config(); } }); - McpServer.register(new Tool("backend_call", + 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", @@ -106,7 +106,7 @@ Object run(Map a) throws Exception { return call(a); } }); - McpServer.register(new Tool("backend_requests", + 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", @@ -115,7 +115,7 @@ Object run(Map a) { return RequestLog.recent(intArg(a, "limit", 20), boolArg(a, "failuresOnly")); } }); - McpServer.register(new Tool("backend_logs", + 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)) { @@ -127,7 +127,7 @@ Object run(Map a) { return DevConsole.recent(intArg(a, "limit", 100)); } }); - McpServer.register(new Tool("backend_sql", + 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.", @@ -139,14 +139,14 @@ Object run(Map a) throws Exception { return sql(a); } }); - McpServer.register(new Tool("backend_schema", + server.register(new Tool("backend_schema", "Lists the entities the build generated persistence for, with their " + "tables and columns.", schema()) { Object run(Map a) { return schemaOf(); } }); - McpServer.register(new Tool("backend_jobs", + server.register(new Tool("backend_jobs", "Lists the scheduled jobs: schedule, runs, failures, last and next run.", schema()) { Object run(Map a) { @@ -154,7 +154,7 @@ Object run(Map a) { return s == null ? new ArrayList() : s.describe(); } }); - McpServer.register(new Tool("backend_run_job", + 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"})) { @@ -167,7 +167,7 @@ Object run(Map a) { return "started"; } }); - McpServer.register(new Tool("backend_metrics", + 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 " @@ -176,14 +176,14 @@ Object run(Map a) { return metrics(stringArg(a, "prefix")); } }); - McpServer.register(new Tool("backend_managed", + server.register(new Tool("backend_managed", "Lists the @ManagedResource beans with their attributes' current values " + "and their operations.", schema()) { Object run(Map a) { - return Management.describeBeans(); + return Management.describeBeans(backend.getManagedBeans()); } }); - McpServer.register(new Tool("backend_invoke", + 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", @@ -191,7 +191,7 @@ Object run(Map a) { new String[] {"bean", "operation"})) { Object run(Map a) throws Exception { Object args = a.get("arguments"); - return Management.invoke(stringArg(a, "bean"), stringArg(a, "operation"), + return Management.invoke(backend.getManagedBeans(), stringArg(a, "bean"), stringArg(a, "operation"), args instanceof Map ? (Map)args : new LinkedHashMap()); } }); diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index d0fdd07e91f..942cf8de587 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -73,7 +73,13 @@ public final class McpServer implements HttpServer.Handler { /** 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"}; - private static final List TOOLS = new ArrayList(); + /** + * 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; @@ -90,7 +96,12 @@ public interface Extension { private final Extension devTools; private McpServer(String path, String token, String[] allowedOrigins, String serverName, - Extension devTools) { + Extension devTools, List tools) { + if(tools != null) { + for(int iter = 0 ; iter < tools.size() ; iter++) { + register((McpTool)tools.get(iter)); + } + } this.path = path; this.token = token == null || token.length() == 0 ? null : utf8(token); this.allowedOrigins = allowedOrigins; @@ -105,16 +116,14 @@ private McpServer(String path, String token, String[] allowedOrigins, String ser * @param devTools the development tools, which the build passes only in a * development build; they are installed only on a development profile * or with {@code cn1.mcp.devTools=true} + * @param tools the application's tools, the ones its server registered */ - public static McpServer fromConfig(Config config, Extension devTools, String serverName) - throws IOException { + 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; - synchronized(McpServer.class) { - anyTools = !TOOLS.isEmpty(); - } + boolean anyTools = tools != null && !tools.isEmpty(); if(!config.getBoolean(ENABLED, anyTools || extension != null)) { return null; } @@ -148,23 +157,23 @@ public static McpServer fromConfig(Config config, Extension devTools, String ser } return new McpServer(path, token, allowed, serverName == null || serverName.length() == 0 ? "codenameone-backend" - : serverName, extension); + : serverName, extension, tools); } - /** Registers a tool, replacing one of the same name. */ - public static 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); + /** 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); + tools.add(tool); } - /** Every registered tool. */ - public static synchronized List tools() { - return new ArrayList(TOOLS); + /** Every tool on this endpoint. */ + public synchronized List tools() { + return new ArrayList(tools); } /** Whether the development tools are installed on this endpoint. */ diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index 72869cb4ee5..eaf9b1eb43b 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -71,6 +71,8 @@ public final class OtlpMetricExporter implements MetricReader { private Thread thread; private final Object lock = new Object(); private boolean stopping; + /** Whether the exporter thread sends one last export before it ends. */ + private boolean finalExport; private long exports; private long failures; private String lastError; @@ -135,11 +137,18 @@ private void loop() { } } if(stopping) { - return; + if(!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. */ @@ -182,11 +191,18 @@ public void shutdown(int timeoutMillis) { return; } 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 connect and read timeouts. + finalExport = timeoutMillis > 0; lock.notifyAll(); } if(thread != null && timeoutMillis > 0) { - // One last export, so the counts of the final minute are not lost. - export(); + try { + thread.join(timeoutMillis); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } } } From d4d761e70790c8bbba4be9483cf85f921493008c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:16:36 +0300 Subject: [PATCH 04/48] Backend: per-server executors, bounded shutdown, lifecycle and DST fixes - Executors belong to the server that opened them: request, task, scheduler and start-up threads carry it, stopping one server no longer shuts down another's, and generated @Async code looks the executor up per call. At the shutdown deadline queued tasks are dropped rather than run against destroyed beans, and running ones are interrupted; a virtual task a host cannot run falls back to its own executor. - Every server applies cn1.session.* (handler-only ones sent a TLS session cookie without Secure); a failing request still stores its session so its session beans are kept or destroyed. - Request beans are destroyed while their request is still current; @PreDestroy runs subclass before superclass; factory beans run inherited lifecycle methods. - Health reports STARTING until the start-up hook returns. - A cron time inside a DST gap is skipped instead of firing an hour late. - The metrics exporter can be reopened; histograms copy and validate their bounds and labels. - A managed resource must be a singleton and cannot overload operations. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/developer-guide/Backend-Beans.asciidoc | 10 +- .../Backend-Observability.asciidoc | 12 +- .../Backend-Scheduling.asciidoc | 4 + .../backend/ApplicationRuntimeTest.java | 127 ++++++++++++ .../backend/otel/OtlpMetricsProtoTest.java | 31 +++ .../maven/processors/BackendBeans.java | 73 +++++-- .../maven/processors/BackendSources.java | 26 +-- .../maven/processors/BackendWiringWriter.java | 13 +- .../maven/processors/BackendBeansTest.java | 114 +++++++++++ .../src/com/codename1/backend/Backend.java | 102 ++++++++-- .../com/codename1/backend/CronSchedule.java | 8 + .../src/com/codename1/backend/HttpServer.java | 4 +- .../src/com/codename1/backend/Management.java | 12 +- .../com/codename1/backend/TaskExecutor.java | 78 ++++++- .../src/com/codename1/backend/Tasks.java | 192 +++++++++++++----- .../codename1/backend/metrics/Histogram.java | 38 +++- .../backend/otel/OtlpMetricExporter.java | 42 ++-- 17 files changed, 749 insertions(+), 137 deletions(-) diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc index 2fcd60b2d5d..8e7155ffed6 100644 --- a/docs/developer-guide/Backend-Beans.asciidoc +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -352,7 +352,9 @@ since each serves the whole life of the server rather than one request. 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: +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] ---- @@ -366,7 +368,11 @@ 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. +. `@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. Each server has its own executors, so stopping one + server leaves another in the same process running. . `@PreDestroy` and `destroyMethod` run, in reverse dependency order. . The connection pool closes. . The last metrics and spans are exported. diff --git a/docs/developer-guide/Backend-Observability.asciidoc b/docs/developer-guide/Backend-Observability.asciidoc index bca13f48fe4..07205888713 100644 --- a/docs/developer-guide/Backend-Observability.asciidoc +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -191,7 +191,9 @@ attribute, numeric or not, appears in the management listing. A 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`. +`@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 @@ -255,7 +257,7 @@ The management endpoints make the same information available over HTTP, for a load balancer and for an operator: ---- -GET /manage/health public; 503 while the server drains +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 @@ -265,6 +267,12 @@ POST /manage/managed/{bean}/{operation} An operation's body is a JSON object of its arguments by parameter 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 diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc index c4301343ea7..12842ff1978 100644 --- a/docs/developer-guide/Backend-Scheduling.asciidoc +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -187,6 +187,10 @@ bad one stops the server. 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. +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. + ==== Fixed rate and fixed delay `fixedRate` starts runs a fixed number of milliseconds apart, measured from start diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 6408798bdd8..17b0be1cbb3 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -64,6 +64,16 @@ class ApplicationRuntimeTest { // ------------------------------------------------------------------ cron + @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() { @@ -201,6 +211,106 @@ public void run() { // --------------------------------------------------------------- sessions + @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("each server has its own executors, and stopping one leaves the other's running") + void executorsArePerServer() throws Exception { + final TaskExecutor[] seen = new TaskExecutor[2]; + Backend[] servers = new Backend[2]; + int[] ports = {freePort(), freePort()}; + for(int i = 0 ; i < 2 ; i++) { + final int index = i; + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(ports[i])); + servers[i] = Backend.builder(Config.of(settings, "test")).quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + seen[index] = Tasks.executor("jobs", Tasks.PLATFORM); + return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); + } + }).start(); + } + try { + assertEquals("ok", read(open(ports[0], "/"))); + assertEquals("ok", read(open(ports[1], "/"))); + assertTrue(seen[0] != null && seen[1] != null && seen[0] != seen[1], + "two servers shared one executor"); + servers[1].stop(); + assertTrue(seen[1].isShutdown()); + assertFalse(seen[0].isShutdown(), "stopping one server shut the other's executor"); + final CountDownLatch ran = new CountDownLatch(1); + seen[0].execute(new Runnable() { + public void run() { + ran.countDown(); + } + }); + assertTrue(ran.await(5, TimeUnit.SECONDS)); + } finally { + servers[0].stop(); + servers[1].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 { @@ -297,6 +407,23 @@ void secureSettingIsValidated() throws Exception { } } + @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() { 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 index 9aa224a6b36..f9be3af1908 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -38,6 +38,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; 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 @@ -72,4 +73,34 @@ void histogramBuckets() throws Exception { assertEquals(1260.5, point.getSum(), 1e-9); assertNotNull(point.getAttributesList()); } + + @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); + } + } } 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 index 3d538f1d3c8..4beb6a282e9 100644 --- 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 @@ -520,6 +520,22 @@ private Bean classBean(AnnotatedClass cls) { if (!chooseConstructor(bean)) { return null; } + hierarchy(bean, cls, false); + AnnotationValues props = cls.getClassAnnotation(CONFIG_PROPERTIES); + if (props != null) { + bindProperties(bean, props, cls); + } + 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. @@ -546,16 +562,8 @@ private Bean classBean(AnnotatedClass cls) { } } members(bean, cls, chain.get(level), level > 0 && !concerns(chain.get(level)), - overridden, declaredBelow, fieldNames); - } - AnnotationValues props = cls.getClassAnnotation(CONFIG_PROPERTIES); - if (props != null) { - bindProperties(bean, props, cls); + overridden, declaredBelow, fieldNames, lifecycleOnly); } - collectJobs(bean); - collectTools(bean); - collectManaged(bean); - return bean; } /// The injection points and lifecycle methods one class of a bean's @@ -567,8 +575,8 @@ private Bean classBean(AnnotatedClass cls) { /// @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) { - for (FieldInfo f : declaring.getFields()) { + 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) { @@ -627,7 +635,7 @@ private void members(Bean bean, AnnotatedClass cls, AnnotatedClass declaring, continue; } String where = declaring.getSourceName() + "." + m.getName(); - if (m.getAnnotation(AUTOWIRED) != null) { + if (!lifecycleOnly && m.getAnnotation(AUTOWIRED) != null) { if (m.isStatic()) { ctx.error(cls, "@Autowired method " + where + " is static; the build " + "injects instances."); @@ -829,16 +837,9 @@ private Bean factoryBean(Bean owner, MethodInfo m) { bean.constructorPoints.add(p); } if (bean.cls != null && ctx.lookup(bean.type) != null) { - for (MethodInfo lm : bean.cls.getMethods()) { - if (lm.getAnnotation(POST_CONSTRUCT) != null - && lifecycle(bean.cls, lm, "@PostConstruct")) { - bean.postConstruct.add(lm); - } - if (lm.getAnnotation(PRE_DESTROY) != null - && lifecycle(bean.cls, lm, "@PreDestroy")) { - bean.preDestroy.add(lm); - } - } + // 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); } AnnotationValues props = m.getAnnotation(CONFIG_PROPERTIES); if (props != null) { @@ -1163,6 +1164,17 @@ private void collectManaged(Bean bean) { 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 @@ -1179,6 +1191,12 @@ private void collectManaged(Bean bean) { + "that takes no arguments and returns a value."); 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", "")); @@ -1203,6 +1221,17 @@ private void collectManaged(Bean bean) { AnnotationValues named = parameterAnnotations(m, i).get(MCP_PARAM); names.add(named != null ? named.getString("value") : "arg" + i); } + 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", "")); 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 index 23c0fae42d6..13a7e8b4297 100644 --- 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 @@ -246,15 +246,6 @@ private void metrics(StringBuilder sb, AnnotatedClass cls, BackendBeans.Aspect a /// 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) { - fieldAccessor(sb, type, field, create, null); - } - - /** - * @param stale a condition on {@code value} under which the cached object is - * thrown away and fetched again, or null when it never goes stale - */ - private void fieldAccessor(StringBuilder sb, String type, String field, String create, - String stale) { // 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. @@ -265,8 +256,7 @@ private void fieldAccessor(StringBuilder sb, String type, String field, String c .append("() {\n"); pendingMembers.append(" ").append(type).append(" value = ").append(field) .append(";\n"); - pendingMembers.append(" if (value == null").append(stale == null ? "" : " || " + stale) - .append(") {\n value = ").append(create) + pendingMembers.append(" if (value == null) {\n value = ").append(create) .append(";\n ").append(field).append(" = value;\n }\n"); pendingMembers.append(" return value;\n }\n\n"); } @@ -278,18 +268,16 @@ private void async(StringBuilder sb, AnnotatedClass cls, BackendBeans.Aspect asp String retType, MethodInfo m) { String executor = aspect.async.getStringOrDefault("value", ""); String thread = BackendBeans.enumName(aspect.async.get("thread"), "PLATFORM"); - fieldAccessor(sb, "com.codename1.backend.TaskExecutor", "E" + index, - "com.codename1.backend.Tasks.executor(" + quote(executor) - + ", com.codename1.backend.Tasks." + thread + ")", - // Tasks.shutdown() stops and forgets every executor when the - // server stops, so a server started again in the same process - // must not keep submitting to the stopped one it cached. - "value.isShutdown()"); + // 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(" e").append(index).append("().execute(cn1Task);\n"); + sb.append(" ").append(lookup).append(".execute(cn1Task);\n"); if (!isVoid) { sb.append(" return cn1Task;\n"); } 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 index 6805b5f2f98..409da43f38d 100644 --- 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 @@ -635,7 +635,7 @@ private void destroy(StringBuilder sb, BackendBeans.Bean b, String ref) { return; } sb.append(" if (").append(ref).append(" != null) {\n"); - for (MethodInfo m : b.preDestroy) { + for (MethodInfo m : destroyOrder(b)) { destroyCall(sb, b, ref + "." + callName(m) + "()"); } if (b.destroyMethod != null) { @@ -644,6 +644,15 @@ private void destroy(StringBuilder sb, BackendBeans.Bean b, String ref) { 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") @@ -673,7 +682,7 @@ private void scopeEnded(StringBuilder sb, String method, String scope) { .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 : b.preDestroy) { + for (MethodInfo m : destroyOrder(b)) { sb.append(" "); destroyCall(sb, b, "bean." + callName(m) + "()"); } 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 index 88f5421b741..d20402ff5c4 100644 --- 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 @@ -574,6 +574,120 @@ public void sessionScopedBeansAreDestroyedWhenTheSessionIsInvalidated() throws E 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 everyScopeAndBindingWorksAtRunTime() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 4c6182d9fa7..44ef65a5205 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -92,12 +92,21 @@ public final class Backend { private final List managedBeans; /** This server's session settings and store. */ private final Sessions sessions; + /** This server's executors. */ + private final Tasks.Registry tasks; + /** + * 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, Application application, com.codename1.backend.metrics.MetricReader metricReader, - List managedBeans, Sessions sessions) { + List managedBeans, Sessions sessions, Tasks.Registry tasks) { + this.tasks = tasks; this.metricReader = metricReader; this.managedBeans = managedBeans; this.sessions = sessions; @@ -154,6 +163,15 @@ 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; + } + /** This server's sessions: their settings and the store they are kept in. */ public Sessions getSessions() { return sessions; @@ -191,9 +209,12 @@ public void stop() { } stopping = true; } + // The callbacks below work for this server, whichever thread stops it. + Object callerTasks = Tasks.enter(tasks); try { stopOnce(); } finally { + Tasks.leave(callerTasks); synchronized(this) { stopping = false; stopped = true; @@ -218,7 +239,7 @@ private void stopOnce() { 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(shutdownMillis); + Tasks.shutdown(tasks, shutdownMillis); // @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 @@ -459,6 +480,8 @@ public static final class Builder { 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; @@ -750,6 +773,15 @@ public Backend start() throws Exception { } private Backend startTraced(boolean tracing) throws Exception { + Object callerTasks = Tasks.peek(); + try { + return startTracedOnce(tracing); + } finally { + Tasks.leave(callerTasks); + } + } + + private Backend startTracedOnce(boolean tracing) throws Exception { DataSource pool = openDataSource(); try { return startWith(pool, tracing); @@ -769,6 +801,13 @@ private Backend startTraced(boolean tracing) throws Exception { // 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(). + Tasks.shutdown(tasks, 0); + } 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. @@ -806,7 +845,12 @@ private Backend startWith(DataSource pool, boolean tracing) throws Exception { // /manage/health or a managed operation's path first. routers.add(relay != null ? 1 : 0, management); } - Tasks.configure(config); + // 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; + // 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 @@ -943,9 +987,11 @@ public HttpServer.Response handle(HttpServer.Request request) { // context exists to leak there. This is a packaged-runtime path. boolean ownsContext = context != null && tls == null; HttpServer server; - final Sessions sessions = application != null - ? Sessions.configure(config, context != null, pool, application) - : new Sessions(); + // 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); final Application app = application; final boolean track = application != null && application.tracksCurrentRequest(); try { @@ -961,6 +1007,7 @@ public HttpServer.Response handle(HttpServer.Request request) // two servers on one host would otherwise present // one's session to the other and be let in. request.sessions = sessions; + Object previousTasks = Tasks.enter(tasks); if(track) { previous = CURRENT_REQUEST.get(); CURRENT_REQUEST.set(request); @@ -972,6 +1019,7 @@ public HttpServer.Response handle(HttpServer.Request request) int status = 500; try { HttpServer.Response response = null; + boolean sessionFinished = false; try { for(int iter = 0 ; iter < chain.length ; iter++) { response = chain[iter].handle(request); @@ -986,9 +1034,25 @@ public HttpServer.Response handle(HttpServer.Request request) // handler's own status. HttpSession session = request.resolvedSession(); if(session != null) { + sessionFinished = true; response = sessions.finish(session, response); } } catch (Exception err) { + HttpSession session = request.resolvedSession(); + if(session != null && !sessionFinished) { + // 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(session, null); + } catch (Exception storeErr) { + System.err.println("Could not store the " + + "session of a failed request: " + + storeErr); + } + } RequestLog.record(request, 500, startedMillis, err); throw err; } @@ -1006,14 +1070,22 @@ public HttpServer.Response handle(HttpServer.Request request) // request would be recorded under it. com.codename1.backend.metrics.Metrics.requestEnded(started, request.getMethod(), status); - if(track) { - CURRENT_REQUEST.set(previous); - } - if(app != null) { - Object[] beans = request.takeScopedBeans(); - if(beans != null) { - app.requestEnded(beans); + // 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 { + if(app != null) { + Object[] beans = request.takeScopedBeans(); + if(beans != null) { + app.requestEnded(beans); + } + } + } finally { + if(track) { + CURRENT_REQUEST.set(previous); } + Tasks.leave(previousTasks); } } } @@ -1057,9 +1129,10 @@ public void register(HttpServer.WebSocketRegistry registry) Backend backend = new Backend(server, pool, manager, config, drain, tracing ? tracer : null, application, metricReader != null && measuring ? metricReader : null, - managedBeans, sessions); + managedBeans, sessions, tasks); // Backend.stop() tears the beans down from here on. createdApplication = null; + startingTasks = null; if(management != null) { management.attach(backend); } @@ -1083,6 +1156,7 @@ public void register(HttpServer.WebSocketRegistry registry) throw err; } } + backend.markReady(); if(!quiet) { announce(backend, listenPort, context != null); } diff --git a/vm/backend/src/com/codename1/backend/CronSchedule.java b/vm/backend/src/com/codename1/backend/CronSchedule.java index 1b4f313a0b3..affa9517684 100644 --- a/vm/backend/src/com/codename1/backend/CronSchedule.java +++ b/vm/backend/src/com/codename1/backend/CronSchedule.java @@ -337,6 +337,14 @@ public long next(long afterMillis) { } long candidateLocal = day * DAY + found * 1000L; long candidate = toUtc(candidateLocal); + if(candidate + offsetAt(candidate) != candidateLocal) { + // 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. + t = candidate > t ? candidate : t + 1000L; + continue; + } if(candidate > afterMillis) { return candidate; } diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index b6c59ff9592..3e7e7e373af 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -2683,7 +2683,7 @@ private static void releaseTaskInbox(VtHost host) { for(int iter = 0 ; iter < tokens.length ; iter++) { Runnable task = takeVirtualTask(tokens[iter].longValue()); if(task != null) { - Tasks.platform(task); + TaskExecutor.fallBack(task); } } if(host.wakeRead >= 0) { @@ -2722,7 +2722,7 @@ private void drainTaskInbox(VtHost me) { if(handle == 0) { Runnable task = takeVirtualTask(token); if(task != null) { - Tasks.platform(task); + TaskExecutor.fallBack(task); } continue; } diff --git a/vm/backend/src/com/codename1/backend/Management.java b/vm/backend/src/com/codename1/backend/Management.java index d9e6642071a..5afbfa31c7a 100644 --- a/vm/backend/src/com/codename1/backend/Management.java +++ b/vm/backend/src/com/codename1/backend/Management.java @@ -246,8 +246,12 @@ private HttpServer.Response health(HttpServer.Request request) { boolean up = true; if(backend != null) { Map server = backend.getServer().getMetrics(); - up = "ok".equals(server.get("status")); - out.put("status", up ? "UP" : "DRAINING"); + 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(); @@ -256,7 +260,9 @@ private HttpServer.Response health(HttpServer.Request request) { out.put("database", db); } } else { - out.put("status", "UP"); + // Not attached yet: the server is still being put together. + up = false; + out.put("status", "STARTING"); } return request.respondJson(up ? 200 : 503, out); } diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index 9c7d88beb9b..44eb1c07369 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -48,11 +48,18 @@ public final class TaskExecutor { 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) { + TaskExecutor(String name, boolean virtual, int size, Tasks.Registry registry) { this.name = name; this.virtual = virtual; this.size = size < 1 ? 1 : size; + this.registry = registry; } public String getName() { @@ -166,12 +173,16 @@ private void work() { 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); try { task.run(); ok = true; } catch (Throwable err) { System.err.println("Task " + task + " on executor " + name + " failed: " + err); } finally { + Tasks.leave(previous); synchronized(this) { active--; completed++; @@ -213,6 +224,71 @@ public synchronized void shutdown(long waitMillis) { break; } } + if(queue.isEmpty() && active == 0) { + 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; + queue.clear(); + if(workers != null) { + for(int iter = 0 ; iter < workers.length ; iter++) { + workers[iter].interrupt(); + } + } + System.err.println("Executor " + name + " did not finish within the shutdown " + + "timeout: " + waiting + " queued task(s) dropped, " + active + + " 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); + } + + private synchronized void requeue(Runnable task) { + active--; + if(timedOut) { + dropped++; + 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() { + public void run() { + work(); + } + }, "cn1-task-" + name + "-late"); + t.setDaemon(true); + t.start(); + } + notify(); } public String toString() { diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index 02018532198..dd0e416f94c 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -36,8 +36,17 @@ * Tasks.virtual(new Runnable() { public void run() { pingWebhooks(); } }); * * - *

The build's generated code asks for an executor once per {@code @Async} - * method and keeps it, so this map is read once per method, not once per call. + *

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 {@code @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

* @@ -57,69 +66,128 @@ public final class Tasks { /** The executor scheduled jobs use when they name none. */ public static final String SCHEDULING = "scheduling"; - private static final Map EXECUTORS = new LinkedHashMap(); - private static Config config; - private static boolean reportedAuto; + /** 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(); + private final Config config; + private boolean reportedAuto; + private boolean shutdown; + + Registry(Config config) { + this.config = config; + } + } + + /** A new registry for a server that is starting, which threads without one then use. */ + static Registry open(Config config) { + Registry r = new Registry(config); + synchronized(Tasks.class) { + LIVE.add(r); + } + return r; + } + /** - * The configuration executors are sized from. Set by the server when it - * starts; before that -- a test calling an {@code @Async} method directly -- - * executors take their defaults. + * Makes {@code registry} the calling thread's until {@link #leave}; answers + * what to restore. */ - public static synchronized void configure(Config configuration) { - config = configuration; + 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)); + } + return (Registry)LIVE.get(LIVE.size() - 1); + } } /** - * The executor called {@code name}, created on first use. + * The executor called {@code name} of the calling thread's server, created + * on first use. * * @param kind {@link #AUTO}, {@link #VIRTUAL} or {@link #PLATFORM}: what the * code asked for, which {@code cn1.task.executor..kind} * overrides */ - public static synchronized TaskExecutor executor(String name, int kind) { + 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; - TaskExecutor existing = (TaskExecutor)EXECUTORS.get(key); - if(existing != null) { - return existing; - } - String prefix = "cn1.task.executor." + key + "."; - int threads = SCHEDULING.equals(key) ? 2 : 8; - String configured = null; - if(config != null) { - try { - threads = config.getInt(prefix + "threads", threads); - configured = config.get(prefix + "kind"); - } catch (java.io.IOException err) { - throw new IllegalStateException("Executor " + key + " cannot be configured: " - + err.getMessage()); + synchronized(registry) { + TaskExecutor existing = (TaskExecutor)registry.executors.get(key); + if(existing != null) { + return existing; } - } - boolean virtual; - if("virtual".equalsIgnoreCase(configured)) { - virtual = true; - } else if("platform".equalsIgnoreCase(configured)) { - virtual = false; - } else if(kind == AUTO) { - virtual = HttpServer.acceptsVirtualTasks(); - if(!reportedAuto) { - reportedAuto = true; - System.out.println("cn1: background tasks marked AUTO run on " - + (virtual ? "virtual" : "platform") + " threads"); + 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()); + } } - } else { - virtual = kind == VIRTUAL; + boolean virtual; + if("virtual".equalsIgnoreCase(configured)) { + virtual = true; + } else if("platform".equalsIgnoreCase(configured)) { + virtual = false; + } else if(kind == AUTO) { + virtual = HttpServer.acceptsVirtualTasks(); + if(!registry.reportedAuto) { + registry.reportedAuto = true; + System.out.println("cn1: background tasks marked AUTO run on " + + (virtual ? "virtual" : "platform") + " threads"); + } + } else { + virtual = kind == VIRTUAL; + } + TaskExecutor created = new TaskExecutor(key, virtual, threads, registry); + registry.executors.put(key, created); + return created; } - TaskExecutor created = new TaskExecutor(key, virtual, threads); - EXECUTORS.put(key, created); - return created; } /** Runs {@code task} once on a virtual thread, or a platform one where there are none. */ @@ -151,21 +219,43 @@ static void runVirtual(long token) { } } - /** Every executor created so far, for a listing. */ - public static synchronized List executors() { - return new ArrayList(EXECUTORS.values()); + /** 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(int iter = 0 ; iter < registries.size() ; iter++) { + Registry r = (Registry)registries.get(iter); + synchronized(r) { + out.addAll(r.executors.values()); + } + } + return out; } /** - * Stops every executor, waiting up to {@code waitMillis} in total for what - * is running, and forgets them, so a server started again in the same - * process -- a test -- gets fresh ones. + * Stops the executors of the calling thread's server, waiting up to + * {@code waitMillis} in total for what is running. */ public static void shutdown(long waitMillis) { + shutdown(current(), waitMillis); + } + + /** + * Stops {@code registry}'s executors, waiting up to {@code 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) { - all = new ArrayList(EXECUTORS.values()); - EXECUTORS.clear(); + LIVE.remove(registry); + } + synchronized(registry) { + registry.shutdown = true; + all = new ArrayList(registry.executors.values()); } long deadline = System.currentTimeMillis() + Math.max(0, waitMillis); for(int iter = 0 ; iter < all.size() ; iter++) { diff --git a/vm/backend/src/com/codename1/backend/metrics/Histogram.java b/vm/backend/src/com/codename1/backend/metrics/Histogram.java index 19f9254eca2..685525737fd 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Histogram.java +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -42,9 +42,18 @@ * label with unbounded values cannot grow the histogram without limit. */ public final class Histogram extends Instrument { - /** OpenTelemetry's default explicit bucket boundaries. */ - public static final double[] DEFAULT_BOUNDS = {0, 5, 10, 25, 50, 75, 100, 250, 500, 750, - 1000, 2500, 5000, 7500, 10000}; + /** + * 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; @@ -60,14 +69,31 @@ public final class Histogram extends Instrument { Histogram(String name, String description, String unit, double[] bounds, String[] labels) { super(name, description, unit, HISTOGRAM); - this.bounds = bounds == null ? DEFAULT_BOUNDS : bounds; - this.labels = labels == null ? new String[0] : labels; + // 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(); + 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 && this.labels[iter].length() == 0) { + throw new IllegalArgumentException("Histogram " + name + ": a label key " + + "is empty"); + } + } this.plain = new Series(null, this.bounds.length + 1); } /** The label keys this histogram was created with. */ public String[] getLabelKeys() { - return labels; + return (String[])labels.clone(); } /** Records one value in the series without labels. */ diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index eaf9b1eb43b..abd10663530 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -70,9 +70,13 @@ public final class OtlpMetricExporter implements MetricReader { private Map resource; private Thread thread; private final Object lock = new Object(); - private boolean stopping; - /** Whether the exporter thread sends one last export before it ends. */ - private boolean finalExport; + /** + * 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; private long exports; private long failures; private String lastError; @@ -111,9 +115,13 @@ public boolean open(Config config) throws IOException { OtlpTracer.parseHeaders(own != null ? own : config.get(OtlpTracer.HEADERS), headers); intervalMillis = OtlpTracer.positive(config, INTERVAL, 60000); resource = OtlpTracer.resource(config, defaultServiceName); + final Run mine = new Run(); + synchronized(lock) { + run = mine; + } thread = new Thread(new Runnable() { public void run() { - loop(); + loop(mine); } }, "cn1-otel-metrics"); thread.setDaemon(true); @@ -121,11 +129,11 @@ public void run() { return true; } - private void loop() { + private void loop(Run mine) { while(true) { synchronized(lock) { long deadline = System.currentTimeMillis() + intervalMillis; - while(!stopping) { + while(!mine.stopping) { long left = deadline - System.currentTimeMillis(); if(left <= 0) { break; @@ -136,8 +144,8 @@ private void loop() { return; } } - if(stopping) { - if(!finalExport) { + if(mine.stopping) { + if(!mine.finalExport) { return; } break; @@ -186,26 +194,34 @@ public boolean export() { } public void shutdown(int timeoutMillis) { + Thread exporter; synchronized(lock) { - if(stopping) { + if(run == null || run.stopping) { return; } - stopping = true; + 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 connect and read timeouts. - finalExport = timeoutMillis > 0; + run.finalExport = timeoutMillis > 0; + exporter = thread; lock.notifyAll(); } - if(thread != null && timeoutMillis > 0) { + if(exporter != null && timeoutMillis > 0) { try { - thread.join(timeoutMillis); + 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(); From 1ce6092bae507f2b056f5ab25c3fc004cff366a4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:39:50 +0300 Subject: [PATCH 05/48] Backend: drain virtual tasks at shutdown, harden JDBC sessions and wiring - A stopping host keeps resuming its background virtual threads through the drain window instead of exiting with them yielded; stop() waits for that (except on the host running a handler that called stop), and a task that overruns is freed and reported. - JDBC sessions: a stale copy of an invalidated session is not written back; the stored last use is refreshed at min(1 min, timeout/4) and expiry allows that much grace; session-scoped beans are shared per session id across copies, created under one lock. - NESTED setRollbackOnly rolls back only its savepoint. - Built-in server gauges sum over running servers and drop stopped ones. - Generated wiring clears every bean field before a restart; factory beans step aside with a @ConditionalOnMissingBean configuration; scoped-bean stand-ins forward methods inherited from library classes; a managed-resource-only module gets an application. - Runtime cron parsing refuses dates no allowed month has; MCP long arguments are range-checked; unconfirmed backend_sql runs in an enforced read-only transaction, one statement, no value-setting pragmas. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/developer-guide/Backend-Beans.asciidoc | 11 +- docs/developer-guide/Backend-Data.asciidoc | 4 +- docs/developer-guide/Backend-MCP.asciidoc | 5 +- .../Backend-Observability.asciidoc | 5 + .../Backend-Scheduling.asciidoc | 3 + .../developer-guide/Backend-Sessions.asciidoc | 10 +- .../backend/ApplicationRuntimeTest.java | 117 +++++++++++++- .../codename1/backend/TransactionsTest.java | 16 ++ .../maven/processors/BackendBeans.java | 66 +++++++- .../maven/processors/BackendSources.java | 4 +- .../maven/processors/BackendWiringWriter.java | 21 ++- .../RestControllerAnnotationProcessor.java | 5 +- .../maven/processors/BackendBeansTest.java | 130 +++++++++++++++- .../src/com/codename1/backend/Backend.java | 3 + .../com/codename1/backend/CronSchedule.java | 28 +++- .../src/com/codename1/backend/HttpServer.java | 112 ++++++++++++++ .../com/codename1/backend/HttpSession.java | 44 ++++-- .../src/com/codename1/backend/Sessions.java | 144 ++++++++++++------ .../com/codename1/backend/Transactions.java | 67 +++++--- .../com/codename1/backend/mcp/DevTools.java | 60 +++++++- .../com/codename1/backend/mcp/McpArgs.java | 13 +- .../codename1/backend/metrics/Metrics.java | 109 ++++++++++--- 22 files changed, 851 insertions(+), 126 deletions(-) diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc index 8e7155ffed6..4bc76d50f96 100644 --- a/docs/developer-guide/Backend-Beans.asciidoc +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -268,7 +268,8 @@ to compete on explicitly, for a class whose default set is too wide or too narro 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 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 @@ -333,9 +334,11 @@ 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 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. -Inject anything else through fields. Each rule is checked by the build. +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: diff --git a/docs/developer-guide/Backend-Data.asciidoc b/docs/developer-guide/Backend-Data.asciidoc index 22c662de706..7ca816791ce 100644 --- a/docs/developer-guide/Backend-Data.asciidoc +++ b/docs/developer-guide/Backend-Data.asciidoc @@ -302,7 +302,9 @@ 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. +`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. diff --git a/docs/developer-guide/Backend-MCP.asciidoc b/docs/developer-guide/Backend-MCP.asciidoc index 4689dfa88f2..2613277bc4f 100644 --- a/docs/developer-guide/Backend-MCP.asciidoc +++ b/docs/developer-guide/Backend-MCP.asciidoc @@ -121,8 +121,9 @@ exercising it, and the server prints the endpoint's address when it starts: | Read the last lines the server printed. | `backend_sql` -| Run SQL against the server's database. Only reading statements run unless - `write` is true. +| 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. diff --git a/docs/developer-guide/Backend-Observability.asciidoc b/docs/developer-guide/Backend-Observability.asciidoc index 07205888713..6e94def06e2 100644 --- a/docs/developer-guide/Backend-Observability.asciidoc +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -172,6 +172,11 @@ The route attribute is the template -- `GET /notes/{id}` is recorded as ask for. A request that fails, whether in a handler or in the session store, is recorded with status 500. +The instruments belong to the process, as an OpenTelemetry meter's do. With more +than one server in a process, each server gauge reports the sum over the servers +still running, and the request histogram counts every server's requests. A +server that stops drops out of the sums. + ==== Managed beans The application's own metrics follow the JMX model -- attributes to watch and diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc index 12842ff1978..34e29b792b6 100644 --- a/docs/developer-guide/Backend-Scheduling.asciidoc +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -246,6 +246,9 @@ 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 diff --git a/docs/developer-guide/Backend-Sessions.asciidoc b/docs/developer-guide/Backend-Sessions.asciidoc index 01431fc9aaf..86431cc348c 100644 --- a/docs/developer-guide/Backend-Sessions.asciidoc +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -81,7 +81,15 @@ include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/b 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, which it updates at most once a minute. +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 diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 17b0be1cbb3..01adc1a60e4 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -64,6 +64,15 @@ 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 cron time a spring-forward night skips does not fire an hour late") void cronSkipsTheDstGap() { @@ -95,7 +104,12 @@ void cronLastDayAndNever() { CronSchedule last = CronSchedule.parse("0 0 0 L * *", "UTC"); long jan1 = 1767225600000L; assertEquals(jan1 + 30 * 86400000L, last.next(jan1)); // Jan 31 - assertEquals(-1, CronSchedule.parse("0 0 0 30 2 *", "UTC").next(jan1)); + // 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 @@ -211,6 +225,100 @@ public void run() { // --------------------------------------------------------------- sessions + @Test + @DisplayName("the database session store: no resurrection, and no early expiry for a short timeout") + void jdbcSessionStore(@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.Jdbc store = new Sessions.Jdbc(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"); + + // 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.Jdbc.touchInterval(10)); + assertEquals(60000L, Sessions.Jdbc.touchInterval(1800)); + } finally { + pool.close(); + } + } + + @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]); + } + + @Test + @DisplayName("the built-in server gauges add up every running server, and drop a stopped one") + void serverGaugesAggregate() throws Exception { + Backend[] servers = new Backend[2]; + int[] ports = {freePort(), freePort()}; + for(int i = 0 ; i < 2 ; i++) { + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(ports[i])); + servers[i] = Backend.builder(Config.of(settings, "dev")).quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); + } + }).start(); + } + try { + read(open(ports[0], "/x")); + read(open(ports[1], "/x")); + read(open(ports[1], "/x")); + double both = served(); + double expected = number(servers[0]) + number(servers[1]); + assertEquals(expected, both, 0.0, "the gauge reported one server, not the sum"); + servers[1].stop(); + assertEquals(number(servers[0]), served(), 0.0, + "a stopped server was still counted"); + } finally { + servers[0].stop(); + servers[1].stop(); + } + } + + 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 server without generated beans still applies the session settings") void handlerOnlySessionSettings() throws Exception { @@ -441,6 +549,13 @@ void narrowArgumentsAreRangeChecked() { () -> 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 diff --git a/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java index ca1987326d2..db1a1c7619d 100644 --- a/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java @@ -202,6 +202,22 @@ void localRollbackOnlyIsSilent() throws Exception { 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 { 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 index 4beb6a282e9..abfc5105b62 100644 --- 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 @@ -394,6 +394,15 @@ boolean hasJobs() { 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()) { @@ -904,8 +913,10 @@ private void bindProperties(Bean bean, AnnotationValues props, AnnotatedClass wh bean.propertySetters.add(m); } } + // Setters a library base class declares bind as well. String parent = c.getSuperInternalName(); - c = parent == null || "java/lang/Object".equals(parent) ? null : ctx.lookup(parent); + c = parent == null || "java/lang/Object".equals(parent) ? null + : RestControllerAnnotationProcessor.resolveClass(ctx, parent); } if (bean.propertySetters.isEmpty()) { ctx.error(where, "@ConfigurationProperties(\"" + prefix + "\") on " + bean.name @@ -967,10 +978,28 @@ private void dropSteppedAside() { } } } - for (Bean b : drop) { + // 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() + " steps aside: another bean has its type"); + ctx.getLog().info("cn1: " + b.describe() + (drop.contains(b) + ? " steps aside: another bean has its type" + : " steps aside with its configuration class")); } } @@ -1513,6 +1542,13 @@ private void checkProxyable(Bean b) { + "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 " @@ -1554,12 +1590,34 @@ List proxiedMethods(AnnotatedClass cls, boolean includeFinal) { } 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 : ctx.lookup(parent); + 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() { 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 index 13a7e8b4297..3fb2a62b474 100644 --- 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 @@ -385,7 +385,9 @@ private boolean declaredIn(AnnotatedClass cls, MethodInfo m, String pkg) { return RestClientAnnotationProcessor.packageOf(c.getBinaryName()).equals(pkg); } String parent = c.getSuperInternalName(); - c = parent == null ? null : beans.ctx.lookup(parent); + // The same walk proxiedMethods makes, library classes included. + c = parent == null || "java/lang/Object".equals(parent) ? null + : RestControllerAnnotationProcessor.resolveClass(beans.ctx, parent); } return false; } 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 index 409da43f38d..82c9782214d 100644 --- 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 @@ -149,6 +149,22 @@ 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"); + 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"); @@ -816,7 +832,10 @@ private void scopes(StringBuilder sb) { + "used outside a request\");\n }\n"); sb.append(" com.codename1.backend.HttpSession session = " + "request.getSession(true);\n"); - sb.append(" synchronized (session) {\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] = " 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 81c94d8bdc2..fdd8545a067 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 @@ -1225,9 +1225,10 @@ public void finish(ProcessorContext ctx) throws ProcessingException { // 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. The same goes - // for one whose only work is scheduled jobs or MCP tools. + // 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.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 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 index d20402ff5c4..eb884d0671b 100644 --- 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 @@ -53,6 +53,7 @@ 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; @@ -241,6 +242,16 @@ public void theGeneratedWiringRunsTheSample() throws Exception { + "{\"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")); } finally { @@ -688,6 +699,118 @@ public void aSessionBeanBuiltByAFailingRequestIsStillDestroyed() throws Exceptio 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 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 everyScopeAndBindingWorksAtRunTime() throws Exception { Map s = new LinkedHashMap(); @@ -834,13 +957,16 @@ private ProcessorContext process(File classes) throws Exception { return process(classes, new RestControllerAnnotationProcessor()); } - private ProcessorContext process(File classes, RestControllerAnnotationProcessor proc) - throws Exception { + 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()); + } ProcessorContext ctx = new ProcessorContext(classes, tmp.newFolder(), index, new SystemStreamLog(), tmp.newFolder(), new Properties(), null, Collections.emptyList(), "UTF-8", cp); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 44ef65a5205..5a6c46a63d1 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -237,6 +237,9 @@ private void stopOnce() { } } server.stop(shutdownMillis); + // Out of the process's server gauges, which would otherwise keep reading + // a stopped server and pool. + com.codename1.backend.metrics.Metrics.disableServer(server, dataSource); // 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); diff --git a/vm/backend/src/com/codename1/backend/CronSchedule.java b/vm/backend/src/com/codename1/backend/CronSchedule.java index affa9517684..ce6940a7b29 100644 --- a/vm/backend/src/com/codename1/backend/CronSchedule.java +++ b/vm/backend/src/com/codename1/backend/CronSchedule.java @@ -138,13 +138,35 @@ public static CronSchedule parse(String expression, String zone) { 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, - field(fields[4], 1, 12, MONTHS, false, expression, "month"), - dow, last, zone, expression); + 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", diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 3e7e7e373af..134b84be65a 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -2189,6 +2189,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(); @@ -2222,6 +2227,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. // @@ -2820,6 +2826,15 @@ boolean ringEmpty() { return ringCount == 0; } + /** Set, under the inbox lock, once a stopping host has finished its tasks. */ + boolean tasksDrained; + + 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 @@ -3103,6 +3118,93 @@ private VtHost ownerOf(int fd) { /** Round robin over the hosts, used only by whoever is accepting. */ private int vtNextHost = 0; + /** Until when a stopping host keeps running its background tasks. */ + private volatile long taskDrainDeadline; + + /** + * 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); + 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()) { + return; + } + } + // Out of time: free what is left rather than leaking its stack. Its + // Future never completes, so say so. + int abandoned = 0; + int left = me.ringCount; + while(left-- > 0 && !me.ringEmpty()) { + long handle = me.ringTake(); + if(VirtualThread.descriptorOf(handle) < 0) { + VirtualThread.free(handle); + abandoned++; + } else { + me.ringAdd(handle); + } + } + if(abandoned > 0) { + System.err.println(abandoned + " background task(s) on virtual threads did " + + "not finish within the shutdown window and were abandoned"); + } + } finally { + synchronized(me.inbox) { + me.tasksDrained = true; + } + } + } + + /** + * 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; + } + VtHost callersHost = callerFd >= 0 ? ownerOf(callerFd) : null; + while(System.currentTimeMillis() < taskDrainDeadline + 50) { + boolean all = true; + for(int iter = 0 ; iter < hosts.length ; iter++) { + if(hosts[iter] != null && hosts[iter] != callersHost) { + synchronized(hosts[iter].inbox) { + all &= hosts[iter].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. * @@ -3114,6 +3216,16 @@ private VtHost ownerOf(int fd) { */ 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 diff --git a/vm/backend/src/com/codename1/backend/HttpSession.java b/vm/backend/src/com/codename1/backend/HttpSession.java index 50e82cfd693..4f3e453f3ad 100644 --- a/vm/backend/src/com/codename1/backend/HttpSession.java +++ b/vm/backend/src/com/codename1/backend/HttpSession.java @@ -179,8 +179,12 @@ synchronized void touch(long now) { lastAccessed = now; } + /** Extra time before expiry, for a store whose stored last use lags. */ + long expiryGraceMillis; + synchronized boolean isExpired(long now) { - return maxInactiveSeconds > 0 && now - lastAccessed > maxInactiveSeconds * 1000L; + return maxInactiveSeconds > 0 + && now - lastAccessed > maxInactiveSeconds * 1000L + expiryGraceMillis; } synchronized void markNew() { @@ -215,25 +219,41 @@ synchronized void loadAttributes(Map values) { } } - /** The session-scoped beans this object holds, or null. */ - synchronized Object[] beansOrNull() { - return beans; + /** + * 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; } - /** Hands this object the beans the server kept for its session, unless it has its own. */ - synchronized void attachBeans(Object[] kept) { - if(kept == null) { - beans = null; - } else if(beans == null) { - beans = kept; - } + /** + * 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 {@code @SessionScope} beans of this session, by the slot the build gave * each. Called by generated code. */ - public synchronized Object[] scopedBeans(int count) { + 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) { diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 24f561403be..5f4cc889e72 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -203,18 +203,12 @@ HttpSession find(String cookieValue, boolean create) throws IOException { HttpSession found = s.load(cookieValue); if(found != null && found.isValid() && !found.isExpired(now)) { found.touch(now); - Held held; - synchronized(this) { - held = (Held)beans.get(cookieValue); - } - if(held != null) { - found.attachBeans(held.beans); - } + found.owner = this; return found; } if(found != null) { s.delete(cookieValue); - destroy(take(cookieValue), found.beansOrNull()); + destroy(take(cookieValue)); } } if(!create) { @@ -222,6 +216,7 @@ HttpSession find(String cookieValue, boolean create) throws IOException { } HttpSession created = new HttpSession(newId(), now, now, timeout); created.markNew(); + created.owner = this; return created; } @@ -247,7 +242,7 @@ private void purgeIfDue(SessionStore s, long now) { } if(expired != null) { for(int iter = 0 ; iter < expired.size() ; iter++) { - destroy((Object[])expired.get(iter), null); + destroy((Object[])expired.get(iter)); } } try { @@ -271,14 +266,13 @@ HttpServer.Response finish(HttpSession session, HttpServer.Response response) String previous = session.previousId(); if(!session.isValid()) { s.delete(session.getId()); - Object[] kept = take(session.getId()); + // The beans end with the session, not with whatever next finds it gone. + destroy(take(session.getId())); if(previous != null) { s.delete(previous); - destroy(take(previous), null); + destroy(take(previous)); } - // The beans end with the session, not with whatever next finds it gone. - destroy(kept, session.beansOrNull()); - session.attachBeans(null); + destroy(session.takeLocalBeans()); cookie = cookie("", 0); } else { if(session.isDirty()) { @@ -299,25 +293,21 @@ HttpServer.Response finish(HttpSession session, HttpServer.Response response) return withHeader(response, "Set-Cookie", cookie); } - /** Records the session's beans under its current id, after a request used it. */ + /** + * 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[] live = session.beansOrNull(); + Object[] late = null; synchronized(this) { Held held = previousId == null ? null : (Held)beans.remove(previousId); - if(live == null && held == null) { - held = (Held)beans.get(session.getId()); - if(held == null) { - return; - } - } - if(held == null) { + if(held != null) { + beans.put(session.getId(), held); + } else { held = (Held)beans.get(session.getId()); } if(held == null) { - held = new Held(); - } - if(live != null) { - held.beans = live; + return; } held.lastAccessed = session.getLastAccessedTime(); held.maxInactiveSeconds = session.getMaxInactiveInterval(); @@ -325,12 +315,54 @@ private void keep(HttpSession session, String previousId) { // The server stopped while this request was in flight; nothing // will destroy what is kept after close(). beans.remove(session.getId()); - } else { - beans.put(session.getId(), held); - return; + late = held.beans; } } - destroy(live, null); + 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); + } + + private Held holderFor(HttpSession session) { + String id = session.getId(); + Held held = (Held)beans.get(id); + 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); + } + } + 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) { @@ -338,16 +370,10 @@ private synchronized Object[] take(String id) { return held == null ? null : held.beans; } - /** Runs the destroy methods of one session's beans; the second array may repeat the first. */ - private void destroy(Object[] first, Object[] second) { - if(application == null) { - return; - } - if(first != null) { - ended(first); - } - if(second != null && second != first) { - ended(second); + /** Runs the destroy methods of one session's beans. */ + private void destroy(Object[] sessionBeans) { + if(application != null && sessionBeans != null) { + ended(sessionBeans); } } @@ -372,7 +398,7 @@ void close() { beans.clear(); } for(int iter = 0 ; iter < all.size() ; iter++) { - destroy(((Held)all.get(iter)).beans, null); + destroy(((Held)all.get(iter)).beans); } } @@ -531,8 +557,13 @@ public HttpSession load(String id) throws IOException { 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")), (int)number(row.get("max_inactive"))); + 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.expiryGraceMillis = touchInterval(maxInactive); Object text = row.get("attributes"); if(text instanceof String && ((String)text).length() > 0) { s.loadAttributes(Json.parseObject((String)text)); @@ -551,6 +582,12 @@ public void save(HttpSession session, String previousId) throws IOException { new Integer(session.getMaxInactiveInterval()), json, session.getId()}; int updated = pool.execute("UPDATE " + TABLE + " SET last_accessed = ?, " + "max_inactive = ?, attributes = ? WHERE id = ?", values); + if(updated == 0 && !session.isNew() && previousId == 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, so it stays gone. + return; + } if(updated == 0) { pool.execute("INSERT INTO " + TABLE + " (id, created, last_accessed, " + "max_inactive, attributes) VALUES (?, ?, ?, ?, ?)", @@ -569,7 +606,7 @@ void touchIfStale(HttpSession session) throws IOException { // 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 < TOUCH_INTERVAL) { + if(now - session.storedAccessed < touchInterval(session.getMaxInactiveInterval())) { return; } pool.execute("UPDATE " + TABLE + " SET last_accessed = ? WHERE id = ?", @@ -577,6 +614,18 @@ void touchIfStale(HttpSession session) throws IOException { 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); + } + public void delete(String id) throws IOException { prepare(); pool.execute("DELETE FROM " + TABLE + " WHERE id = ?", new Object[] {id}); @@ -584,8 +633,13 @@ public void delete(String id) throws IOException { public int purgeExpired(long now) throws IOException { prepare(); + // The same grace load() allows: the timeout plus the touch interval, + // min(a minute, a quarter of the timeout). + Long at = new Long(now); return pool.execute("DELETE FROM " + TABLE + " WHERE max_inactive > 0 AND " - + "last_accessed + max_inactive * 1000 < ?", new Object[] {new Long(now)}); + + "(last_accessed + max_inactive * 1250 < ? OR " + + "last_accessed + max_inactive * 1000 + " + TOUCH_INTERVAL + " < ?)", + new Object[] {at, at}); } public int size() { diff --git a/vm/backend/src/com/codename1/backend/Transactions.java b/vm/backend/src/com/codename1/backend/Transactions.java index 62f2cc988ac..418d7247950 100644 --- a/vm/backend/src/com/codename1/backend/Transactions.java +++ b/vm/backend/src/com/codename1/backend/Transactions.java @@ -101,8 +101,12 @@ static final class Physical { * chose to undo its own work, so the rollback is silent, as in Spring. */ boolean localRollback; - /** How many joined or nested calls are running inside the one that began it. */ - int participants; + /** + * 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 @@ -136,6 +140,8 @@ public static final class Transaction { 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) { @@ -160,7 +166,15 @@ public static boolean isActive() { /** Whether the calling thread's transaction has been marked rollback-only. */ public static boolean isRollbackOnly() { Physical p = used ? (Physical)CURRENT.get() : null; - return p != null && (p.rollbackOnly || p.localRollback); + 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); } /** @@ -174,10 +188,16 @@ public static void setRollbackOnly() { throw new TransactionException.IllegalState("setRollbackOnly() outside a " + "transaction: there is nothing to roll back"); } - if(p.participants == 0) { + 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. @@ -196,7 +216,7 @@ public static Transaction begin(int propagation, boolean readOnly, int timeoutSe used = true; Transaction tx = open(propagation, readOnly, timeoutSeconds); if(tx.physical != null && (tx.kind == KIND_JOINED || tx.kind == KIND_SAVEPOINT)) { - tx.physical.participants++; + tx.physical.calls.add(tx); } return tx; } @@ -286,6 +306,10 @@ public static void commit(Transaction 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); @@ -345,19 +369,7 @@ public static void afterThrow(Transaction tx, boolean rollback) { // Never set, so nothing ran after it: nothing to undo. return; } - 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); - } + rollBackToSavepoint(tx); return; case KIND_NEW: try { @@ -371,10 +383,27 @@ public static void afterThrow(Transaction tx, boolean rollback) { } } + /** 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.participants--; + tx.physical.calls.remove(tx); } } diff --git a/vm/backend/src/com/codename1/backend/mcp/DevTools.java b/vm/backend/src/com/codename1/backend/mcp/DevTools.java index 43edd4d3a9c..96b894bcf21 100644 --- a/vm/backend/src/com/codename1/backend/mcp/DevTools.java +++ b/vm/backend/src/com/codename1/backend/mcp/DevTools.java @@ -22,6 +22,7 @@ */ package com.codename1.backend.mcp; +import java.io.IOException; import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashMap; @@ -31,6 +32,7 @@ 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.RequestLog; @@ -293,6 +295,62 @@ private Map call(Map a) throws Exception { 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) && body.indexOf('=') >= 0) { + // Setting a pragma changes the pooled connection for whoever borrows + // it next -- or the file -- whatever the transaction says. + throw new IllegalArgumentException("That pragma sets a value; 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); + try { + return db.query(body, params); + } finally { + db.rollbackTransaction(); + } + } 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); + } + } + private Object sql(Map a) throws Exception { DataSource pool = backend.getDataSource(); if(pool == null) { @@ -322,7 +380,7 @@ private Object sql(Map a) throws Exception { throw new IllegalArgumentException("That statement writes; pass write=true to run " + "it"); } - List rows = pool.query(statement, params); + List rows = readOnly(pool, statement, params); if(rows.size() > 500) { List cut = new ArrayList(rows.subList(0, 500)); Map out = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java index 82ee034df70..191665e94f6 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java @@ -240,12 +240,21 @@ public static double toDouble(Object value) { } 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(d != Math.floor(d)) { + if(Double.isNaN(d) || Double.isInfinite(d) || d != Math.floor(d)) { throw new IllegalArgumentException("\"" + name + "\" must be a whole number"); } - return ((Number)v).longValue(); + // longValue() saturates: 1e20 would become Long.MAX_VALUE and the tool + // would act on a number nobody sent. 2^63 itself is out of range. + 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()); diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index 4e80e6cc88a..c801a366cc2 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -170,42 +170,58 @@ public static long startTimeMillis() { // ------------------------------------------------------- server instrumentation + /** 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 the server's own metrics. Called once the server is + * 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(server, "http.server.active_requests", "activeRequests", + serverMetric("http.server.active_requests", "activeRequests", "Requests being served", "{request}"); - serverMetric(server, "http.server.open_connections", "openConnections", + serverMetric("http.server.open_connections", "openConnections", "Open client connections", "{connection}"); - serverMetric(server, "cn1.server.websocket_connections", "webSocketConnections", + serverMetric("cn1.server.websocket_connections", "webSocketConnections", "Open websocket connections", "{connection}"); - serverMetric(server, "cn1.server.requests_served", "requestsServed", + serverMetric("cn1.server.requests_served", "requestsServed", "Requests answered since start", "{request}"); - serverMetric(server, "cn1.server.connections_refused", "connectionsRefused", + serverMetric("cn1.server.connections_refused", "connectionsRefused", "Connections refused for being over the limit", "{connection}"); - if(pool != null) { - gauge("db.client.connection.count", "Open database connections", "{connection}", - new Gauge.Source() { - public double read() { - return pool.getOpenCount(); - } - }); - gauge("db.client.connection.idle", "Idle database connections", "{connection}", - new Gauge.Source() { - public double read() { - return pool.getIdleCount(); - } - }); - } + gauge("db.client.connection.count", "Open database connections", "{connection}", + new Gauge.Source() { + public double read() { + return poolSum(false); + } + }); + gauge("db.client.connection.idle", "Idle database connections", "{connection}", + new Gauge.Source() { + public double read() { + return poolSum(true); + } + }); gauge("cn1.task.queue_depth", "Tasks waiting for a thread, by executor", "{task}", new Gauge.MultiSource() { public List read() { @@ -230,16 +246,59 @@ public double read() { return (System.currentTimeMillis() - STARTED) / 1000.0; } }); - serverEnabled = true; + synchronized(Metrics.class) { + serverEnabled = true; + } } - private static void serverMetric(final com.codename1.backend.HttpServer server, - String name, final String key, String description, + /** + * 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(int iter = 0 ; iter < pools.size() ; iter++) { + com.codename1.backend.DataSource p = (com.codename1.backend.DataSource)pools.get(iter); + 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() { public double read() { - Object v = server.getMetrics().get(key); - return v instanceof Number ? ((Number)v).doubleValue() : Double.NaN; + List servers = liveServers(); + double sum = 0; + for(int iter = 0 ; iter < servers.size() ; iter++) { + Object v = ((com.codename1.backend.HttpServer)servers.get(iter)) + .getMetrics().get(key); + if(v instanceof Number) { + sum += ((Number)v).doubleValue(); + } + } + return sum; } }); } From 5315e2cd9478124eb1a17e963242960a9eec8f16 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:12:43 +0300 Subject: [PATCH 06/48] Backend: finish abandoned tasks, order request beans before the session save - A virtual task freed at shutdown now releases its executor's active count and fails its Future, instead of leaving both hanging. - Request-scoped beans are destroyed before the session is stored, so a @PreDestroy that changes or starts the session is saved and its cookie sent. - Loading a session refreshes its shared beans' last use, so a purge cannot destroy beans a long request is still using. - Scheduler.trigger refuses after stop and restores the job if the executor rejects it. - backend_sql runs only allow-listed read pragmas unconfirmed (either spelling), and closes a connection whose rollback failed. - The build warns about public methods a class-level @Transactional or @Async class inherits, which the annotation does not cover (as in Spring). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/developer-guide/Backend-Data.asciidoc | 6 +- .../backend/ApplicationRuntimeTest.java | 31 ++++++++++ .../codename1/backend/mcp/DevToolsTest.java | 46 ++++++++++++++ .../maven/processors/BackendBeans.java | 35 +++++++++++ .../maven/processors/BackendBeansTest.java | 39 ++++++++++++ .../src/com/codename1/backend/AsyncTask.java | 14 +++++ .../src/com/codename1/backend/Backend.java | 25 ++++++-- .../src/com/codename1/backend/HttpServer.java | 19 ++++++ .../src/com/codename1/backend/Scheduler.java | 22 +++++-- .../src/com/codename1/backend/Sessions.java | 9 +++ .../com/codename1/backend/TaskExecutor.java | 23 +++++++ .../com/codename1/backend/mcp/DevTools.java | 62 +++++++++++++++++-- 12 files changed, 311 insertions(+), 20 deletions(-) create mode 100644 maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java diff --git a/docs/developer-guide/Backend-Data.asciidoc b/docs/developer-guide/Backend-Data.asciidoc index 7ca816791ce..3e72012d496 100644 --- a/docs/developer-guide/Backend-Data.asciidoc +++ b/docs/developer-guide/Backend-Data.asciidoc @@ -188,8 +188,10 @@ include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/b 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 of its public -methods. +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 diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 01adc1a60e4..5092e14eee0 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -137,6 +137,37 @@ void cronRefusals() { // -------------------------------------------------------------- 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 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("fixed-delay jobs run, never overlap, and stop with the scheduler") void schedulerRunsAndStops() 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..2b1bb1bba21 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.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.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")); + } +} 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 index abfc5105b62..4464d0f94b7 100644 --- 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 @@ -1687,6 +1687,37 @@ private void visit(Bean b, Map state, List path) { // ---------------------------------------------------------------- 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. + 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. private void collectAspects() { @@ -1696,6 +1727,10 @@ private void collectAspects() { } 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()) 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 index eb884d0671b..62b309745ac 100644 --- 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 @@ -811,6 +811,45 @@ public void aScopedBeanForwardsMethodsItInheritsFromALibrary() throws Exception 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 everyScopeAndBindingWorksAtRunTime() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/AsyncTask.java b/vm/backend/src/com/codename1/backend/AsyncTask.java index 86a698b8d32..7ed8e11b616 100644 --- a/vm/backend/src/com/codename1/backend/AsyncTask.java +++ b/vm/backend/src/com/codename1/backend/AsyncTask.java @@ -108,6 +108,20 @@ public Object run(Span span) throws Exception { } } + /** + * 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. + */ + synchronized void abandon(String reason) { + if(done) { + return; + } + failure = new IllegalStateException(name + ": " + reason); + done = true; + notifyAll(); + } + /** Cancels the call if it has not started. A running call is never interrupted. */ public synchronized boolean cancel(boolean mayInterruptIfRunning) { if(started || done) { diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 5a6c46a63d1..3d66feb7ee3 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -1000,6 +1000,16 @@ public HttpServer.Response handle(HttpServer.Request request) { try { server = HttpServer.start(host, listenPort, listenBacklog, workerCount, new HttpServer.Handler() { + /** Destroys the request's scoped beans, once. */ + private void endRequestBeans(HttpServer.Request request) { + if(app != null) { + Object[] beans = request.takeScopedBeans(); + if(beans != null) { + app.requestEnded(beans); + } + } + } + public HttpServer.Response handle(HttpServer.Request request) throws Exception { long started = com.codename1.backend.metrics.Metrics @@ -1030,6 +1040,11 @@ public HttpServer.Response handle(HttpServer.Request request) 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); // Inside the logged region: a session // store that fails to save is a 500 the // client receives, and the request log @@ -1041,6 +1056,7 @@ public HttpServer.Response handle(HttpServer.Request request) response = sessions.finish(session, response); } } catch (Exception err) { + endRequestBeans(request); HttpSession session = request.resolvedSession(); if(session != null && !sessionFinished) { // The handler threw, but what it did to @@ -1078,12 +1094,9 @@ public HttpServer.Response handle(HttpServer.Request request) // request-scoped bean goes through that bean's // stand-in, which looks the request up. try { - if(app != null) { - Object[] beans = request.takeScopedBeans(); - if(beans != null) { - app.requestEnded(beans); - } - } + // Normally done already, and then a + // no-op: the beans are taken once. + endRequestBeans(request); } finally { if(track) { CURRENT_REQUEST.set(previous); diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 134b84be65a..cfa1431f781 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -2724,7 +2724,15 @@ private void drainTaskInbox(VtHost me) { } for(int iter = 0 ; iter < tokens.length ; iter++) { long token = tokens[iter].longValue(); + Runnable queued; + synchronized(VIRTUAL_TASKS) { + queued = (Runnable)VIRTUAL_TASKS.get(new Long(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(new Long(handle), queued); + } if(handle == 0) { Runnable task = takeVirtualTask(token); if(task != null) { @@ -2745,6 +2753,7 @@ private void drainTaskInbox(VtHost me) { private void advanceTask(VtHost me, long handle) { int state = VirtualThread.resume(handle); if(state == VirtualThread.FINISHED) { + me.tasks.remove(new Long(handle)); VirtualThread.free(handle); return; } @@ -2829,6 +2838,12 @@ boolean ringEmpty() { /** Set, under the inbox lock, once a stopping host has finished its tasks. */ boolean tasksDrained; + /** + * 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(); + boolean hasQueuedTasks() { synchronized(inbox) { return !inbox.isEmpty(); @@ -3155,7 +3170,11 @@ private void drainTasksAfterStop(VtHost me) { while(left-- > 0 && !me.ringEmpty()) { long handle = me.ringTake(); if(VirtualThread.descriptorOf(handle) < 0) { + Runnable task = (Runnable)me.tasks.remove(new Long(handle)); VirtualThread.free(handle); + if(task != null) { + TaskExecutor.abandoned(task); + } abandoned++; } else { me.ringAdd(handle); diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/backend/Scheduler.java index 2f6a1b56808..f3af4baef52 100644 --- a/vm/backend/src/com/codename1/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -443,15 +443,25 @@ public synchronized boolean trigger(String name) { for(int iter = 0 ; iter < jobs.size() ; iter++) { final Job job = (Job)jobs.get(iter); if(job.name.equals(name)) { - if(job.running || job.executor == null) { + // 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; - job.executor.execute(new Runnable() { - public void run() { - runJob(job); - } - }); + try { + job.executor.execute(new Runnable() { + 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; } } diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 5f4cc889e72..e0673ef007b 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -204,6 +204,15 @@ HttpSession find(String cookieValue, boolean create) throws IOException { if(found != null && found.isValid() && !found.isExpired(now)) { found.touch(now); found.owner = this; + synchronized(this) { + // The beans are in use from now, not from when this request + // ends: a purge by another request during a long one must + // not destroy what this one is still using. + Held held = (Held)beans.get(cookieValue); + if(held != null) { + held.lastAccessed = now; + } + } return found; } if(found != null) { diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index 44eb1c07369..6fdae6d24b5 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -267,6 +267,29 @@ static void fallBack(Runnable task) { 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) { diff --git a/vm/backend/src/com/codename1/backend/mcp/DevTools.java b/vm/backend/src/com/codename1/backend/mcp/DevTools.java index 96b894bcf21..373cb0c75d9 100644 --- a/vm/backend/src/com/codename1/backend/mcp/DevTools.java +++ b/vm/backend/src/com/codename1/backend/mcp/DevTools.java @@ -316,11 +316,13 @@ private static List readOnly(DataSource pool, String statement, Object[] params) throw new IllegalArgumentException("Send one statement at a time, or pass " + "write=true"); } - if(body.regionMatches(true, 0, "PRAGMA", 0, 6) && body.indexOf('=') >= 0) { + 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. - throw new IllegalArgumentException("That pragma sets a value; pass write=true " - + "to run it"); + // 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()); @@ -330,11 +332,21 @@ private static List readOnly(DataSource pool, String statement, Object[] params) db.execute("PRAGMA query_only = ON", null); } db.beginTransaction(true); + List rows; try { - return db.query(body, params); + rows = db.query(body, params); } finally { - db.rollbackTransaction(); + 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 { @@ -351,6 +363,44 @@ private static List readOnly(DataSource pool, String statement, Object[] params) } } + /** + * 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(int iter = 0 ; iter < allowed.length ; iter++) { + if(allowed[iter].equalsIgnoreCase(name)) { + return true; + } + } + return false; + } + private Object sql(Map a) throws Exception { DataSource pool = backend.getDataSource(); if(pool == null) { From aac2cc4de15c5ec3eff5c7cac1a080155bccd43b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:26:06 +0300 Subject: [PATCH 07/48] Backend: roll back failed statements as Spring does; per-server logs and metrics - Database failures are DataAccessException (an IOException), and the default @Transactional rule rolls back for it beside RuntimeException and Error: in Spring a failed statement is an unchecked DataAccessException and rolls back. Other checked exceptions still commit, as Spring's rule says. - JDBC sessions save by optimistic merge: a versioned row, only this request's changed attributes applied, last use only moving forward; the purge no longer overflows PostgreSQL INTEGER for long timeouts. - Request logs are per server; request and job metrics are recorded only by servers that measure. - Duplicate @McpTool and @ManagedResource names are refused at build time (and at start-up for conditional beans). - Cron searches a full 400-year cycle; fixed-rate catch-up is arithmetic. - Managed operations answer 400 for bad JSON or rejected arguments and 404 only for an unknown bean or operation. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../developerguide/backend/beans/Imports.java | 2 +- .../developerguide/backend/beans/Orders.java | 2 +- .../developerguide/backend/beans/Signups.java | 5 +- docs/developer-guide/Backend-Data.asciidoc | 26 ++-- .../backend/ApplicationRuntimeTest.java | 131 +++++++++++++++++- .../backend/ServerEngineTransactionsTest.java | 20 +++ .../maven/processors/BackendBeans.java | 38 +++++ .../maven/processors/BackendSources.java | 6 +- .../maven/processors/BackendBeansTest.java | 80 +++++++++++ .../resources/skill/references/backend.md | 5 +- .../src/com/codename1/backend/Backend.java | 47 +++++-- .../com/codename1/backend/CronSchedule.java | 14 +- .../backend/DataAccessException.java | 59 ++++++++ .../src/com/codename1/backend/DataSource.java | 9 +- .../src/com/codename1/backend/Database.java | 52 ++++--- .../com/codename1/backend/HttpSession.java | 21 +++ .../src/com/codename1/backend/Management.java | 47 ++++++- .../src/com/codename1/backend/RequestLog.java | 26 ++-- .../src/com/codename1/backend/Scheduler.java | 22 ++- .../src/com/codename1/backend/Sessions.java | 102 +++++++++++--- .../com/codename1/backend/mcp/DevTools.java | 6 +- .../codename1/backend/metrics/Metrics.java | 9 +- 22 files changed, 635 insertions(+), 94 deletions(-) create mode 100644 vm/backend/src/com/codename1/backend/DataAccessException.java 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 index 539b5b30e03..8a6633c36e9 100644 --- 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 @@ -53,7 +53,7 @@ public int importAll(List lines) { return imported; // the good lines commit together } - @Transactional(propagation = Propagation.NESTED, rollbackFor = IOException.class) + @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]}); 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 index 14e0554cd45..e748362aafd 100644 --- 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 @@ -39,7 +39,7 @@ public Orders(DataSource db, AuditLog audit) { this.audit = audit; } - @Transactional(rollbackFor = {IOException.class, PaymentDeclined.class}, timeout = 10) + @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"); 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 index 6c8659b43cb..a29906c7c1b 100644 --- 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 @@ -53,8 +53,9 @@ void createTable() throws IOException { 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. IOException - // is checked, so it takes rollbackFor to make it roll 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[] diff --git a/docs/developer-guide/Backend-Data.asciidoc b/docs/developer-guide/Backend-Data.asciidoc index 3e72012d496..bf41c84a552 100644 --- a/docs/developer-guide/Backend-Data.asciidoc +++ b/docs/developer-guide/Backend-Data.asciidoc @@ -270,7 +270,8 @@ 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. `rollbackFor` +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 @@ -283,12 +284,17 @@ include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/b [IMPORTANT] ==== -Every `DataSource`, `Database` and dao method throws `IOException`, which is -checked. Under the default rule, a statement that fails and propagates out of a -`@Transactional` method therefore commits whatever the statements before it did. -Spring doesn't have this problem, because its JDBC layer throws unchecked -exceptions. Add `rollbackFor = IOException.class` to every `@Transactional` -method that writes through the pool, as the examples in this chapter do. +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 @@ -377,9 +383,9 @@ gives `@Async`, `@Timed` and `@Counted` the same property. ==== Pitfalls -* **Checked exceptions commit.** Covered above, and worth repeating: without - `rollbackFor = IOException.class`, a failed statement that propagates out of the - method commits the ones before it. +* **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 diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 5092e14eee0..24c9edc4029 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -73,6 +73,16 @@ void impossibleCronIsRefused() { 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() { @@ -277,6 +287,28 @@ void jdbcSessionStore(@org.junit.jupiter.api.io.TempDir java.io.File dir) throws 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"); + 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. @@ -350,6 +382,103 @@ 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() + .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(); + } + } + + 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(); + } + + @Test + @DisplayName("a server that does not measure keeps its requests out of another's histogram") + void uninstrumentedServerIsNotMeasured() throws Exception { + int[] ports = {freePort(), freePort()}; + Properties measured = new Properties(); + measured.setProperty(Config.SERVER_PORT, String.valueOf(ports[0])); + Properties plain = new Properties(); + plain.setProperty(Config.SERVER_PORT, String.valueOf(ports[1])); + HttpServer.Handler ok = new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); + } + }; + Backend a = Backend.builder(Config.of(measured, "dev")).quiet().handler(ok).start(); + Backend b = Backend.builder(Config.of(plain, "prod")).quiet().handler(ok).start(); + try { + read(open(ports[0], "/a")); + long before = requestCount(); + read(open(ports[1], "/b")); + read(open(ports[1], "/b")); + assertEquals(before, requestCount(), + "requests to a server without metrics were recorded"); + } finally { + a.stop(); + b.stop(); + } + } + + 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 { @@ -708,7 +837,7 @@ void mcpNeedsATokenInProduction() { // ----------------------------------------------------------------- helpers /** An application with no beans, for tests that only need the hooks. */ - static final class EmptyApplication implements Backend.Application { + static class EmptyApplication implements Backend.Application { public HttpServer.Handler[] create(Backend.Environment environment) { return new HttpServer.Handler[0]; } diff --git a/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java index 115f4df48ca..ae548510687 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java @@ -131,6 +131,7 @@ void theSessionStoreRoundTrips(String engine) throws Exception { Sessions.Jdbc store = new Sessions.Jdbc(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); @@ -139,13 +140,32 @@ void theSessionStoreRoundTrips(String engine) throws Exception { 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); 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 index 4464d0f94b7..f209aca5d14 100644 --- 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 @@ -470,6 +470,7 @@ public int compare(AnnotatedClass a, AnnotatedClass b) { } } dropSteppedAside(); + checkUniqueNames(); pickEntryPackage(); } @@ -1003,6 +1004,43 @@ private void dropSteppedAside() { } } + /// 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() { + 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 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 index 3fb2a62b474..50ee7b0de14 100644 --- 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 @@ -152,8 +152,12 @@ public int compare(Object[] a, Object[] b) { .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"); + + "java.lang.Error || cn1Error instanceof " + + "com.codename1.backend.DataAccessException"); for (int i = 0; i < open; i++) { sb.append(')'); } 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 index 62b309745ac..5763215d413 100644 --- 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 @@ -850,6 +850,86 @@ public void aRequestBeanDestroyedAfterTheHandlerCanStillUseTheSession() throws E } } + @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 everyScopeAndBindingWorksAtRunTime() throws Exception { Map s = new LinkedHashMap(); diff --git a/scripts/initializr/common/src/main/resources/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md index 2b4e67dcf52..3bf05e4e418 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/backend.md +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -112,7 +112,10 @@ public class Transfers { - 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. +- 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. diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 3d66feb7ee3..52b7bef988d 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -94,6 +94,8 @@ public final class Backend { private final Sessions sessions; /** This server's executors. */ private final Tasks.Registry tasks; + /** 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 @@ -105,8 +107,10 @@ private Backend(HttpServer server, DataSource dataSource, EntityManager entities Config config, int shutdownMillis, Tracer ownTracer, Application application, com.codename1.backend.metrics.MetricReader metricReader, - List managedBeans, Sessions sessions, Tasks.Registry tasks) { + List managedBeans, Sessions sessions, Tasks.Registry tasks, + RequestLog requestLog) { this.tasks = tasks; + this.requestLog = requestLog; this.metricReader = metricReader; this.managedBeans = managedBeans; this.sessions = sessions; @@ -172,6 +176,11 @@ 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; @@ -415,6 +424,15 @@ public static final class Environment { * serve a tool bound to a bean that has been destroyed. */ public void registerTool(com.codename1.backend.mcp.McpTool tool) { + for(int iter = 0 ; iter < tools.size() ; iter++) { + if(((com.codename1.backend.mcp.McpTool)tools.get(iter)).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); } @@ -423,8 +441,8 @@ public void registerManaged(ManagedBean bean) { for(int iter = 0 ; iter < managed.size() ; iter++) { if(((ManagedBean)managed.get(iter)).getObjectName() .equals(bean.getObjectName())) { - managed.set(iter, bean); - return; + throw new IllegalStateException("Two managed resources are named \"" + + bean.getObjectName() + "\"; set objectName on one"); } } managed.add(bean); @@ -995,6 +1013,12 @@ public HttpServer.Response handle(HttpServer.Request request) { // send a TLS server's session cookie without Secure. final Sessions sessions = Sessions.configure(config, context != null, pool, application); + // 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. + final boolean[] instrumented = new boolean[1]; final Application app = application; final boolean track = application != null && application.tracksCurrentRequest(); try { @@ -1012,8 +1036,9 @@ private void endRequestBeans(HttpServer.Request request) { public HttpServer.Response handle(HttpServer.Request request) throws Exception { - long started = com.codename1.backend.metrics.Metrics - .requestStarted(); + long started = instrumented[0] + ? 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 @@ -1025,7 +1050,7 @@ public HttpServer.Response handle(HttpServer.Request request) previous = CURRENT_REQUEST.get(); CURRENT_REQUEST.set(request); } - long startedMillis = RequestLog.enabled + long startedMillis = requestLog.enabled ? System.currentTimeMillis() : 0L; // What the metrics record; stays 500 when a // handler or the session store throws. @@ -1072,12 +1097,12 @@ public HttpServer.Response handle(HttpServer.Request request) + storeErr); } } - RequestLog.record(request, 500, startedMillis, err); + requestLog.record(request, 500, 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 + requestLog.record(request, response == null ? 404 : response.getStatus(), startedMillis, null); status = response == null ? 404 : response.getStatus(); return response; @@ -1141,11 +1166,15 @@ public void register(HttpServer.WebSocketRegistry registry) } if(measuring) { com.codename1.backend.metrics.Metrics.enableServer(server, pool); + instrumented[0] = true; + } + if(application != null && application.getScheduler() != null) { + application.getScheduler().setMeasured(measuring); } Backend backend = new Backend(server, pool, manager, config, drain, tracing ? tracer : null, application, metricReader != null && measuring ? metricReader : null, - managedBeans, sessions, tasks); + managedBeans, sessions, tasks, requestLog); // Backend.stop() tears the beans down from here on. createdApplication = null; startingTasks = null; diff --git a/vm/backend/src/com/codename1/backend/CronSchedule.java b/vm/backend/src/com/codename1/backend/CronSchedule.java index ce6940a7b29..21de2ae14f3 100644 --- a/vm/backend/src/com/codename1/backend/CronSchedule.java +++ b/vm/backend/src/com/codename1/backend/CronSchedule.java @@ -66,8 +66,18 @@ public final class CronSchedule { private final String expression; private static final long DAY = 86400000L; - /** How far to look for a match before concluding there is none (Feb 30th). */ - private static final int SEARCH_DAYS = 366 * 5; + /** + * 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: + * {@code 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. 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..3a53b9dce8c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/DataAccessException.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.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 {@code @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 {@code DataAccessException}. This runtime's data methods declare + * {@link IOException}, so their failures are this subtype of it, and the build's + * rollback rule lists it beside {@code RuntimeException} and {@code Error}. A + * plain {@code IOException} of the application's own -- a file it could not + * read -- still commits, as in Spring; {@code rollbackFor} and + * {@code noRollbackFor} change either. + */ +public class DataAccessException extends IOException { + public DataAccessException(String message) { + super(message); + } + + public DataAccessException(String message, Throwable cause) { + super(message, cause); + } + + /** {@code 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 f8f7604413c..70ac1ee2641 100644 --- a/vm/backend/src/com/codename1/backend/DataSource.java +++ b/vm/backend/src/com/codename1/backend/DataSource.java @@ -280,7 +280,14 @@ synchronized Database borrowFromPool() 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) { diff --git a/vm/backend/src/com/codename1/backend/Database.java b/vm/backend/src/com/codename1/backend/Database.java index 131d518e20c..e65f9161add 100644 --- a/vm/backend/src/com/codename1/backend/Database.java +++ b/vm/backend/src/com/codename1/backend/Database.java @@ -274,15 +274,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. */ @@ -311,15 +317,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); } /** @@ -338,7 +348,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); @@ -380,14 +390,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; diff --git a/vm/backend/src/com/codename1/backend/HttpSession.java b/vm/backend/src/com/codename1/backend/HttpSession.java index 4f3e453f3ad..3f758a9250e 100644 --- a/vm/backend/src/com/codename1/backend/HttpSession.java +++ b/vm/backend/src/com/codename1/backend/HttpSession.java @@ -66,6 +66,13 @@ public final class HttpSession { private boolean fresh; private boolean dirty; private String previousId; + /** + * 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; @@ -103,6 +110,7 @@ public synchronized int getMaxInactiveInterval() { public synchronized void setMaxInactiveInterval(int seconds) { maxInactiveSeconds = seconds; + maxInactiveChanged = true; dirty = true; } @@ -118,12 +126,14 @@ public synchronized void setAttribute(String name, Object value) { } 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; } } @@ -200,6 +210,17 @@ synchronized void clean() { dirty = false; fresh = false; previousId = null; + 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; } /** The id the client held before {@link #changeSessionId}, or null. */ diff --git a/vm/backend/src/com/codename1/backend/Management.java b/vm/backend/src/com/codename1/backend/Management.java index 5afbfa31c7a..1b94c309008 100644 --- a/vm/backend/src/com/codename1/backend/Management.java +++ b/vm/backend/src/com/codename1/backend/Management.java @@ -158,6 +158,26 @@ public static List describeBeans(List all) { * * @throws IllegalArgumentException when there is no such bean or operation */ + private static ManagedBean find(List all, String objectName) { + for(int iter = 0 ; iter < all.size() ; iter++) { + ManagedBean bean = (ManagedBean)all.get(iter); + 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(int iter = 0 ; iter < all.size() ; iter++) { @@ -223,19 +243,36 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { 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) { - arguments = Json.parseObject(body); + 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(); - // Bean and operation names are Java identifiers, so there is - // nothing to percent-decode: an escaped one matches nothing. - result.put("result", invoke(beans(), parts[0], parts[1], arguments)); + result.put("result", bean.invoke(op, arguments)); return request.respondJson(200, result); } catch (IllegalArgumentException err) { - return request.respondJson(404, error(err.getMessage())); + return request.respondJson(400, error(err.getMessage())); } } return null; diff --git a/vm/backend/src/com/codename1/backend/RequestLog.java b/vm/backend/src/com/codename1/backend/RequestLog.java index eb1c4a2988f..c0103dbc346 100644 --- a/vm/backend/src/com/codename1/backend/RequestLog.java +++ b/vm/backend/src/com/codename1/backend/RequestLog.java @@ -35,24 +35,30 @@ * read per request and nothing else. */ public final class RequestLog { - static boolean enabled; - private static Map[] ring = new Map[0]; - private static int next; - private static int size; + /** + * 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. Plain, not volatile: a request that misses the switch by + * a moment is simply not logged. + */ + boolean enabled; + private Map[] ring = new Map[0]; + private int next; + private int size; - private RequestLog() { + RequestLog() { } /** Starts keeping the last {@code capacity} requests. */ - public static synchronized void enable(int capacity) { + public synchronized void enable(int capacity) { ring = new Map[capacity < 1 ? 1 : capacity]; next = 0; size = 0; enabled = true; } - static void record(HttpServer.Request request, int status, long startedMillis, - Throwable error) { + void record(HttpServer.Request request, int status, long startedMillis, + Throwable error) { if(!enabled) { return; } @@ -78,7 +84,7 @@ static void record(HttpServer.Request request, int status, long startedMillis, entry.put("causes", where.toString()); } } - synchronized(RequestLog.class) { + synchronized(this) { ring[next] = entry; next = (next + 1) % ring.length; if(size < ring.length) { @@ -88,7 +94,7 @@ static void record(HttpServer.Request request, int status, long startedMillis, } /** The newest {@code limit} requests, newest first; only failures when asked. */ - public static synchronized List recent(int limit, boolean failuresOnly) { + 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; diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/backend/Scheduler.java index f3af4baef52..8e15941e3a1 100644 --- a/vm/backend/src/com/codename1/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -116,6 +116,17 @@ public String describeSchedule() { private Thread thread; 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; + + /** Set by the server once it knows whether it records metrics. */ + public void setMeasured(boolean measured) { + this.measured = measured; + } private boolean lockTableReady; /** @@ -321,9 +332,12 @@ private long following(Job job, long now) { next = now; } // Catch up without a burst: the next START after now on the rate's - // own grid. - while(next <= now) { - next += job.period; + // 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; + next += missed * job.period; } return next; } @@ -352,7 +366,7 @@ public Object run(Span span) throws Exception { release(job); } long end = System.currentTimeMillis(); - if(ran) { + if(ran && measured) { com.codename1.backend.metrics.Metrics.jobRan(job.name, end - start, error != null); } diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index e0673ef007b..fa9ff4f7155 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -555,7 +555,8 @@ private synchronized void prepare() throws IOException { + 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) + ")", null); + + d.columnType(Dialect.TEXT) + ", version " + + d.columnType(Dialect.BIGINT) + " NOT NULL)", null); ready = true; } @@ -584,30 +585,77 @@ 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. + */ public void save(HttpSession session, String previousId) throws IOException { prepare(); - String json = Json.write(session.attributesCopy()); - Object[] values = new Object[] {new Long(session.getLastAccessedTime()), - new Integer(session.getMaxInactiveInterval()), json, session.getId()}; - int updated = pool.execute("UPDATE " + TABLE + " SET last_accessed = ?, " - + "max_inactive = ?, attributes = ? WHERE id = ?", values); - if(updated == 0 && !session.isNew() && previousId == 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, so it stays gone. - return; - } - if(updated == 0) { + if(session.isNew() || previousId != null) { + // A new id: nobody else can have written this row yet. pool.execute("INSERT INTO " + TABLE + " (id, created, last_accessed, " - + "max_inactive, attributes) VALUES (?, ?, ?, ?, ?)", + + "max_inactive, attributes, version) VALUES (?, ?, ?, ?, ?, 0)", new Object[] {session.getId(), new Long(session.getCreationTime()), new Long(session.getLastAccessedTime()), - new Integer(session.getMaxInactiveInterval()), json}); + new Integer(session.getMaxInactiveInterval()), + Json.write(session.attributesCopy())}); + session.storedAccessed = session.getLastAccessedTime(); + if(previousId != null) { + delete(previousId); + } + return; } - session.storedAccessed = session.getLastAccessedTime(); - if(previousId != null) { - delete(previousId); + java.util.Set changed = session.changedNames(); + Map mine = session.attributesCopy(); + Long used = new Long(session.getLastAccessedTime()); + for(int attempt = 0 ; attempt < SAVE_ATTEMPTS ; attempt++) { + Map row = pool.queryOne("SELECT max_inactive, attributes, version FROM " + + TABLE + " WHERE id = ?", new Object[] {session.getId()}); + 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)); + } + java.util.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 " + + "version = ?", new Object[] {Json.write(merged), + new Integer(maxInactive), used, used, new Long(version + 1), + session.getId(), new Long(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)"); } void touchIfStale(HttpSession session) throws IOException { @@ -618,8 +666,11 @@ void touchIfStale(HttpSession session) throws IOException { if(now - session.storedAccessed < touchInterval(session.getMaxInactiveInterval())) { return; } - pool.execute("UPDATE " + TABLE + " SET last_accessed = ? WHERE id = ?", - new Object[] {new Long(now), session.getId()}); + // Forward only: a slower request must not move the last use back. + Long at = new Long(now); + pool.execute("UPDATE " + TABLE + " SET last_accessed = CASE WHEN last_accessed " + + "> ? THEN last_accessed ELSE ? END WHERE id = ?", + new Object[] {at, at, session.getId()}); session.storedAccessed = now; } @@ -645,9 +696,16 @@ public int purgeExpired(long now) throws IOException { // The same grace load() allows: the timeout plus the touch interval, // min(a minute, a quarter of the timeout). Long at = new Long(now); + // + // 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 < ? OR " - + "last_accessed + max_inactive * 1000 + " + TOUCH_INTERVAL + " < ?)", + + "(last_accessed + max_inactive * 1250.0 < ? OR " + + "last_accessed + max_inactive * 1000.0 + " + TOUCH_INTERVAL + " < ?)", new Object[] {at, at}); } diff --git a/vm/backend/src/com/codename1/backend/mcp/DevTools.java b/vm/backend/src/com/codename1/backend/mcp/DevTools.java index 373cb0c75d9..70b2ddd1f03 100644 --- a/vm/backend/src/com/codename1/backend/mcp/DevTools.java +++ b/vm/backend/src/com/codename1/backend/mcp/DevTools.java @@ -35,7 +35,6 @@ import com.codename1.backend.Database; import com.codename1.backend.DevConsole; import com.codename1.backend.Management; -import com.codename1.backend.RequestLog; import com.codename1.backend.Scheduler; import com.codename1.backend.Web; import com.codename1.backend.metrics.Metrics; @@ -69,7 +68,7 @@ public final class DevTools implements McpServer.Extension { public void install(McpServer server, Backend running) { this.backend = running; - RequestLog.enable(200); + running.getRequestLog().enable(200); DevConsole.install(); server.register(new Tool("backend_routes", "Lists every HTTP route of the running backend: method, path and the " @@ -114,7 +113,8 @@ Object run(Map a) throws Exception { schema(new String[] {"limit", "integer", "How many, default 20", "failuresOnly", "boolean", "Only 5xx answers and exceptions"}, null)) { Object run(Map a) { - return RequestLog.recent(intArg(a, "limit", 20), boolArg(a, "failuresOnly")); + return backend.getRequestLog().recent(intArg(a, "limit", 20), + boolArg(a, "failuresOnly")); } }); server.register(new Tool("backend_logs", diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index c801a366cc2..344fd874380 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -317,11 +317,16 @@ public static long requestStarted() { /** 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; } - Object route = ROUTE.get(); - ROUTE.set(null); requestDuration.record((System.nanoTime() - started) / 1000000.0, route, method, new Integer(status)); } From 51929feb78af50f66c9005932a0409fe5ca76896 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:15:07 +0300 Subject: [PATCH 08/48] Backend: per-server tracers and gauges, dependency-ordered destruction - Each server keeps its own tracer until it stops: a later server's start no longer retires a live one's, request spans use the tracer of the server they reached and child spans their parent's, and the global slot passes to another live server's tracer when its own stops. - Managed-resource gauges are sources of a shared gauge, added when their server starts and removed when it stops. - Lazy singletons are destroyed in dependency order with the eager ones; request and session beans dependents first. - An @Async call dropped at the shutdown deadline fails its Future. - Counters export through OTLP as_int and print exactly in Prometheus. - Jobs whose classes share a simple name take qualified names. - Virtual tasks run only on their own server's hosts. - MCP enum arguments match constant names; Prometheus name collisions are refused when the second instrument is created. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Backend-Observability.asciidoc | 6 +- .../Backend-Scheduling.asciidoc | 5 + .../backend/ApplicationRuntimeTest.java | 73 ++++++++++++ .../backend/otel/OtlpMetricsProtoTest.java | 15 +++ .../backend/otel/OtlpTracerTest.java | 50 +++++++++ .../maven/processors/BackendBeans.java | 27 ++++- .../maven/processors/BackendSources.java | 5 +- .../maven/processors/BackendWiringWriter.java | 70 ++++++++++-- .../maven/processors/BackendBeansTest.java | 102 +++++++++++++++++ .../src/com/codename1/backend/Backend.java | 46 +++++++- .../src/com/codename1/backend/HttpServer.java | 34 +++++- .../src/com/codename1/backend/Span.java | 2 + .../com/codename1/backend/TaskExecutor.java | 17 ++- .../src/com/codename1/backend/Tasks.java | 23 +++- .../src/com/codename1/backend/Tracing.java | 82 ++++++++++++-- .../com/codename1/backend/mcp/McpArgs.java | 10 +- .../codename1/backend/metrics/Instrument.java | 12 ++ .../codename1/backend/metrics/Metrics.java | 105 +++++++++++++++++- .../backend/otel/OtlpMetricExporter.java | 8 +- .../codename1/backend/otel/OtlpSchema.java | 4 +- 20 files changed, 652 insertions(+), 44 deletions(-) diff --git a/docs/developer-guide/Backend-Observability.asciidoc b/docs/developer-guide/Backend-Observability.asciidoc index 6e94def06e2..5766750de33 100644 --- a/docs/developer-guide/Backend-Observability.asciidoc +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -270,7 +270,11 @@ 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. +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` diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc index 34e29b792b6..70a20e4f607 100644 --- a/docs/developer-guide/Backend-Scheduling.asciidoc +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -238,6 +238,11 @@ 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 diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 24c9edc4029..662d1c8ac7b 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -165,6 +165,78 @@ public void run() { 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("an abandoned @Async call fails its Future instead of never finishing") void abandonedAsyncFails() throws Exception { @@ -573,6 +645,7 @@ public void run() { }); one.shutdown(50); assertEquals(1, one.getDroppedCount()); + release.countDown(); Thread.sleep(200); assertEquals(0, late.get(), "a queued task ran after the shutdown deadline"); 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 index f9be3af1908..2f462054b34 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -74,6 +74,21 @@ void histogramBuckets() throws Exception { assertNotNull(point.getAttributesList()); } + @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 exporter opened again after a shutdown exports periodically again") void reopenedExporterKeepsExporting() throws Exception { 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..c240d9e034a 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 @@ -323,6 +323,56 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { "the status description carried the excluded message"); } + @Test + @DisplayName("a second traced server leaves the first one's tracer running and in use") + void twoTracedServersKeepTheirOwnTracers() throws Exception { + int[] ports = {freePort(), freePort()}; + Backend[] servers = new Backend[2]; + String[] names = {"first-service", "second-service"}; + for(int i = 0 ; i < 2 ; i++) { + Properties settings = settings(ports[i]); + settings.setProperty(OtlpTracer.SERVICE_NAME, names[i]); + servers[i] = Backend.builder(Config.of(settings, "test")) + .quiet() + .tracing(new OtlpTracer()) + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) + throws Exception { + return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); + } + }) + .start(); + } + try { + // The first server's request, after the second installed its tracer. + HttpURLConnection c = (HttpURLConnection)new URL( + "http://127.0.0.1:" + ports[0] + "/first").openConnection(); + assertEquals(200, c.getResponseCode()); + c.getInputStream().close(); + } finally { + servers[0].stop(); + servers[1].stop(); + } + String service = null; + for(int iter = 0 ; iter < exports.size() ; iter++) { + ExportTraceServiceRequest request = ExportTraceServiceRequest.parseFrom( + (byte[])exports.get(iter)); + for(int r = 0 ; r < request.getResourceSpansCount() ; r++) { + String name = attribute(request.getResourceSpans(r).getResource() + .getAttributesList(), "service.name"); + for(int sc = 0 ; sc < request.getResourceSpans(r).getScopeSpansCount() ; sc++) { + for(Span span : request.getResourceSpans(r).getScopeSpans(sc).getSpansList()) { + if("/first".equals(attribute(span.getAttributesList(), "url.path"))) { + service = name; + } + } + } + } + } + assertEquals("first-service", service, + "the first server's request was not exported by its own tracer"); + } + @Test @DisplayName("installing a tracer shuts down the one it replaces, and only that one") void replacingATracerShutsDownThePreviousOne() { 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 index f209aca5d14..0e10c163c0f 100644 --- 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 @@ -167,7 +167,10 @@ static final class Call { /// One `@Scheduled` method. static final class Job { final MethodInfo method; - final String name; + /// 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 = ""; @@ -1010,6 +1013,27 @@ private void dropSteppedAside() { /// 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(); + } + } + } Map tools = new LinkedHashMap(); Map managed = new LinkedHashMap(); for (Bean b : beans) { @@ -1103,6 +1127,7 @@ private void collectJobs(Bean bean) { } 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")); 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 index 50ee7b0de14..f32e420c281 100644 --- 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 @@ -642,7 +642,8 @@ String managed(BackendBeans.Bean b) { 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() {\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); @@ -651,7 +652,7 @@ String managed(BackendBeans.Bean b) { if (read == null) { continue; } - sb.append(" com.codename1.backend.metrics.Metrics.gauge(") + 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") 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 index 82c9782214d..60bfc5dd350 100644 --- 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 @@ -220,7 +220,8 @@ private void create(StringBuilder sb, List routers) { .append(ref).append(");\n"); sb.append(" environment.registerManaged(managed") .append(b.var).append(");\n"); - sb.append(" managed").append(b.var).append(".registerGauges();\n"); + sb.append(" managed").append(b.var) + .append(".registerGauges(environment);\n"); } for (BackendBeans.Tool t : b.tools) { sb.append(" environment.registerTool(new ") @@ -631,18 +632,21 @@ private static String duration(long literal, String text, String where) { private void stopped(StringBuilder sb) { sb.append(" public void stopped() {\n"); - List reverse = new ArrayList(model.order); - java.util.Collections.reverse(reverse); - for (BackendBeans.Bean b : reverse) { - if (b.isEager()) { - destroy(sb, b, b.var); - } - } + // 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.lazy) { - destroy(sb, b, b.var + "Real"); + 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"); } @@ -685,7 +689,17 @@ private void requestEnded(StringBuilder sb) { /// 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. @@ -767,6 +781,42 @@ private static String conditionText(BackendBeans.Bean b) { 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()); 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 index 5763215d413..14a7bf6b0fe 100644 --- 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 @@ -930,6 +930,108 @@ public void duplicateToolAndManagedNamesAreBuildErrors() throws Exception { 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 everyScopeAndBindingWorksAtRunTime() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 52b7bef988d..610473f2715 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -94,6 +94,8 @@ public final class Backend { 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; /** The recent requests of this server, kept once the development tools ask. */ private final RequestLog requestLog; /** @@ -108,7 +110,13 @@ private Backend(HttpServer server, DataSource dataSource, EntityManager entities Application application, com.codename1.backend.metrics.MetricReader metricReader, List managedBeans, Sessions sessions, Tasks.Registry tasks, - RequestLog requestLog) { + RequestLog requestLog, List gauges) { + this.gauges = gauges; + for(int iter = 0 ; iter < gauges.size() ; iter++) { + Object[] g = (Object[])gauges.get(iter); + com.codename1.backend.metrics.Metrics.addSource((String)g[0], (String)g[1], + (String)g[2], (com.codename1.backend.metrics.Gauge.Source)g[3]); + } this.tasks = tasks; this.requestLog = requestLog; this.metricReader = metricReader; @@ -249,6 +257,11 @@ private void stopOnce() { // Out of the process's server gauges, which would otherwise keep reading // a stopped server and pool. com.codename1.backend.metrics.Metrics.disableServer(server, dataSource); + for(int iter = 0 ; iter < gauges.size() ; iter++) { + Object[] g = (Object[])gauges.get(iter); + com.codename1.backend.metrics.Metrics.removeSource((String)g[0], + (com.codename1.backend.metrics.Gauge.Source)g[3]); + } // 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); @@ -407,6 +420,8 @@ public static final class Environment { 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) { @@ -436,6 +451,15 @@ public void registerTool(com.codename1.backend.mcp.McpTool tool) { 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) { for(int iter = 0 ; iter < managed.size() ; iter++) { @@ -788,7 +812,9 @@ public Backend start() throws Exception { 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; } @@ -881,9 +907,10 @@ private Backend startWith(DataSource pool, boolean tracing) throws Exception { // the first server's beans into the second. List tools = new ArrayList(mcpTools); List managedBeans = new ArrayList(); + Environment environment = null; if(application != null) { - HttpServer.Handler[] built = application.create( - new Environment(config, pool, manager, tools, managedBeans)); + environment = new Environment(config, pool, manager, tools, managedBeans); + HttpServer.Handler[] built = application.create(environment); if(built != null) { for(int iter = 0 ; iter < built.length ; iter++) { if(built[iter] != null) { @@ -1152,6 +1179,14 @@ public void register(HttpServer.WebSocketRegistry registry) } 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; + } + if(tracing) { + server.setTracer(tracer); + } // 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. @@ -1174,7 +1209,8 @@ public void register(HttpServer.WebSocketRegistry registry) Backend backend = new Backend(server, pool, manager, config, drain, tracing ? tracer : null, application, metricReader != null && measuring ? metricReader : null, - managedBeans, sessions, tasks, requestLog); + managedBeans, sessions, tasks, requestLog, + environment == null ? new ArrayList() : environment.gauges); // Backend.stop() tears the beans down from here on. createdApplication = null; startingTasks = null; diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index cfa1431f781..54eed52533f 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -2648,9 +2648,31 @@ static boolean acceptsVirtualTasks() { * host 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 = ACTIVE_SERVER; - if(task == null || server == null || !server.running || server.vtHosts == null) { + /** The tracer this server's requests report to, when it has one of its own. */ + private volatile Tracer serverTracer; + + /** + * Traces this server's requests with {@code tracer} rather than whichever + * tracer is installed process-wide -- which, with two servers, is the one + * that started last. Called by the server's builder. + */ + void setTracer(Tracer tracer) { + this.serverTracer = tracer; + } + + /** The server holding the virtual-thread slot, or null. */ + static HttpServer activeServer() { + return ACTIVE_SERVER; + } + + /** + * Queues {@code task} on a host of {@code 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. + */ + 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; @@ -5252,7 +5274,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 { @@ -5285,7 +5307,7 @@ 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; try { @@ -5658,7 +5680,7 @@ 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); + Span span = Tracing.startServer(request, tls != null, serverTracer); Exception 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 diff --git a/vm/backend/src/com/codename1/backend/Span.java b/vm/backend/src/com/codename1/backend/Span.java index a7d0d213b88..88391637328 100644 --- a/vm/backend/src/com/codename1/backend/Span.java +++ b/vm/backend/src/com/codename1/backend/Span.java @@ -50,6 +50,8 @@ public abstract class Span { * Owned by {@link 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 {@link Tracing} made this span current and must restore on end. */ boolean entered; /** diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index 6fdae6d24b5..b7157197304 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -116,7 +116,9 @@ public void execute(Runnable task) { } active++; } - if(HttpServer.submitVirtualTask(new Counted(this, task))) { + HttpServer host = registry == null ? HttpServer.activeServer() + : registry.virtualHost(); + if(HttpServer.submitVirtualTask(new Counted(this, task), host)) { return; } synchronized(this) { @@ -236,6 +238,15 @@ public synchronized void shutdown(long waitMillis) { 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(int iter = 0 ; iter < queue.size() ; iter++) { + Object queued = queue.get(iter); + if(queued instanceof AsyncTask) { + ((AsyncTask)queued).abandon("dropped when the server stopped before it " + + "could start"); + } + } queue.clear(); if(workers != null) { for(int iter = 0 ; iter < workers.length ; iter++) { @@ -294,6 +305,10 @@ 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; } diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index dd0e416f94c..4d0a9f10b1c 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -80,15 +80,32 @@ static final class Registry { 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 {@link #ownedByServer}. + */ + HttpServer server; + /** Whether a server opened this registry, as opposed to a bare process. */ + final boolean ownedByServer; - Registry(Config config) { + 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) { - Registry r = new Registry(config); + Registry r = new Registry(config, true); synchronized(Tasks.class) { LIVE.add(r); } @@ -124,7 +141,7 @@ private static Registry current() { if(LIVE.isEmpty()) { // No server is running -- a test calling an @Async method // directly: executors take their defaults. - LIVE.add(new Registry(null)); + LIVE.add(new Registry(null, false)); } return (Registry)LIVE.get(LIVE.size() - 1); } diff --git a/vm/backend/src/com/codename1/backend/Tracing.java b/vm/backend/src/com/codename1/backend/Tracing.java index 2aadcf600f5..01c2d841275 100644 --- a/vm/backend/src/com/codename1/backend/Tracing.java +++ b/vm/backend/src/com/codename1/backend/Tracing.java @@ -139,12 +139,36 @@ static Swap swap(Tracer installed) { */ 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 {@code owned} the installed tracer belongs to + * the server that just started and lives until that server stops it with + * {@link #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); } @@ -271,7 +295,21 @@ public static Object inSpan(String name, Work work) throws Exception { * another thread; null otherwise. */ static Span captureParent() { - return tracer == null ? null : currentOrNull(); + 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; + } + return tracer; } /** @@ -280,7 +318,8 @@ static Span captureParent() { * 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 { - Tracer t = tracer; + // The parent's tracer: the run belongs to whoever started it. + Tracer t = parent != null && parent.owner != null ? parent.owner : tracer; if(t == null || isSuppressed()) { return work.run(NOOP); } @@ -288,6 +327,7 @@ static Object inBackground(String name, Span parent, Work work) throws Exception try { span = t.startSpan(name, Span.KIND_INTERNAL, parent, null, null); if(span != null) { + span.owner = t; enter(span); } } catch (RuntimeException err) { @@ -308,12 +348,15 @@ static Object inBackground(String name, Span parent, Work work) throws Exception } 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) { failed(err); @@ -360,7 +403,7 @@ public static void route(String template) { 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(); @@ -398,7 +441,17 @@ public static void route(String template) { * request NOW, because a Request is valid only while its handler runs. */ static Span startServer(HttpServer.Request request, boolean secure) { - Tracer t = tracer; + return startServer(request, secure, null); + } + + /** + * {@link #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 || request == null) { return null; } @@ -410,6 +463,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(); @@ -522,7 +576,7 @@ private static Span servingSpan() { * 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; } @@ -623,7 +677,7 @@ static void endHttpClient(Span span, int status, Throwable error) { * {@code 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; } @@ -796,8 +850,15 @@ static void shutdown(Tracer owned, int timeoutMillis) { } boolean stop = false; synchronized(LIFECYCLE) { + boolean wasOwned = OWNED.remove(owned); if(tracer == owned) { - 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(int iter = 0 ; iter < PENDING.size() ; iter++) { @@ -845,7 +906,7 @@ private static Span currentOrNull() { * 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; } @@ -858,6 +919,9 @@ 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; } diff --git a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java index 191665e94f6..12da8cfb93f 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java @@ -216,20 +216,26 @@ public static List list(Map args, String name, boolean required) { 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 {@code 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(int iter = 0 ; iter < values.length ; iter++) { - if(String.valueOf(values[iter]).equals(s)) { + if(constantName(values[iter]).equals(s)) { return values[iter]; } } StringBuilder allowed = new StringBuilder(); for(int iter = 0 ; iter < values.length ; iter++) { - allowed.append(iter == 0 ? "" : ", ").append(values[iter]); + allowed.append(iter == 0 ? "" : ", ").append(constantName(values[iter])); } throw new IllegalArgumentException("\"" + name + "\" must be one of " + allowed); } diff --git a/vm/backend/src/com/codename1/backend/metrics/Instrument.java b/vm/backend/src/com/codename1/backend/metrics/Instrument.java index 110928fccb0..6ff6fc8d414 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Instrument.java +++ b/vm/backend/src/com/codename1/backend/metrics/Instrument.java @@ -75,6 +75,18 @@ public int getKind() { */ 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", new Long(value)); + return p; + } + static Map point(Map attributes, double value) { Map p = new LinkedHashMap(); p.put("attributes", attributes == null ? new LinkedHashMap() : attributes); diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index 344fd874380..28672d77f55 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -23,6 +23,7 @@ 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; @@ -126,9 +127,48 @@ private static synchronized void replaceGauge(Gauge g) { throw new IllegalArgumentException("Metric " + g.getName() + " already exists as another kind"); } + claimPrometheusNames(g.getName(), Instrument.GAUGE); INSTRUMENTS.put(g.getName(), g); } + /** 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(int iter = 0 ; iter < series.length ; iter++) { + Object owner = PROMETHEUS_NAMES.get(series[iter]); + if(owner != null && !owner.equals(name)) { + throw new IllegalArgumentException("Metric " + name + " would be exported to " + + "Prometheus as " + series[iter] + ", which metric " + owner + + " already is; rename one"); + } + } + for(int iter = 0 ; iter < series.length ; iter++) { + PROMETHEUS_NAMES.put(series[iter], name); + } + } + + private static void releasePrometheusNames(String name) { + java.util.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) { @@ -149,6 +189,8 @@ private static synchronized Instrument register(String name, int kind, String de } 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; } @@ -170,6 +212,63 @@ public static long startTimeMillis() { // ------------------------------------------------------- server instrumentation + /** Gauges several servers contribute to: name -> List of Gauge.Source. */ + private static final Map SHARED = new HashMap(); + + /** + * Adds one server's source to the gauge called {@code 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) { + final List all = new ArrayList(); + sources = all; + SHARED.put(name, all); + replaceGauge(new Gauge(name, description, unit, new Gauge.Source() { + public double read() { + Object[] each; + synchronized(Metrics.class) { + each = all.toArray(); + } + double sum = 0; + boolean any = false; + for(int iter = 0 ; iter < each.length ; iter++) { + try { + double v = ((Gauge.Source)each[iter]).read(); + if(!Double.isNaN(v)) { + sum += v; + any = true; + } + } catch (Exception err) { + // One source failing leaves the others' values. + } + } + return any ? sum : Double.NaN; + } + })); + } + sources.add(source); + } + + /** Removes a source {@link #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); + } + } + /** 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(); @@ -422,8 +521,10 @@ public static String prometheus() { } else { sb.append(name); labels(sb, attributes, null); - sb.append(' ').append(number(((Number)point.get("value")).doubleValue())) - .append('\n'); + 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'); } } } diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index abd10663530..f1c33cb6eb5 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -280,7 +280,13 @@ static Map request(Map resource, List instruments, long nowMillis) { if(value instanceof Double && ((Double)value).isNaN()) { continue; } - dp.put("asDouble", value); + 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", value); + } } dataPoints.add(dp); } diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java index 529fea57475..6eeea7dbf67 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java @@ -233,7 +233,9 @@ private static Field rep(String name, int number) { Message numberPoint = new Message(new Field[] { attributes(7), f("startTimeUnixNano", 2, FIXED64), f("timeUnixNano", 3, FIXED64), - f("asDouble", 4, DOUBLE), f("flags", 8, VARINT) + // as_int is sfixed64: the same eight little-endian bytes FIXED64 + // writes for a long, negative values included. + f("asDouble", 4, DOUBLE), f("asInt", 6, FIXED64), f("flags", 8, VARINT) }); // bucket_counts is "repeated fixed64" on HistogramDataPoint in // opentelemetry-proto's metrics.proto, like count -- NOT the "repeated From b9ab53826660397e8cb03677ef4a4ccf5dc750bc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:37:31 +0300 Subject: [PATCH 09/48] Backend: safer start-up failure, init order, session replacement - Any failure after the listener binds, before a Backend owns it, stops the listener, and gauges a failed start added are removed. - @PostConstruct follows every injection, fields and setters included. - A connection whose ROLLBACK fails in Database.transaction is closed, so the pool discards it instead of lending it to a borrower that would hang. - The generated application binds its scheduler to the server before starting it: job metrics are recorded when the server measures, and job spans go to the server's own tracer. - After invalidate(), getSession(false) answers null and getSession(true) starts a new session in the same request; both are finished at its end. - @Async on a @RequestScope bean, and @Async with @Scheduled, are build errors. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Backend-Scheduling.asciidoc | 6 ++ .../developer-guide/Backend-Sessions.asciidoc | 4 + .../backend/ApplicationRuntimeTest.java | 33 +++++++ .../maven/processors/BackendBeans.java | 26 +++++ .../maven/processors/BackendWiringWriter.java | 13 ++- .../maven/processors/BackendBeansTest.java | 51 ++++++++++ .../src/com/codename1/backend/Backend.java | 98 ++++++++++++++----- .../src/com/codename1/backend/Database.java | 12 ++- .../src/com/codename1/backend/HttpServer.java | 17 ++++ .../src/com/codename1/backend/Scheduler.java | 18 +++- .../src/com/codename1/backend/Tracing.java | 13 ++- 11 files changed, 264 insertions(+), 27 deletions(-) diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc index 70a20e4f607..ea3cdb8bf70 100644 --- a/docs/developer-guide/Backend-Scheduling.asciidoc +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -48,6 +48,12 @@ 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. +Two combinations are build errors. `@Async` on a `@RequestScope` bean would hand +the task an instance that's destroyed when the request ends, possibly before the +task runs. `@Async` on a `@Scheduled` method 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. + 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 diff --git a/docs/developer-guide/Backend-Sessions.asciidoc b/docs/developer-guide/Backend-Sessions.asciidoc index 86431cc348c..837f4be138b 100644 --- a/docs/developer-guide/Backend-Sessions.asciidoc +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -49,6 +49,10 @@ effect. `invalidate()` ends one, and the response clears the client's cookie: 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 diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 662d1c8ac7b..ef571de0ec4 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -679,6 +679,15 @@ public HttpServer.Response handle(HttpServer.Request request) request.getSession(true).invalidate(); return request.respond(200, "text/plain", "out".getBytes("UTF-8")); } + 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; } }) @@ -704,6 +713,30 @@ public HttpServer.Response handle(HttpServer.Request request) 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)); } finally { backend.stop(); } 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 index 0e10c163c0f..9be0b253040 100644 --- 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 @@ -538,6 +538,21 @@ private Bean classBean(AnnotatedClass cls) { if (props != null) { bindProperties(bean, props, cls); } + if (REQUEST.equals(bean.scope)) { + for (MethodInfo m : cls.getMethods()) { + if (m.getAnnotation(ASYNC) != null + || (cls.getClassAnnotation(ASYNC) != null && m.isPublic() + && !m.isConstructor() && !m.isStatic())) { + // The task would hold the request's instance after the request + // ended and destroyed it. + ctx.error(cls, "@Async method " + cls.getSourceName() + "." + m.getName() + + " is on a @RequestScope bean, which is destroyed when its " + + "request 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); @@ -1125,6 +1140,17 @@ private void collectJobs(Bean bean) { + "that takes no arguments."); continue; } + if (m.getAnnotation(ASYNC) != null + || (cls.getClassAnnotation(ASYNC) != null && m.isPublic())) { + // 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; + } Job job = new Job(m, RestClientAnnotationProcessor.simpleName( cls.getBinaryName().replace('$', '.')) + "." + m.getName()); job.ownerBinary = cls.getBinaryName(); 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 index 60bfc5dd350..e06f837053d 100644 --- 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 @@ -203,11 +203,19 @@ private void create(StringBuilder sb, List routers) { } } 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()) { - initialize(sb, b, b.var, " ", true); + 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; @@ -556,6 +564,9 @@ private void started(StringBuilder sb) { + "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()) { 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 index 14a7bf6b0fe..4d143501198 100644 --- 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 @@ -254,6 +254,10 @@ public void theGeneratedWiringRunsTheSample() throws Exception { 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(); } @@ -1032,6 +1036,53 @@ public void managedGaugesLeaveWithTheirServer() throws Exception { 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"); + ProcessorContext ctx = process(compile(s)); + String errors = String.valueOf(ctx.getErrors()); + assertTrue(errors, errors.contains("is on a @RequestScope bean")); + 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 everyScopeAndBindingWorksAtRunTime() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 610473f2715..432fef8fa60 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -96,6 +96,22 @@ public final class Backend { 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; /** @@ -112,10 +128,21 @@ private Backend(HttpServer server, DataSource dataSource, EntityManager entities List managedBeans, Sessions sessions, Tasks.Registry tasks, RequestLog requestLog, List gauges) { this.gauges = gauges; - for(int iter = 0 ; iter < gauges.size() ; iter++) { - Object[] g = (Object[])gauges.get(iter); - com.codename1.backend.metrics.Metrics.addSource((String)g[0], (String)g[1], - (String)g[2], (com.codename1.backend.metrics.Gauge.Source)g[3]); + 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; @@ -1102,13 +1129,31 @@ public HttpServer.Response handle(HttpServer.Request request) // client receives, and the request log // must say so rather than record the // handler's own status. + HttpSession ended = request.endedSession(); HttpSession session = request.resolvedSession(); - if(session != null) { + if(ended != null || session != null) { sessionFinished = true; + } + if(ended != null) { + // First, so its clearing cookie comes + // before the new session's. + response = sessions.finish(ended, response); + } + if(session != null) { response = sessions.finish(session, response); } } catch (Exception err) { endRequestBeans(request); + HttpSession ended = request.endedSession(); + if(ended != null && !sessionFinished) { + try { + sessions.finish(ended, null); + } catch (Exception storeErr) { + System.err.println("Could not end the " + + "session of a failed request: " + + storeErr); + } + } HttpSession session = request.resolvedSession(); if(session != null && !sessionFinished) { // The handler threw, but what it did to @@ -1191,26 +1236,35 @@ public void register(HttpServer.WebSocketRegistry registry) // listener began accepting -- registering them here instead left a // window in which a valid upgrade was answered as ordinary HTTP. boolean measuring = management != null; - if(metricReader != null) { - try { + 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. + try { + if(metricReader != null) { measuring |= metricReader.open(config); - } catch (IOException err) { - server.stop(0); - throw err; } + if(measuring) { + com.codename1.backend.metrics.Metrics.enableServer(server, pool); + instrumented[0] = true; + } + backend = new Backend(server, pool, manager, config, drain, + tracing ? tracer : null, application, + metricReader != null && measuring ? metricReader : null, + managedBeans, sessions, tasks, requestLog, + environment == null ? new ArrayList() : environment.gauges); + } catch (Exception err) { + server.stop(0); + com.codename1.backend.metrics.Metrics.disableServer(server, pool); + if(metricReader != null) { + metricReader.shutdown(0); + } + throw err; } - if(measuring) { - com.codename1.backend.metrics.Metrics.enableServer(server, pool); - instrumented[0] = true; - } - if(application != null && application.getScheduler() != null) { - application.getScheduler().setMeasured(measuring); - } - Backend backend = new Backend(server, pool, manager, config, drain, - tracing ? tracer : null, application, - metricReader != null && measuring ? metricReader : null, - managedBeans, sessions, tasks, requestLog, - environment == null ? new ArrayList() : environment.gauges); + backend.measured = measuring; // Backend.stop() tears the beans down from here on. createdApplication = null; startingTasks = null; diff --git a/vm/backend/src/com/codename1/backend/Database.java b/vm/backend/src/com/codename1/backend/Database.java index e65f9161add..08081f4357d 100644 --- a/vm/backend/src/com/codename1/backend/Database.java +++ b/vm/backend/src/com/codename1/backend/Database.java @@ -874,8 +874,16 @@ public synchronized Object transaction(Work body) throws Exception { return result; } finally { if (!committed) { - try { rollbackTransaction(); } - catch (Exception err) { System.err.println("rollback failed: " + err); } + try { + rollbackTransaction(); + } catch (Exception 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 54eed52533f..3ab362baca1 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -124,6 +124,8 @@ public static final class Request { private boolean canonicalChecked; /** The sessions of the server serving this request; set by Backend. */ Sessions sessions; + /** See {@link #endedSession}. */ + private HttpSession endedSession; /** This request's session once looked up; see {@link #getSession(boolean)}. */ private HttpSession session; private boolean sessionResolved; @@ -587,6 +589,7 @@ void releaseRetained() { this.sessionResolved = false; this.scopedBeans = null; this.sessions = null; + this.endedSession = null; } /** The session of this request, creating one if it has none. */ @@ -601,6 +604,14 @@ public HttpSession getSession() { * See {@link 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. + endedSession = session; + session = null; + } if(sessionResolved && (session != null || !create)) { return session; } @@ -627,6 +638,11 @@ HttpSession resolvedSession() { return session; } + /** A session this request invalidated and then replaced, or null. */ + HttpSession endedSession() { + return endedSession; + } + /** * This request's {@code @RequestScope} beans, grown to hold at least * {@code count}. Called by generated code. @@ -682,6 +698,7 @@ void reset(Conn conn, String method, String target, String version, byte[] raw, this.sessionResolved = false; this.scopedBeans = null; this.sessions = null; + this.endedSession = null; } /** diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/backend/Scheduler.java index 8e15941e3a1..f16b18475db 100644 --- a/vm/backend/src/com/codename1/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -123,10 +123,26 @@ public String describeSchedule() { */ 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(); + this.tracer = backend.getTracer(); + } private boolean lockTableReady; /** @@ -351,7 +367,7 @@ private void runJob(final Job job) { try { if(job.lock == null || claim(job, start)) { ran = true; - Tracing.inBackground("scheduled " + job.name, null, new Tracing.Work() { + Tracing.inBackground("scheduled " + job.name, null, tracer, new Tracing.Work() { public Object run(Span span) throws Exception { job.body.run(); return null; diff --git a/vm/backend/src/com/codename1/backend/Tracing.java b/vm/backend/src/com/codename1/backend/Tracing.java index 01c2d841275..b2cfb75b60a 100644 --- a/vm/backend/src/com/codename1/backend/Tracing.java +++ b/vm/backend/src/com/codename1/backend/Tracing.java @@ -318,8 +318,19 @@ static Tracer active() { * 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); + } + + /** + * {@link #inBackground(String, Span, Work)} reporting to {@code 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 : tracer; + Tracer t = parent != null && parent.owner != null ? parent.owner + : own != null ? own : tracer; if(t == null || isSuppressed()) { return work.run(NOOP); } From b7ea87b80a12d44721e83a04511fafe7256390ba Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:29:59 +0300 Subject: [PATCH 10/48] Backend: clean up after Errors at start-up; tighten sessions, MCP, metrics - Start-up clean-up runs through finally blocks, so an Error from a bean (a failed static initializer, a missing class) closes the pool, stops the executors and listener, destroys what was built and rolls the tracer back. - Every session a request invalidates is finished, not only the last; a new session is not stored when no response can carry its cookie; the session cookie name must be an HTTP token. - @McpTool with @Async, and duplicate or empty tool/operation parameter names, are build errors; float arguments are range-checked. - A @WebSocketMapping path with a percent escape is a build error. - A refused shared gauge leaves no entry behind; counters refuse to overflow. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/developer-guide/Backend-MCP.asciidoc | 10 +- .../developer-guide/Backend-Sessions.asciidoc | 4 +- docs/developer-guide/Backend-Web.asciidoc | 4 +- .../backend/ApplicationRuntimeTest.java | 100 ++++++++++++ .../maven/processors/BackendBeans.java | 34 +++- .../maven/processors/BackendSources.java | 2 +- .../RestControllerAnnotationProcessor.java | 10 ++ .../maven/processors/BackendBeansTest.java | 31 ++++ .../src/com/codename1/backend/Backend.java | 146 ++++++++++-------- .../src/com/codename1/backend/HttpServer.java | 23 ++- .../src/com/codename1/backend/Sessions.java | 42 +++++ .../com/codename1/backend/mcp/McpArgs.java | 15 +- .../codename1/backend/metrics/Counter.java | 17 +- .../codename1/backend/metrics/Metrics.java | 5 +- 14 files changed, 362 insertions(+), 81 deletions(-) diff --git a/docs/developer-guide/Backend-MCP.asciidoc b/docs/developer-guide/Backend-MCP.asciidoc index 2613277bc4f..9ec213103ca 100644 --- a/docs/developer-guide/Backend-MCP.asciidoc +++ b/docs/developer-guide/Backend-MCP.asciidoc @@ -31,10 +31,14 @@ server serves itself has to be listed too. 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, an optional -`description` that goes into the schema, and `required`, true by default. A +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. +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 -- diff --git a/docs/developer-guide/Backend-Sessions.asciidoc b/docs/developer-guide/Backend-Sessions.asciidoc index 837f4be138b..950c744e8f5 100644 --- a/docs/developer-guide/Backend-Sessions.asciidoc +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -109,7 +109,9 @@ browser closes; how long the session lasts is the server's decision. | Key | What it sets | `cn1.session.cookie` -| The cookie's name, `CN1SESSION` by default. +| 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 diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index 47ada1a04ff..da878c78452 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -213,7 +213,9 @@ share per-connection state, which is an object. What it keeps from `@GetMapping` 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. +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. ==== A server that writes its own main diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index ef571de0ec4..597d81e90fc 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -237,6 +237,83 @@ public String toString() { } } + @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 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 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("an abandoned @Async call fails its Future instead of never finishing") void abandonedAsyncFails() throws Exception { @@ -679,6 +756,17 @@ public HttpServer.Response handle(HttpServer.Request request) 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(); @@ -737,6 +825,18 @@ public HttpServer.Response handle(HttpServer.Request request) 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(); } 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 index 9be0b253040..b4bf15cf454 100644 --- 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 @@ -1217,6 +1217,16 @@ private void collectTools(Bean bean) { ctx.error(cls, "@McpTool method " + where + " must be an instance method."); continue; } + if (m.getAnnotation(ASYNC) != null + || (cls.getClassAnnotation(ASYNC) != null && m.isPublic())) { + // 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; + } String name = t.getStringOrDefault("name", "").trim(); Tool tool = new Tool(m, name.length() > 0 ? name : m.getName(), t.getStringOrDefault("description", "")); @@ -1243,7 +1253,19 @@ private void collectTools(Bean bean) { ok = false; continue; } - tool.paramNames.add(p.getString("value")); + 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))); } @@ -1337,7 +1359,15 @@ private void collectManaged(Bean bean) { ok = false; } AnnotationValues named = parameterAnnotations(m, i).get(MCP_PARAM); - names.add(named != null ? named.getString("value") : "arg" + i); + 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())) { 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 index f32e420c281..2d6f8cf74e2 100644 --- 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 @@ -466,7 +466,7 @@ String convert(Type t, String map, String name, boolean required) { 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 "(float) " + helper + "doubleValue(" + args + ")"; + case Type.FLOAT: return helper + "floatValue(" + args + ")"; case Type.DOUBLE: return helper + "doubleValue(" + args + ")"; default: break; 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 fdd8545a067..1f1a7a4fbea 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 @@ -1102,6 +1102,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 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 index 4d143501198..85bc213b90a 100644 --- 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 @@ -1083,6 +1083,37 @@ public void aFieldDependencyIsInitializedBeforeItsUser() throws Exception { } } + @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 everyScopeAndBindingWorksAtRunTime() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 432fef8fa60..0ebd80cb050 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -830,13 +830,18 @@ 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 { started = startTraced(tracing); - } catch (Exception err) { - if(tracing) { + ok = true; + } finally { + if(!ok && tracing) { Tracing.rollBack(claim); } - throw err; } if(tracing) { // Owned: this server's tracer runs until this server stops it, @@ -857,45 +862,54 @@ private Backend startTraced(boolean tracing) throws Exception { private Backend startTracedOnce(boolean tracing) throws Exception { DataSource pool = openDataSource(); + boolean ok = false; try { - return startWith(pool, tracing); - } 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 dataSourceGiven 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(). - Tasks.shutdown(tasks, 0); - } - 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 (RuntimeException destroyErr) { - System.err.println("Destroying the application's beans failed: " - + destroyErr); - } + Backend started = startWith(pool, tracing); + ok = true; + return started; + } finally { + if(!ok) { + abandonStart(pool); } - if(pool != null && dataSource == null) { - pool.close(); + } + } + + /** 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 dataSourceGiven 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(). + Tasks.shutdown(tasks, 0); + } + 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); } - throw err; + } + if(pool != null && dataSource == null) { + pool.close(); } } @@ -1073,6 +1087,7 @@ public HttpServer.Response handle(HttpServer.Request request) { // exporter has opened below; until then, and on a server that turned // metrics off, its requests stay out of the process's histograms. final boolean[] instrumented = new boolean[1]; + boolean bound = false; final Application app = application; final boolean track = application != null && application.tracksCurrentRequest(); try { @@ -1129,25 +1144,28 @@ public HttpServer.Response handle(HttpServer.Request request) // client receives, and the request log // must say so rather than record the // handler's own status. - HttpSession ended = request.endedSession(); + List ended = request.endedSessions(); HttpSession session = request.resolvedSession(); if(ended != null || session != null) { sessionFinished = true; } - if(ended != null) { - // First, so its clearing cookie comes - // before the new session's. - response = sessions.finish(ended, response); + // First, so their clearing cookies come + // before the new session's. + for(int e = 0 ; ended != null && e < ended.size() ; e++) { + response = sessions.finish( + (HttpSession)ended.get(e), response); } if(session != null) { response = sessions.finish(session, response); } } catch (Exception err) { endRequestBeans(request); - HttpSession ended = request.endedSession(); - if(ended != null && !sessionFinished) { + List ended = request.endedSessions(); + for(int e = 0 ; ended != null && !sessionFinished + && e < ended.size() ; e++) { try { - sessions.finish(ended, null); + sessions.finish((HttpSession)ended.get(e), + null); } catch (Exception storeErr) { System.err.println("Could not end the " + "session of a failed request: " @@ -1218,11 +1236,11 @@ public void register(HttpServer.WebSocketRegistry registry) } } }); - } catch (Exception err) { - if(ownsContext) { + 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. @@ -1243,6 +1261,7 @@ public void register(HttpServer.WebSocketRegistry registry) // 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; try { if(metricReader != null) { measuring |= metricReader.open(config); @@ -1256,13 +1275,15 @@ public void register(HttpServer.WebSocketRegistry registry) metricReader != null && measuring ? metricReader : null, managedBeans, sessions, tasks, requestLog, environment == null ? new ArrayList() : environment.gauges); - } catch (Exception err) { - server.stop(0); - com.codename1.backend.metrics.Metrics.disableServer(server, pool); - if(metricReader != null) { - metricReader.shutdown(0); + owned = true; + } finally { + if(!owned) { + server.stop(0); + com.codename1.backend.metrics.Metrics.disableServer(server, pool); + if(metricReader != null) { + metricReader.shutdown(0); + } } - throw err; } backend.measured = measuring; // Backend.stop() tears the beans down from here on. @@ -1282,13 +1303,16 @@ public void register(HttpServer.WebSocketRegistry registry) } } if(application != null) { + boolean running = false; try { application.started(backend); - } catch (Exception err) { - // A job or exporter that cannot start is a server that is not - // what its build says it is; stop the one that is listening. - backend.stop(); - throw err; + running = true; + } finally { + if(!running) { + // A job or exporter that cannot start is a server that is + // not what its build says it is; stop the one listening. + backend.stop(); + } } } backend.markReady(); diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 3ab362baca1..d276616c2ba 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -124,8 +124,8 @@ public static final class Request { private boolean canonicalChecked; /** The sessions of the server serving this request; set by Backend. */ Sessions sessions; - /** See {@link #endedSession}. */ - private HttpSession endedSession; + /** See {@link #endedSessions}. */ + private java.util.List endedSessions; /** This request's session once looked up; see {@link #getSession(boolean)}. */ private HttpSession session; private boolean sessionResolved; @@ -589,7 +589,7 @@ void releaseRetained() { this.sessionResolved = false; this.scopedBeans = null; this.sessions = null; - this.endedSession = null; + this.endedSessions = null; } /** The session of this request, creating one if it has none. */ @@ -609,7 +609,10 @@ public HttpSession getSession(boolean create) { // 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. - endedSession = session; + if(endedSessions == null) { + endedSessions = new java.util.ArrayList(1); + } + endedSessions.add(session); session = null; } if(sessionResolved && (session != null || !create)) { @@ -638,9 +641,13 @@ HttpSession resolvedSession() { return session; } - /** A session this request invalidated and then replaced, or null. */ - HttpSession endedSession() { - return endedSession; + /** + * 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. + */ + java.util.List endedSessions() { + return endedSessions; } /** @@ -698,7 +705,7 @@ void reset(Conn conn, String method, String target, String version, byte[] raw, this.sessionResolved = false; this.scopedBeans = null; this.sessions = null; - this.endedSession = null; + this.endedSessions = null; } /** diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index fa9ff4f7155..ca41c3b4752 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -93,6 +93,14 @@ 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 @@ -156,6 +164,22 @@ static synchronized Sessions standalone() { return standalone; } + /** Whether {@code 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. */ public synchronized void setStore(SessionStore replacement) { if(replacement == null) { @@ -273,6 +297,24 @@ HttpServer.Response finish(HttpSession session, HttpServer.Response response) SessionStore s = getStore(); String cookie = null; String previous = session.previousId(); + 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 { + synchronized(this) { + Held moved = (Held)beans.remove(session.getId()); + if(moved != null) { + beans.put(previous, moved); + } + } + } + return null; + } if(!session.isValid()) { s.delete(session.getId()); // The beans end with the session, not with whatever next finds it gone. diff --git a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java index 12da8cfb93f..1bd16e2094d 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java @@ -187,7 +187,20 @@ public static Double doubleObject(Map args, String name, boolean required) { public static Float floatObject(Map args, String name, boolean required) { return raw(args, name, required) == null ? null - : new Float((float)doubleValue(args, name, required)); + : new Float(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"); + } + return (float)d; } public static Boolean booleanObject(Map args, String name, boolean required) { diff --git a/vm/backend/src/com/codename1/backend/metrics/Counter.java b/vm/backend/src/com/codename1/backend/metrics/Counter.java index d36e5561aa8..b0bcb616bba 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Counter.java +++ b/vm/backend/src/com/codename1/backend/metrics/Counter.java @@ -40,7 +40,7 @@ public final class Counter extends Instrument { /** Adds one. */ public void increment() { - total.incrementAndGet(); + add(1); } /** @@ -53,7 +53,20 @@ public void add(long amount) { throw new IllegalArgumentException("Counter " + getName() + " only goes up; use an up-down counter"); } - total.addAndGet(amount); + // 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() { diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index 28672d77f55..f432fc9540a 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -228,7 +228,9 @@ public static synchronized void addSource(String name, String description, Strin if(sources == null) { final List all = new ArrayList(); sources = all; - SHARED.put(name, 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() { public double read() { Object[] each; @@ -251,6 +253,7 @@ public double read() { return any ? sum : Double.NaN; } })); + SHARED.put(name, all); } sources.add(source); } From 941e52ed94d4c249997a5dfd32bf710b73e170dc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:32:31 +0300 Subject: [PATCH 11/48] Backend: guard post-start hooks, protect in-use session beans, scope rules - Every hook after the Backend exists (management, MCP attach, the application's started()) runs under one guard that stops the server if any fails. - Session beans are not expired while a request is using their session: each server counts requests per session id until the request ends. - Histograms keep a null first label apart from an empty one. - A @SessionScope bean cannot inject HttpSession (a per-request copy with the database store), and a websocket endpoint cannot inject request- or session-scoped beans; both are build errors. - @Scheduled, @McpTool and managed methods inherited from a superclass are registered. - The request-metrics switch is an AtomicBoolean, visible to running workers. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/developer-guide/Backend-Beans.asciidoc | 5 +- .../developer-guide/Backend-Sessions.asciidoc | 8 +- docs/developer-guide/Backend-Web.asciidoc | 3 + .../backend/ApplicationRuntimeTest.java | 33 +++++++ .../maven/processors/BackendBeans.java | 89 +++++++++++++++++-- .../maven/processors/BackendBeansTest.java | 47 ++++++++++ .../resources/skill/references/backend.md | 3 +- .../src/com/codename1/backend/Backend.java | 56 ++++++------ .../src/com/codename1/backend/HttpServer.java | 8 ++ .../src/com/codename1/backend/Sessions.java | 53 +++++++++-- .../codename1/backend/metrics/Histogram.java | 6 +- 11 files changed, 266 insertions(+), 45 deletions(-) diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc index 4bc76d50f96..bba8a7d5de2 100644 --- a/docs/developer-guide/Backend-Beans.asciidoc +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -104,7 +104,8 @@ A bean receives its dependencies in three places, all resolved by type: a constructor's. Injection points and lifecycle methods declared in a superclass count too, and are -handled before the subclass's own. A method the subclass overrides follows the +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 @@ -158,7 +159,7 @@ and they're injected the same way: | The current request. Only a `@RequestScope` bean can take it. | `HttpSession` -| The current session. Only a `@RequestScope` or `@SessionScope` bean can take it. +| The current session. Only a `@RequestScope` bean can take it. |=== === Configuration values diff --git a/docs/developer-guide/Backend-Sessions.asciidoc b/docs/developer-guide/Backend-Sessions.asciidoc index 950c744e8f5..f46a796f289 100644 --- a/docs/developer-guide/Backend-Sessions.asciidoc +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -191,9 +191,11 @@ that doesn't wait for one answer before sending the next -- and they reach the s 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 inject the `HttpSession` it belongs to. It can't inject -the request, because it outlives the request that built it, and the build refuses -that with a message saying so. +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 diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index da878c78452..a126fef5d33 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -216,6 +216,9 @@ injected the same way, and two endpoints claiming one path is a build error rath 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 can't inject a `@RequestScope` or `@SessionScope` bean either: its +callbacks run outside any HTTP request, so there's no request or session to find +the bean in. Keep per-connection state in the session's attachment instead. ==== A server that writes its own main diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 597d81e90fc..59af25b5450 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -314,6 +314,39 @@ public void stopped() { 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 { 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 index b4bf15cf454..5f1e36594ee 100644 --- 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 @@ -1127,9 +1127,56 @@ private void pickEntryPackage() { } /// 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(); + + 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; + } + private void collectJobs(Bean bean) { AnnotatedClass cls = bean.cls; - for (MethodInfo m : cls.getMethods()) { + for (MethodInfo m : inheritedMembers(cls)) { AnnotationValues s = m.getAnnotation(SCHEDULED); if (s == null) { continue; @@ -1207,7 +1254,7 @@ private void collectJobs(Bean bean) { private void collectTools(Bean bean) { AnnotatedClass cls = bean.cls; - for (MethodInfo m : cls.getMethods()) { + for (MethodInfo m : inheritedMembers(cls)) { AnnotationValues t = m.getAnnotation(MCP_TOOL); if (t == null) { continue; @@ -1291,7 +1338,7 @@ private void collectManaged(Bean bean) { AnnotatedClass cls = bean.cls; AnnotationValues resource = cls.getClassAnnotation(MANAGED_RESOURCE); boolean any = resource != null; - for (MethodInfo m : cls.getMethods()) { + for (MethodInfo m : inheritedMembers(cls)) { if (m.getAnnotation(MANAGED_ATTRIBUTE) != null || m.getAnnotation(MANAGED_OPERATION) != null) { if (resource == null) { @@ -1320,7 +1367,7 @@ private void collectManaged(Bean bean) { managed.objectName = objectName.length() > 0 ? objectName : RestClientAnnotationProcessor.simpleName(cls.getBinaryName().replace('$', '.')); managed.description = resource.getStringOrDefault("description", ""); - for (MethodInfo m : cls.getMethods()) { + for (MethodInfo m : inheritedMembers(cls)) { String where = cls.getSourceName() + "." + m.getName(); AnnotationValues attr = m.getAnnotation(MANAGED_ATTRIBUTE); if (attr != null) { @@ -1444,6 +1491,27 @@ private void resolve() { + b.scope + "-scoped; an endpoint serves many connections for the " + "life of the server, so it must be a singleton."); } + if (b.webSocket) { + // Its callbacks run outside the HTTP handler that makes a request + // current, so a scoped stand-in would throw on first use. + List all = new ArrayList(b.constructorPoints); + all.addAll(b.fields.values()); + for (Call c : b.setters) { + all.addAll(c.points); + } + for (Point p : all) { + for (Bean d : p.candidates) { + if (REQUEST.equals(d.scope) || SESSION.equals(d.scope)) { + ctx.error(b.cls, "Websocket endpoint " + b.describe() + " injects " + + d.describe() + ", which is " + d.scope + "-scoped. A " + + "websocket callback runs outside any HTTP request, so " + + "there is no " + d.scope + " to find it in; inject a " + + "singleton, and keep per-connection state in the " + + "WebSocketSession's attachment."); + } + } + } + } 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."); @@ -1517,9 +1585,16 @@ private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { + "the handler method instead, or scope the bean."); return; } - if (SESSION.equals(owner.scope) && REQUEST_TYPE.equals(type)) { - ctx.error(where, p.where + " asks for the request, but a session bean outlives " - + "the request that built it. Take the HttpSession instead."); + 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"; 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 index 85bc213b90a..2c201cf5b76 100644 --- 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 @@ -1114,6 +1114,53 @@ public void anEscapedWebSocketPathIsABuildError() throws Exception { .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 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(errors, errors.contains("A websocket callback runs outside any HTTP request")); + } + @Test public void everyScopeAndBindingWorksAtRunTime() throws Exception { Map s = new LinkedHashMap(); diff --git a/scripts/initializr/common/src/main/resources/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md index 3bf05e4e418..5ff35eff631 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/backend.md +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -89,7 +89,8 @@ public class NotesApi { Built-in injectables: `Config`, `DataSource`, `orm.EntityManager`, `com.codename1.orm.session.Session` (the current transaction's session), and — in a -request or session bean only — `HttpServer.Request` and `HttpSession`. +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. diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 0ebd80cb050..a1ef3155468 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -1086,7 +1086,10 @@ public HttpServer.Response handle(HttpServer.Request request) { // 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. - final boolean[] instrumented = new boolean[1]; + // 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(); @@ -1105,7 +1108,7 @@ private void endRequestBeans(HttpServer.Request request) { public HttpServer.Response handle(HttpServer.Request request) throws Exception { - long started = instrumented[0] + long started = instrumented.get() ? com.codename1.backend.metrics.Metrics.requestStarted() : 0L; Object previous = null; @@ -1217,6 +1220,7 @@ public HttpServer.Response handle(HttpServer.Request request) CURRENT_REQUEST.set(previous); } Tasks.leave(previousTasks); + sessions.leave(request); } } } @@ -1268,7 +1272,7 @@ public void register(HttpServer.WebSocketRegistry registry) } if(measuring) { com.codename1.backend.metrics.Metrics.enableServer(server, pool); - instrumented[0] = true; + instrumented.set(true); } backend = new Backend(server, pool, manager, config, drain, tracing ? tracer : null, application, @@ -1289,31 +1293,33 @@ public void register(HttpServer.WebSocketRegistry registry) // Backend.stop() tears the beans down from here on. createdApplication = null; startingTasks = null; - if(management != null) { - management.attach(backend); - } - if(mcpServer != null) { - mcpServer.attach(backend); - if(!quiet) { - // The line an agent's setup instructions point at. - System.out.println("cn1: MCP endpoint at http" - + (context != null ? "s" : "") + "://127.0.0.1:" + server.getPort() - + mcpServer.getPath() - + (mcpServer.hasDevTools() ? " (with development tools)" : "")); + // 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) { + management.attach(backend); } - } - if(application != null) { - boolean running = false; - try { - application.started(backend); - running = true; - } finally { - if(!running) { - // A job or exporter that cannot start is a server that is - // not what its build says it is; stop the one listening. - backend.stop(); + if(mcpServer != null) { + mcpServer.attach(backend); + if(!quiet) { + // The line an agent's setup instructions point at. + System.out.println("cn1: MCP endpoint at http" + + (context != null ? "s" : "") + "://127.0.0.1:" + + server.getPort() + mcpServer.getPath() + + (mcpServer.hasDevTools() ? " (with development tools)" : "")); } } + if(application != null) { + application.started(backend); + } + running = true; + } finally { + if(!running) { + backend.stop(); + } } backend.markReady(); if(!quiet) { diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index d276616c2ba..b124d60eb47 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -126,6 +126,8 @@ public static final class Request { Sessions sessions; /** See {@link #endedSessions}. */ private java.util.List endedSessions; + /** The id of the session this request counts as using; see Sessions.enter. */ + String sessionInUse; /** This request's session once looked up; see {@link #getSession(boolean)}. */ private HttpSession session; private boolean sessionResolved; @@ -590,6 +592,7 @@ void releaseRetained() { this.scopedBeans = null; this.sessions = null; this.endedSessions = null; + this.sessionInUse = null; } /** The session of this request, creating one if it has none. */ @@ -628,6 +631,10 @@ public HttpSession getSession(boolean create) { + err.getMessage()); } sessionResolved = true; + if(session != null && sessions != null) { + // Counted for a Backend's sessions only: it is what ends the count. + owner.enter(this, session); + } return session; } @@ -706,6 +713,7 @@ void reset(Conn conn, String method, String target, String version, byte[] raw, this.scopedBeans = null; this.sessions = null; this.endedSessions = null; + this.sessionInUse = null; } /** diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index ca41c3b4752..16ef2b13aa7 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -229,9 +229,9 @@ HttpSession find(String cookieValue, boolean create) throws IOException { found.touch(now); found.owner = this; synchronized(this) { - // The beans are in use from now, not from when this request - // ends: a purge by another request during a long one must - // not destroy what this one is still using. + // 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; @@ -260,11 +260,13 @@ private void purgeIfDue(SessionStore s, long now) { return; } lastPurge = now; - Iterator it = beans.values().iterator(); + Iterator it = beans.entrySet().iterator(); while(it.hasNext()) { - Held h = (Held)it.next(); + Map.Entry e = (Map.Entry)it.next(); + Held h = (Held)e.getValue(); if(h.maxInactiveSeconds > 0 - && now - h.lastAccessed > h.maxInactiveSeconds * 1000L) { + && now - h.lastAccessed > h.maxInactiveSeconds * 1000L + && !inUse.containsKey(e.getKey())) { it.remove(); if(expired == null) { expired = new ArrayList(); @@ -395,6 +397,45 @@ synchronized Object beanLock(HttpSession session) { return holderFor(session); } + /** + * Requests using each session right now, by session id: its beans are never + * expired while any is -- a request can outlast the inactivity timeout, and + * destroying its session's beans under it would hand it closed resources. + */ + private final Map inUse = new HashMap(); + + /** A request has resolved {@code session}; counted until {@link #leave}. */ + synchronized void enter(HttpServer.Request request, HttpSession session) { + if(request.sessionInUse != null) { + return; + } + String id = session.getId(); + int[] count = (int[])inUse.get(id); + if(count == null) { + count = new int[1]; + inUse.put(id, count); + } + count[0]++; + request.sessionInUse = id; + } + + /** The request is over; its session's beans may expire again. */ + synchronized void leave(HttpServer.Request request) { + String id = request.sessionInUse; + if(id == null) { + return; + } + request.sessionInUse = null; + int[] count = (int[])inUse.get(id); + if(count != null && --count[0] <= 0) { + inUse.remove(id); + } + Held held = (Held)beans.get(id); + if(held != null) { + held.lastAccessed = System.currentTimeMillis(); + } + } + private Held holderFor(HttpSession session) { String id = session.getId(); Held held = (Held)beans.get(id); diff --git a/vm/backend/src/com/codename1/backend/metrics/Histogram.java b/vm/backend/src/com/codename1/backend/metrics/Histogram.java index 685525737fd..26dcc4ad5b1 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Histogram.java +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -63,6 +63,8 @@ public static double[] defaultBounds() { 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; @@ -114,7 +116,9 @@ public void record(double value, Object first, Object second, Object third) { } private Series find(Object first, Object second, Object third) { - Object key = first == null ? "" : first; + // 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. + Object key = first == null ? NO_VALUE : first; List list = (List)byFirst.get(key); if(list != null) { for(int iter = 0 ; iter < list.size() ; iter++) { From ec07ddb4094299378a7da6d6581b1eb1d178dea8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:37:53 +0300 Subject: [PATCH 12/48] Backend: test that expiry spares session beans a request is using The purge is package-private so a test can run it at a chosen time instead of waiting for the next once-a-minute lookup; the test fails with the in-use check removed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 25 +++++++++++++++++++ .../src/com/codename1/backend/Sessions.java | 6 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 59af25b5450..c502b140038 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -507,6 +507,31 @@ void jdbcSessionStore(@org.junit.jupiter.api.io.TempDir java.io.File dir) throws } } + @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); + sessions.enter(request, session); + // Two minutes on -- long past the timeout and the purge interval -- and + // the request is still running: its beans must survive. + sessions.purgeIfDue(sessions.getStore(), now + 120000); + assertTrue(ended.isEmpty(), "a running request's session beans were destroyed"); + sessions.leave(request); + sessions.purgeIfDue(sessions.getStore(), now + 240000); + assertEquals(1, ended.size(), "the idle session's beans were never destroyed"); + } + @Test @DisplayName("every loaded copy of a session shares one set of session-scoped beans") void sessionBeansAreSharedAcrossCopies() { diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 16ef2b13aa7..5813d1f71ca 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -253,7 +253,11 @@ HttpSession find(String cookieValue, boolean create) throws IOException { return created; } - private void purgeIfDue(SessionStore s, long now) { + /** + * Expires what is due, at most once a minute. Package-private so a test can + * run it at a chosen {@code now} instead of waiting for the next lookup. + */ + void purgeIfDue(SessionStore s, long now) { List expired = null; synchronized(this) { if(now - lastPurge < PURGE_INTERVAL) { From 3a382d195ce4dc40a7db64329b265a42066e9039 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:09:58 +0300 Subject: [PATCH 13/48] Backend: database-time locks, atomic session rotation, per-server callbacks - Scheduler lock leases use the database server's clock (PostgreSQL, MySQL), so a replica running ahead cannot take a held lock. - A JDBC session rotation is one transaction that locks the old row and is refused when it is gone, so a stale copy cannot undo a logout. - Session in-use counts follow the HttpSession object, covering replaced and rotated sessions; a bare HttpServer refuses sessions it could never store. - WebSocket callbacks carry their own server's executors. - as_int encodes as sfixed64, so a negative up-down counter exports. - Virtual-task submission rechecks, under the inbox lock, that shutdown has not already drained the host. - Unknown cron zones are refused; an Error from a lifecycle hook no longer abandons shutdown; a second metrics exporter with another identity is refused, since metrics are per process. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Backend-Observability.asciidoc | 5 +- .../Backend-Scheduling.asciidoc | 10 +- .../developer-guide/Backend-Sessions.asciidoc | 5 +- .../backend/ApplicationRuntimeTest.java | 92 ++++++++++++ .../backend/otel/OtlpMetricsProtoTest.java | 36 +++++ .../src/com/codename1/backend/Backend.java | 118 ++++++++++++++- .../com/codename1/backend/CronSchedule.java | 32 +++++ .../src/com/codename1/backend/HttpServer.java | 32 ++++- .../src/com/codename1/backend/Scheduler.java | 28 +++- .../src/com/codename1/backend/Sessions.java | 135 +++++++++++++----- .../backend/otel/OtlpMetricExporter.java | 26 ++++ .../codename1/backend/otel/OtlpSchema.java | 15 +- 12 files changed, 482 insertions(+), 52 deletions(-) diff --git a/docs/developer-guide/Backend-Observability.asciidoc b/docs/developer-guide/Backend-Observability.asciidoc index 5766750de33..75cf9016e3d 100644 --- a/docs/developer-guide/Backend-Observability.asciidoc +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -120,7 +120,10 @@ 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 +traces on. The metrics are the process's, so with several servers in one process +their exporters must report as the same service to the same collector; a second +exporter that would report the same numbers under another name is refused at +start-up. The management endpoints described below record and serve the same metrics whether anything exports them or not. ==== What the server records diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc index ea3cdb8bf70..8714bdbb214 100644 --- a/docs/developer-guide/Backend-Scheduling.asciidoc +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -191,7 +191,10 @@ 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. +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 @@ -236,7 +239,10 @@ Before each run the instance tries to claim the named row in a table called 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. A job with a lock needs a database, which the build checks. +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 diff --git a/docs/developer-guide/Backend-Sessions.asciidoc b/docs/developer-guide/Backend-Sessions.asciidoc index f46a796f289..69175a6308d 100644 --- a/docs/developer-guide/Backend-Sessions.asciidoc +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -23,7 +23,10 @@ include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/b `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. +bearer tokens, say -- pays nothing for them and sets no cookie. Sessions need a +server started through `Backend.builder()`, which stores them and sends their +cookie; on a bare `HttpServer`, `getSession` throws rather than hand out a +session that would never be kept. 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 diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index c502b140038..70ecff92325 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -486,6 +486,16 @@ void jdbcSessionStore(@org.junit.jupiter.api.io.TempDir java.io.File dir) throws 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")); @@ -530,6 +540,88 @@ public void sessionEnded(Object[] beans) { 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("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("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 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 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 index 2f462054b34..8343c9b0642 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -89,6 +89,42 @@ void countersAreIntegral() throws Exception { "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("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("an exporter opened again after a shutdown exports periodically again") void reopenedExporterKeepsExporting() throws Exception { diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index a1ef3155468..359534deabb 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -276,7 +276,7 @@ private void stopOnce() { if(application != null) { try { application.stopping(); - } catch (RuntimeException err) { + } catch (Throwable err) { System.err.println("Stopping the application failed: " + err); } } @@ -300,7 +300,7 @@ private void stopOnce() { if(application != null) { try { application.stopped(); - } catch (RuntimeException err) { + } catch (Throwable err) { System.err.println("Destroying the application's beans failed: " + err); } } @@ -439,6 +439,114 @@ public static HttpServer.Request currentRequest() { return (HttpServer.Request)CURRENT_REQUEST.get(); } + /** + * {@code registry}, with every endpoint it is handed wrapped so its callbacks + * run carrying {@code 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 new HttpServer.WebSocketRegistry() { + public void route(String path, WebSocket endpoint) { + registry.route(path, endpoint == null ? null : new TaskBound(endpoint, tasks)); + } + + public void fallback(final HttpServer.WebSocketHandler router) { + registry.fallback(router == null ? null : new HttpServer.WebSocketHandler() { + public WebSocket open(HttpServer.Request request) throws Exception { + Object previous = Tasks.enter(tasks); + try { + WebSocket endpoint = router.open(request); + return endpoint == null ? null : new TaskBound(endpoint, tasks); + } finally { + Tasks.leave(previous); + } + } + }); + } + }; + } + + /** An endpoint whose every callback runs carrying its server's executors. */ + static final class TaskBound implements WebSocket { + private final WebSocket endpoint; + private final Tasks.Registry tasks; + + TaskBound(WebSocket endpoint, Tasks.Registry tasks) { + this.endpoint = endpoint; + this.tasks = tasks; + } + + public void onOpen(WebSocketSession session) throws Exception { + Object previous = Tasks.enter(tasks); + try { + endpoint.onOpen(session); + } finally { + Tasks.leave(previous); + } + } + + public void onText(WebSocketSession session, String message) throws Exception { + Object previous = Tasks.enter(tasks); + try { + endpoint.onText(session, message); + } finally { + Tasks.leave(previous); + } + } + + public void onBinary(WebSocketSession session, byte[] message, int offset, int length) + throws Exception { + Object previous = Tasks.enter(tasks); + try { + endpoint.onBinary(session, message, offset, length); + } finally { + Tasks.leave(previous); + } + } + + public void onPing(WebSocketSession session, byte[] payload, int offset, int length) + throws Exception { + Object previous = Tasks.enter(tasks); + try { + endpoint.onPing(session, payload, offset, length); + } finally { + Tasks.leave(previous); + } + } + + public void onPong(WebSocketSession session, byte[] payload, int offset, int length) + throws Exception { + Object previous = Tasks.enter(tasks); + try { + endpoint.onPong(session, payload, offset, length); + } finally { + Tasks.leave(previous); + } + } + + public void onClose(WebSocketSession session, int code, String reason) + throws Exception { + Object previous = Tasks.enter(tasks); + try { + endpoint.onClose(session, code, reason); + } finally { + Tasks.leave(previous); + } + } + + public void onError(WebSocketSession session, Exception error) { + Object previous = Tasks.enter(tasks); + try { + endpoint.onError(session, error); + } finally { + Tasks.leave(previous); + } + } + } + /** What an {@link Application} is built from. */ public static final class Environment { private final Config config; @@ -1226,8 +1334,12 @@ public HttpServer.Response handle(HttpServer.Request request) } }, context, webSocketEndpoints == null && application == null ? null : new HttpServer.WebSocketRoutes() { - public void register(HttpServer.WebSocketRegistry registry) + 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); // The same two arguments a Handlers factory gets, // and for the same reason: an endpoint that needs // the database declares it rather than reaching diff --git a/vm/backend/src/com/codename1/backend/CronSchedule.java b/vm/backend/src/com/codename1/backend/CronSchedule.java index 21de2ae14f3..a3afe1cc5b6 100644 --- a/vm/backend/src/com/codename1/backend/CronSchedule.java +++ b/vm/backend/src/com/codename1/backend/CronSchedule.java @@ -104,6 +104,14 @@ public CronSchedule(long seconds, long minutes, long hours, long daysOfMonth, } 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)) { @@ -111,6 +119,30 @@ public CronSchedule(long seconds, long minutes, long hours, long daysOfMonth, } } + /** + * Whether {@code 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; diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index b124d60eb47..94be2569cbe 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -126,8 +126,8 @@ public static final class Request { Sessions sessions; /** See {@link #endedSessions}. */ private java.util.List endedSessions; - /** The id of the session this request counts as using; see Sessions.enter. */ - String sessionInUse; + /** The sessions this request counts as using; see Sessions.enter. */ + java.util.List sessionsInUse; /** This request's session once looked up; see {@link #getSession(boolean)}. */ private HttpSession session; private boolean sessionResolved; @@ -592,7 +592,7 @@ void releaseRetained() { this.scopedBeans = null; this.sessions = null; this.endedSessions = null; - this.sessionInUse = null; + this.sessionsInUse = null; } /** The session of this request, creating one if it has none. */ @@ -621,7 +621,14 @@ public HttpSession getSession(boolean create) { if(sessionResolved && (session != null || !create)) { return session; } - Sessions owner = sessions != null ? sessions : Sessions.standalone(); + 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; try { session = owner.find(sessionResolved ? null : Sessions.cookieValue(getHeader("cookie"), owner.getCookieName()), @@ -631,8 +638,7 @@ public HttpSession getSession(boolean create) { + err.getMessage()); } sessionResolved = true; - if(session != null && sessions != null) { - // Counted for a Backend's sessions only: it is what ends the count. + if(session != null) { owner.enter(this, session); } return session; @@ -713,7 +719,7 @@ void reset(Conn conn, String method, String target, String version, byte[] raw, this.scopedBeans = null; this.sessions = null; this.endedSessions = null; - this.sessionInUse = null; + this.sessionsInUse = null; } /** @@ -2722,6 +2728,14 @@ static boolean submitVirtualTask(Runnable task, HttpServer server) { 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(new Long(token)); } if(host.wakeWrite >= 0) { @@ -2739,6 +2753,7 @@ private static void releaseTaskInbox(VtHost host) { synchronized(host.inbox) { tokens = (Long[])host.inbox.toArray(new Long[host.inbox.size()]); host.inbox.clear(); + host.inboxClosed = true; } for(int iter = 0 ; iter < tokens.length ; iter++) { Runnable task = takeVirtualTask(tokens[iter].longValue()); @@ -2892,6 +2907,9 @@ boolean ringEmpty() { /** 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. diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/backend/Scheduler.java index f16b18475db..81535c84190 100644 --- a/vm/backend/src/com/codename1/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -407,8 +407,12 @@ public Object run(Span span) throws Exception { } } - private boolean claim(Job job, long now) throws IOException { + private boolean claim(Job job, long localNow) throws IOException { prepareLockTable(); + // 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 = now + job.lockAtMostFor; int updated = locks.execute("UPDATE " + LOCK_TABLE + " SET lock_until = ?, locked_at = ?, " + "locked_by = ? WHERE name = ? AND lock_until <= ?", @@ -443,7 +447,7 @@ private boolean claim(Job job, long now) throws IOException { private void release(Job job) { try { locks.execute("UPDATE " + LOCK_TABLE + " SET lock_until = ? WHERE name = ? AND " - + "locked_by = ?", new Object[] {new Long(System.currentTimeMillis()), + + "locked_by = ?", new Object[] {new Long(databaseNow()), job.lock, instance}); } catch (IOException err) { // The claim expires by itself; the only cost is that the next run on @@ -452,6 +456,26 @@ private void release(Job job) { } } + /** 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; diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 5813d1f71ca..fcdd78d5ffc 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -151,19 +151,6 @@ public static Sessions configure(Config config, boolean tls, DataSource pool, return out; } - private static Sessions standalone; - - /** - * The sessions of a bare HttpServer that no Backend started, which has no - * per-server settings to read. A Backend always hands its requests its own. - */ - static synchronized Sessions standalone() { - if(standalone == null) { - standalone = new Sessions(); - } - return standalone; - } - /** Whether {@code name} is an RFC 6265 cookie-name: an HTTP token. */ static boolean isToken(String name) { if(name == null || name.length() == 0) { @@ -264,13 +251,14 @@ void purgeIfDue(SessionStore s, long now) { return; } lastPurge = now; + java.util.Set busy = idsInUse(); Iterator it = beans.entrySet().iterator(); while(it.hasNext()) { Map.Entry e = (Map.Entry)it.next(); Held h = (Held)e.getValue(); if(h.maxInactiveSeconds > 0 && now - h.lastAccessed > h.maxInactiveSeconds * 1000L - && !inUse.containsKey(e.getKey())) { + && !busy.contains(e.getKey())) { it.remove(); if(expired == null) { expired = new ArrayList(); @@ -402,42 +390,61 @@ synchronized Object beanLock(HttpSession session) { } /** - * Requests using each session right now, by session id: its beans are never - * expired while any is -- a request can outlast the inactivity timeout, and - * destroying its session's beans under it would hand it closed resources. + * 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(); /** A request has resolved {@code session}; counted until {@link #leave}. */ synchronized void enter(HttpServer.Request request, HttpSession session) { - if(request.sessionInUse != null) { + if(request.sessionsInUse == null) { + request.sessionsInUse = new ArrayList(1); + } else if(request.sessionsInUse.contains(session)) { return; } - String id = session.getId(); - int[] count = (int[])inUse.get(id); + request.sessionsInUse.add(session); + int[] count = (int[])inUse.get(session); if(count == null) { count = new int[1]; - inUse.put(id, count); + inUse.put(session, count); } count[0]++; - request.sessionInUse = id; } - /** The request is over; its session's beans may expire again. */ + /** The request is over; the sessions it used may expire again. */ synchronized void leave(HttpServer.Request request) { - String id = request.sessionInUse; - if(id == null) { + List used = request.sessionsInUse; + if(used == null) { return; } - request.sessionInUse = null; - int[] count = (int[])inUse.get(id); - if(count != null && --count[0] <= 0) { - inUse.remove(id); + request.sessionsInUse = null; + long now = System.currentTimeMillis(); + for(int iter = 0 ; iter < used.size() ; iter++) { + HttpSession session = (HttpSession)used.get(iter); + int[] count = (int[])inUse.get(session); + if(count != null && --count[0] <= 0) { + inUse.remove(session); + } + Held held = (Held)beans.get(session.getId()); + if(held != null) { + held.lastAccessed = now; + } } - Held held = (Held)beans.get(id); - if(held != null) { - held.lastAccessed = System.currentTimeMillis(); + } + + /** 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()) { + ids.add(((HttpSession)it.next()).getId()); } + return ids; } private Held holderFor(HttpSession session) { @@ -685,7 +692,11 @@ private static long number(Object value) { */ public void save(HttpSession session, String previousId) throws IOException { prepare(); - if(session.isNew() || previousId != null) { + 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) VALUES (?, ?, ?, ?, ?, 0)", @@ -745,6 +756,64 @@ public void save(HttpSession session, String previousId) throws IOException { + "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(); + try { + pool.inTransaction(new DataSource.Work() { + public Object run(Database db) throws Exception { + Map row = db.queryOne("SELECT created, max_inactive, attributes FROM " + + TABLE + " WHERE id = ?" + lock, new Object[] {previousId}); + if(row == null) { + return null; // invalidated meanwhile: stays gone + } + Map merged = new LinkedHashMap(); + Object text = row.get("attributes"); + if(text instanceof String && ((String)text).length() > 0) { + merged.putAll(Json.parseObject((String)text)); + } + java.util.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) VALUES (?, ?, ?, ?, ?, 0)", + new Object[] {session.getId(), new Long(number(row.get("created"))), + new Long(session.getLastAccessedTime()), + new Integer(maxInactive), Json.write(merged)}); + db.execute("DELETE FROM " + TABLE + " WHERE id = ?", + new Object[] {previousId}); + return null; + } + }); + } catch (IOException err) { + throw err; + } catch (Exception err) { + throw new IOException("Could not rotate the session: " + err.getMessage()); + } + session.storedAccessed = session.getLastAccessedTime(); + } + 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 diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index f1c33cb6eb5..36adc278131 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -77,6 +77,8 @@ public final class OtlpMetricExporter implements MetricReader { * 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; private String lastError; @@ -115,6 +117,27 @@ public boolean open(Config config) throws IOException { OtlpTracer.parseHeaders(own != null ? own : config.get(OtlpTracer.HEADERS), headers); intervalMillis = OtlpTracer.positive(config, INTERVAL, 60000); resource = OtlpTracer.resource(config, defaultServiceName); + synchronized(OPEN) { + // 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. + for(int iter = 0 ; iter < OPEN.size() ; iter++) { + OtlpMetricExporter other = (OtlpMetricExporter)OPEN.get(iter); + if(other != this && (!other.resource.equals(resource) + || !other.endpoint.equals(endpoint))) { + 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(!OPEN.contains(this)) { + OPEN.add(this); + } + } final Run mine = new Run(); synchronized(lock) { run = mine; @@ -194,6 +217,9 @@ public boolean export() { } public void shutdown(int timeoutMillis) { + synchronized(OPEN) { + OPEN.remove(this); + } Thread exporter; synchronized(lock) { if(run == null || run.stopping) { diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java index 6eeea7dbf67..8eec9509579 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java @@ -67,6 +67,8 @@ final class OtlpSchema { 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 { @@ -233,9 +235,10 @@ private static Field rep(String name, int number) { Message numberPoint = new Message(new Field[] { attributes(7), f("startTimeUnixNano", 2, FIXED64), f("timeUnixNano", 3, FIXED64), - // as_int is sfixed64: the same eight little-endian bytes FIXED64 - // writes for a long, negative values included. - f("asDouble", 4, DOUBLE), f("asInt", 6, FIXED64), f("flags", 8, VARINT) + // 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 @@ -515,6 +518,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 From 42ae64fb938aa0be0d34fb120205670f04c85dfc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:54:43 +0300 Subject: [PATCH 14/48] Backend: spare in-use sessions in the store, finish sessions on Errors - The store purge (memory and JDBC) skips sessions a running request uses. - A handler that throws an Error still has its invalidated and changed sessions stored. - @Async on a @SessionScope bean is a build error, as on a request bean. - Task queue-depth points are summed per executor name; histogram label keys that collide in Prometheus, or are "le", are refused. - The management endpoint's backend and the request-log switch are volatile, so running workers see them. - Managed-resource names must be one URL segment; duplicate programmatic job names are refused; the stdio bridge answers under the top-level id. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Backend-Observability.asciidoc | 3 +- .../Backend-Scheduling.asciidoc | 6 +- .../backend/ApplicationRuntimeTest.java | 74 +++++++++++++++- .../codename1/backend/mcp/DevToolsTest.java | 15 ++++ .../maven/processors/BackendBeans.java | 23 +++-- .../maven/processors/BackendBeansTest.java | 9 ++ .../codename1/backend/mcp/StdioBridge.java | 29 ++++-- .../src/com/codename1/backend/Backend.java | 88 +++++++++++++------ .../src/com/codename1/backend/Management.java | 6 +- .../src/com/codename1/backend/RequestLog.java | 6 +- .../src/com/codename1/backend/Scheduler.java | 9 ++ .../src/com/codename1/backend/Sessions.java | 55 ++++++++++-- .../codename1/backend/metrics/Histogram.java | 16 +++- .../codename1/backend/metrics/Metrics.java | 16 +++- 14 files changed, 293 insertions(+), 62 deletions(-) diff --git a/docs/developer-guide/Backend-Observability.asciidoc b/docs/developer-guide/Backend-Observability.asciidoc index 75cf9016e3d..84aaaa25d27 100644 --- a/docs/developer-guide/Backend-Observability.asciidoc +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -191,7 +191,8 @@ include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/b ---- `@ManagedResource` marks the bean, and `objectName` names it; without one it's -the simple class name. Each `@ManagedAttribute` is an instance getter with no +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 diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc index 8714bdbb214..5022b673718 100644 --- a/docs/developer-guide/Backend-Scheduling.asciidoc +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -48,9 +48,9 @@ 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. -Two combinations are build errors. `@Async` on a `@RequestScope` bean would hand -the task an instance that's destroyed when the request ends, possibly before the -task runs. `@Async` on a `@Scheduled` method would end the run as soon as the work +Two combinations are build errors. `@Async` on a `@RequestScope` or +`@SessionScope` bean would hand the task an instance that's destroyed when its +request or session ends, possibly before the task runs. `@Async` on a `@Scheduled` method 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. diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 70ecff92325..ee0a06c8a75 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -532,11 +532,16 @@ public void sessionEnded(Object[] beans) { 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 must survive. + // 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"); @@ -557,6 +562,73 @@ public void sessionEnded(Object[] beans) { 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); + try { + // An Error drops the connection rather than answering 500. + out.getResponseCode(); + } catch (IOException dropped) { + // expected either way + } + 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() { 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 index 2b1bb1bba21..c675819709e 100644 --- a/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java @@ -43,4 +43,19 @@ void pragmaAllowList() { 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"); + } } 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 index 5f1e36594ee..b7209aef3be 100644 --- 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 @@ -538,17 +538,19 @@ private Bean classBean(AnnotatedClass cls) { if (props != null) { bindProperties(bean, props, cls); } - if (REQUEST.equals(bean.scope)) { + if (REQUEST.equals(bean.scope) || SESSION.equals(bean.scope)) { for (MethodInfo m : cls.getMethods()) { if (m.getAnnotation(ASYNC) != null || (cls.getClassAnnotation(ASYNC) != null && m.isPublic() && !m.isConstructor() && !m.isStatic())) { - // The task would hold the request's instance after the request - // ended and destroyed it. + // The task would hold the scoped instance after its request or + // session ended and destroyed it. + String scope = REQUEST.equals(bean.scope) ? "request" : "session"; ctx.error(cls, "@Async method " + cls.getSourceName() + "." + m.getName() - + " is on a @RequestScope bean, which is destroyed when its " - + "request ends -- possibly before the task runs. Move the " - + "method to a singleton and pass it what it needs."); + + " 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; } } @@ -1367,6 +1369,15 @@ private void collectManaged(Bean bean) { 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); 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 index 2c201cf5b76..ad52e07b646 100644 --- 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 @@ -1045,9 +1045,18 @@ public void asyncOnARequestBeanOrAScheduledMethodIsABuildError() throws Exceptio 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()); assertTrue(errors, errors.contains("is on a @RequestScope bean")); + assertTrue(errors, errors.contains("is on a @SessionScope bean")); + assertTrue(errors, errors.contains("is a segment of the management URL")); assertTrue(errors, errors.contains("is also @Async")); } diff --git a/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java b/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java index 04542a806c2..062235fa549 100644 --- a/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java +++ b/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java @@ -69,7 +69,8 @@ public static void main(String[] args) throws Exception { } } - private static String post(URL url, String token, String body) { + /** Forwards one line; package-private for the test of the unreachable path. */ + static String post(URL url, String token, String body) { try { HttpURLConnection c = (HttpURLConnection)url.openConnection(); c.setRequestMethod("POST"); @@ -94,15 +95,27 @@ private static String post(URL url, String token, String body) { return new String(buffer.toByteArray(), StandardCharsets.UTF_8); } catch (Exception err) { // The backend is not up: answer the host rather than hang it, under - // the id it asked with -- and not at all for a notification. - java.util.regex.Matcher id = java.util.regex.Pattern - .compile("\"id\"\\s*:\\s*(\"[^\"]*\"|-?[0-9]+)").matcher(body); - if(!id.find()) { + // the id it asked with -- and not at all for a notification. The + // TOP-LEVEL id, parsed: a pattern took the first "id" anywhere, so a + // tool argument named id got the answer and the host waited forever. + Object request; + try { + request = com.codename1.backend.Json.parse(body); + } catch (Exception parseErr) { return null; } - return "{\"jsonrpc\":\"2.0\",\"id\":" + id.group(1) + ",\"error\":{\"code\":-32000," - + "\"message\":\"backend unreachable: " - + String.valueOf(err.getMessage()).replace('"', '\'') + "\"}}"; + if(!(request instanceof java.util.Map) + || !((java.util.Map)request).containsKey("id")) { + return null; + } + java.util.Map error = new java.util.LinkedHashMap(); + error.put("code", Integer.valueOf(-32000)); + error.put("message", "backend unreachable: " + err.getMessage()); + java.util.Map response = new java.util.LinkedHashMap(); + response.put("jsonrpc", "2.0"); + response.put("id", ((java.util.Map)request).get("id")); + response.put("error", error); + return com.codename1.backend.Json.write(response); } } } diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 359534deabb..f7bd0be76dd 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -597,6 +597,20 @@ public void registerGauge(String name, String description, String unit, /** 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(int iter = 0 ; iter < managed.size() ; iter++) { if(((ManagedBean)managed.get(iter)).getObjectName() .equals(bean.getObjectName())) { @@ -1204,6 +1218,44 @@ public HttpServer.Response handle(HttpServer.Request request) { try { server = HttpServer.start(host, listenPort, listenBacklog, workerCount, new HttpServer.Handler() { + /** + * What a request that threw still owes: its request beans + * destroyed, and the sessions it ended or changed stored. + */ + private void failed(HttpServer.Request request, + boolean sessionFinished, long startedMillis, + Throwable err) { + endRequestBeans(request); + List ended = request.endedSessions(); + for(int e = 0 ; ended != null && !sessionFinished + && e < ended.size() ; e++) { + try { + sessions.finish((HttpSession)ended.get(e), + null); + } catch (Exception storeErr) { + System.err.println("Could not end the " + + "session of a failed request: " + + storeErr); + } + } + HttpSession session = request.resolvedSession(); + if(session != null && !sessionFinished) { + // 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(session, null); + } catch (Exception storeErr) { + System.err.println("Could not store the " + + "session of a failed request: " + + storeErr); + } + } + requestLog.record(request, 500, startedMillis, err); + } + /** Destroys the request's scoped beans, once. */ private void endRequestBeans(HttpServer.Request request) { if(app != null) { @@ -1270,35 +1322,13 @@ public HttpServer.Response handle(HttpServer.Request request) response = sessions.finish(session, response); } } catch (Exception err) { - endRequestBeans(request); - List ended = request.endedSessions(); - for(int e = 0 ; ended != null && !sessionFinished - && e < ended.size() ; e++) { - try { - sessions.finish((HttpSession)ended.get(e), - null); - } catch (Exception storeErr) { - System.err.println("Could not end the " - + "session of a failed request: " - + storeErr); - } - } - HttpSession session = request.resolvedSession(); - if(session != null && !sessionFinished) { - // 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(session, null); - } catch (Exception storeErr) { - System.err.println("Could not store the " - + "session of a failed request: " - + storeErr); - } - } - requestLog.record(request, 500, startedMillis, err); + failed(request, sessionFinished, startedMillis, err); + throw err; + } catch (Error err) { + // The same clean-up: an invalidated session + // must still be deleted, or the client's old + // cookie keeps its signed-in state. + failed(request, sessionFinished, startedMillis, err); throw err; } // Null is a 404 from here, which is what a diff --git a/vm/backend/src/com/codename1/backend/Management.java b/vm/backend/src/com/codename1/backend/Management.java index 1b94c309008..07906288419 100644 --- a/vm/backend/src/com/codename1/backend/Management.java +++ b/vm/backend/src/com/codename1/backend/Management.java @@ -58,7 +58,11 @@ public final class Management implements HttpServer.Handler { private final String path; private final byte[] token; private final boolean development; - private Backend backend; + /** + * 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; private Management(String path, String token, boolean development) { this.path = path; diff --git a/vm/backend/src/com/codename1/backend/RequestLog.java b/vm/backend/src/com/codename1/backend/RequestLog.java index c0103dbc346..6ec1512cf89 100644 --- a/vm/backend/src/com/codename1/backend/RequestLog.java +++ b/vm/backend/src/com/codename1/backend/RequestLog.java @@ -38,10 +38,10 @@ 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. Plain, not volatile: a request that misses the switch by - * a moment is simply not logged. + * and exceptions. Volatile: the development tools switch it on after the + * workers are running, and a plain write need never become visible to them. */ - boolean enabled; + volatile boolean enabled; private Map[] ring = new Map[0]; private int next; private int size; diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/backend/Scheduler.java index 81535c84190..6abde5b34ea 100644 --- a/vm/backend/src/com/codename1/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -212,6 +212,15 @@ private void add(Job job) { throw new IllegalStateException("Scheduled job " + job.name + " takes lock \"" + job.lock + "\", which needs a database, and this server has none"); } + for(int iter = 0 ; iter < jobs.size() ; iter++) { + if(((Job)jobs.get(iter)).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); } diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index fcdd78d5ffc..c2e9b8f9e80 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -246,12 +246,13 @@ HttpSession find(String cookieValue, boolean create) throws IOException { */ void purgeIfDue(SessionStore s, long now) { List expired = null; + java.util.Set busy; synchronized(this) { if(now - lastPurge < PURGE_INTERVAL) { return; } lastPurge = now; - java.util.Set busy = idsInUse(); + busy = idsInUse(); Iterator it = beans.entrySet().iterator(); while(it.hasNext()) { Map.Entry e = (Map.Entry)it.next(); @@ -273,7 +274,18 @@ void purgeIfDue(SessionStore s, long now) { } } try { - s.purgeExpired(now); + // 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 Jdbc) { + ((Jdbc)s).purgeExpired(now, busy); + } else { + s.purgeExpired(now); + } } catch (IOException err) { System.err.println("Could not purge expired sessions: " + err.getMessage()); } @@ -606,11 +618,20 @@ public synchronized void delete(String id) { sessions.remove(id); } - public synchronized int purgeExpired(long now) { + public int purgeExpired(long now) { + return purgeExpired(now, java.util.Collections.EMPTY_SET); + } + + /** {@link #purgeExpired(long)}, sparing the sessions whose ids are in {@code busy}. */ + synchronized int purgeExpired(long now, java.util.Set busy) { int purged = 0; - Iterator it = sessions.values().iterator(); + Iterator it = sessions.entrySet().iterator(); while(it.hasNext()) { - HttpSession s = (HttpSession)it.next(); + 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++; @@ -848,10 +869,30 @@ public void delete(String id) throws IOException { } public int purgeExpired(long now) throws IOException { + return purgeExpired(now, java.util.Collections.EMPTY_SET); + } + + /** {@link #purgeExpired(long)}, sparing the sessions whose ids are in {@code 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 = new Long(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); + 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 @@ -861,8 +902,8 @@ public int purgeExpired(long now) throws IOException { // 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 + " < ?)", - new Object[] {at, at}); + + "last_accessed + max_inactive * 1000.0 + " + TOUCH_INTERVAL + " < ?)" + + spare.toString(), params.toArray()); } public int size() { diff --git a/vm/backend/src/com/codename1/backend/metrics/Histogram.java b/vm/backend/src/com/codename1/backend/metrics/Histogram.java index 26dcc4ad5b1..f4756b8b97c 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Histogram.java +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -83,12 +83,26 @@ public static double[] defaultBounds() { } } 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 && this.labels[iter].length() == 0) { + 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.promName(this.labels[iter]); + 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); } diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index f432fc9540a..806456e0f31 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -327,12 +327,24 @@ public double read() { gauge("cn1.task.queue_depth", "Tasks waiting for a thread, by executor", "{task}", new Gauge.MultiSource() { public List read() { - List out = new ArrayList(); + // 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(); List all = com.codename1.backend.Tasks.executors(); for(int iter = 0 ; iter < all.size() ; iter++) { com.codename1.backend.TaskExecutor e = (com.codename1.backend.TaskExecutor)all.get(iter); - out.add(Gauge.point("cn1.executor", e.getName(), e.getQueueDepth())); + Long sum = (Long)byName.get(e.getName()); + byName.put(e.getName(), new Long((sum == null ? 0 : sum.longValue()) + + e.getQueueDepth())); + } + List out = new ArrayList(); + java.util.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; } From 1bfafbd9b9c62a5abcf00f4b0734e318447884d2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:14:34 +0300 Subject: [PATCH 15/48] Backend: contain tracer Errors, honour lost rotations and leases, check tokens - Every hook into the tracer now catches Throwable: an AssertionError or LinkageError out of a tracer escaped request start-up, and since the tracer stays installed, every later request failed the same way. - A database session rotation that finds its old row gone (another request of the client rotated it first) is recorded on the session, and the request sends no Set-Cookie: the id it would announce has no row. - A scheduler claim writes a lease unique to that run, and release matches it, so a run that outlived lockAtMostFor cannot free the claim a later run (of any job sharing the lock name) took after it expired. - Prometheus label names are folded without the colon a metric name may carry; histogram label collisions use the same fold. - An executor shut down from one of its own tasks (an @Async method that stops the server) no longer waits out the timeout for that task, nor interrupts it. - A double of exactly -2^63 is refused as a long argument, like 2^63. - Config.getHeaderSecret refuses a bearer token no request could carry, for the MCP and management tokens as well as the OTLP relay's. - Three doc comments the /// conversion had merged into their neighbours are back on their own members, or gone where the member no longer exists. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 133 ++++++++++++++++++ .../codename1/backend/LambdaTracingTest.java | 36 ++++- .../backend/otel/TraceContextTest.java | 5 +- .../src/com/codename1/backend/Config.java | 40 ++++++ .../src/com/codename1/backend/HttpServer.java | 16 +-- .../com/codename1/backend/HttpSession.java | 13 ++ .../src/com/codename1/backend/Management.java | 4 +- .../src/com/codename1/backend/Scheduler.java | 40 ++++-- .../src/com/codename1/backend/Sessions.java | 22 ++- .../com/codename1/backend/TaskExecutor.java | 29 +++- .../src/com/codename1/backend/Tracing.java | 55 ++++---- .../com/codename1/backend/mcp/McpArgs.java | 8 +- .../com/codename1/backend/mcp/McpServer.java | 2 +- .../com/codename1/backend/mcp/McpTool.java | 2 +- .../codename1/backend/metrics/Histogram.java | 2 +- .../codename1/backend/metrics/Metrics.java | 16 ++- .../codename1/backend/otel/OtlpTracer.java | 33 +---- 17 files changed, 362 insertions(+), 94 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index ee0a06c8a75..c66c6497d2d 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -1262,6 +1262,139 @@ void mcpNeedsATokenInProduction() { 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.Jdbc store = new Sessions.Jdbc(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()); + } + } + // ----------------------------------------------------------------- helpers /** An application with no beans, for tests that only need the hooks. */ 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..50708a0cacf 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,28 @@ 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("a span whose decoration throws is still ended") void abandonedSpansAreEnded() throws Exception { @@ -235,6 +257,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 +266,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 +295,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/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/vm/backend/src/com/codename1/backend/Config.java b/vm/backend/src/com/codename1/backend/Config.java index 5091253576a..7feb95a57cc 100644 --- a/vm/backend/src/com/codename1/backend/Config.java +++ b/vm/backend/src/com/codename1/backend/Config.java @@ -250,6 +250,46 @@ public String get(String key) throws IOException { return get(key, null); } + /// 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); diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 74700ad9d3c..6031e2aef45 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -1006,9 +1006,6 @@ public Response(int status, String contentType, byte[] body, Map extraHeaders) { this.extraHeaders = extraHeaders; } - /// 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. /// 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 @@ -1023,6 +1020,9 @@ Response withHeaders(Map headers) { 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. public static Response file(int status, String contentType, int fd, long offset, long length, Map extraHeaders) { return new Response(status, contentType, null, fd, offset, length, extraHeaders); @@ -2513,13 +2513,6 @@ static boolean acceptsVirtualTasks() { return server != null && server.running && server.vtHosts != null; } - /// Runs `task` on a virtual thread of the running server's hosts. - /// Answers false, having done nothing, when there is none to run it on; the - /// caller then runs it on a platform thread. - /// - /// The task is queued on a host and the host woken through its pipe; the - /// host creates the virtual thread itself, because a virtual thread's VM state - /// belongs to the host that runs it. /// The tracer this server's requests report to, when it has one of its own. private volatile Tracer serverTracer; //NOPMD AvoidUsingVolatile - set by setTracer after start, read by every worker @@ -2538,6 +2531,9 @@ static HttpServer activeServer() { /// 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) { diff --git a/vm/backend/src/com/codename1/backend/HttpSession.java b/vm/backend/src/com/codename1/backend/HttpSession.java index e10b9a4bc27..601ebe69e00 100644 --- a/vm/backend/src/com/codename1/backend/HttpSession.java +++ b/vm/backend/src/com/codename1/backend/HttpSession.java @@ -64,6 +64,10 @@ public final class HttpSession { 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. @@ -211,6 +215,7 @@ synchronized void clean() { dirty = false; fresh = false; previousId = null; + rotationLost = false; changed.clear(); maxInactiveChanged = false; } @@ -224,6 +229,14 @@ synchronized boolean isMaxInactiveChanged() { return maxInactiveChanged; } + synchronized void markRotationLost() { + rotationLost = true; + } + + synchronized boolean isRotationLost() { + return rotationLost; + } + /// The id the client held before [#changeSessionId], or null. synchronized String previousId() { return previousId; diff --git a/vm/backend/src/com/codename1/backend/Management.java b/vm/backend/src/com/codename1/backend/Management.java index 88e5d2b576c..a50f6263ae1 100644 --- a/vm/backend/src/com/codename1/backend/Management.java +++ b/vm/backend/src/com/codename1/backend/Management.java @@ -70,14 +70,14 @@ private Management(String path, String token, boolean development) { /// /// #### Throws /// - /// - `IOException`: @throws IOException when they are on outside development with no token, + /// - `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.get(TOKEN); + 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 " diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/backend/Scheduler.java index 8372567fbfa..d2877c7faf2 100644 --- a/vm/backend/src/com/codename1/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -111,6 +111,11 @@ public String describeSchedule() { 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 @@ -384,12 +389,16 @@ 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 || claim(job)) { + 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)); @@ -398,8 +407,8 @@ private void runJob(final Job job) { error = String.valueOf(err); System.err.println("Scheduled job " + job.name + " failed: " + err); } finally { - if (ran && job.lock != null) { - release(job); + if (lease != null) { + release(job, lease); } long end = System.currentTimeMillis(); if (ran && measured) { @@ -427,8 +436,15 @@ private void runJob(final Job job) { } } - private boolean claim(Job job) throws IOException { + /// 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. @@ -436,15 +452,15 @@ private boolean claim(Job job) throws IOException { long until = 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), instance, job.lock, Long.valueOf(now)}); + new Object[] {Long.valueOf(until), Long.valueOf(now), lease, job.lock, Long.valueOf(now)}); if (updated > 0) { - return true; + 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), instance}); - return true; + 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 @@ -458,17 +474,19 @@ private boolean claim(Job job) throws IOException { throw failed; } if (row != null) { - return false; + return null; } throw failed; } } - private void release(Job job) { + /// 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, instance}); + 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. diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 70c8ada5119..b0da1c9b9bc 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -325,6 +325,15 @@ HttpServer.Response finish(HttpSession session, HttpServer.Response response) if (session.isDirty()) { boolean announce = session.isNew() || previous != null; s.save(session, previous); + 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); } @@ -777,13 +786,18 @@ private void rotate(final HttpSession session, final String previousId) 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 { - pool.inTransaction(new Rotation(session, previousId, lock, changed, mine)); + moved = pool.inTransaction(new Rotation(session, previousId, lock, changed, mine)); } 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(); } @@ -809,7 +823,9 @@ public Object run(Database db) throws Exception { Map row = db.queryOne("SELECT created, max_inactive, attributes FROM " + TABLE + " WHERE id = ?" + lock, new Object[] {previousId}); if (row == null) { - return null; // invalidated meanwhile: stays gone + // 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"); @@ -835,7 +851,7 @@ public Object run(Database db) throws Exception { Integer.valueOf(maxInactive), Json.write(merged)}); db.execute("DELETE FROM " + TABLE + " WHERE id = ?", new Object[] {previousId}); - return null; + return Boolean.TRUE; } } diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index 2831c998cd4..fa2174b812d 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -37,6 +37,11 @@ /// 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(); + private final String name; private final boolean virtual; private final int size; @@ -177,12 +182,15 @@ void runCounted(Runnable task) { // 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--; @@ -190,7 +198,9 @@ void runCounted(Runnable task) { if (!ok) { failed++; } - if (shutdown && active == 0 && queue.isEmpty()) { + 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(); } } @@ -208,11 +218,17 @@ public synchronized boolean isShutdown() { /// 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 = System.currentTimeMillis() + Math.max(0, waitMillis); - while ((active > 0 || !queue.isEmpty()) && waitMillis > 0) { + while ((active > self || !queue.isEmpty()) && waitMillis > 0) { long left = deadline - System.currentTimeMillis(); if (left <= 0) { break; @@ -223,7 +239,7 @@ public synchronized void shutdown(long waitMillis) { break; } } - if (queue.isEmpty() && active == 0) { + if (queue.isEmpty() && active <= self) { return; } // Out of time. The server destroys its beans and closes its database @@ -245,12 +261,15 @@ public synchronized void shutdown(long waitMillis) { } queue.clear(); if (workers != null) { + Thread caller = Thread.currentThread(); for (Thread element : workers) { - element.interrupt(); + 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 + + "timeout: " + waiting + " queued task(s) dropped, " + (active - self) + " still running"); } diff --git a/vm/backend/src/com/codename1/backend/Tracing.java b/vm/backend/src/com/codename1/backend/Tracing.java index b6f9b6f887b..cee22cae9e9 100644 --- a/vm/backend/src/com/codename1/backend/Tracing.java +++ b/vm/backend/src/com/codename1/backend/Tracing.java @@ -164,7 +164,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); } } @@ -213,7 +213,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); } } @@ -265,8 +265,6 @@ public static Object inSpan(String name, Work work) throws Exception { } } - /// 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. /// The current span when tracing is on, for handing to work that runs on /// another thread; null otherwise. static Span captureParent() { @@ -310,7 +308,7 @@ static Object inBackground(String name, Span parent, Tracer own, Work work) span.owner = t; enter(span); } - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); span = null; } @@ -338,7 +336,7 @@ public static Span startSpan(String name) { span.owner = t; } return span == null ? NOOP : span; - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); return NOOP; } @@ -400,7 +398,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); } } @@ -460,7 +458,7 @@ static Span startServer(HttpServer.Request request, boolean secure, Tracer own) } enter(span); return span; - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); abandon(span); return null; @@ -490,7 +488,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); @@ -527,7 +525,7 @@ private static Span servingSpan() { if (span.getKind() == Span.KIND_SERVER) { return span; } - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); return null; } @@ -568,7 +566,7 @@ static Span startHttpClient(String method, String url, List callerHeaders) { } } } - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } return span; @@ -596,7 +594,7 @@ static List propagationHeaders(Span span, List callerHeaders) { out.add(TRACESTATE + ": " + state); } return out; - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); return null; } @@ -622,7 +620,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); @@ -655,7 +653,7 @@ static Span startDatabase(String system, String sql) { span.setAttribute("db.query.text", sql); } } - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } return span; @@ -670,7 +668,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); } } @@ -739,7 +737,7 @@ static Span startLambda(String traceHeader, String requestId) { } enter(span); return span; - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); abandon(span); return null; @@ -776,7 +774,7 @@ static void flush(int timeoutMillis) { } try { t.flush(timeoutMillis); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -823,7 +821,7 @@ static void shutdown(Tracer owned, int timeoutMillis) { } try { owned.shutdown(timeoutMillis); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -836,7 +834,7 @@ static void metrics(Map out) { } try { t.metrics(out); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -874,7 +872,7 @@ private static Span begin(String name, int kind, String traceparent, String trac } enter(span); return span; - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); return null; } @@ -907,7 +905,7 @@ private static void finish(Span span) { } try { span.end(); - } catch (RuntimeException err) { + } catch (Throwable err) { failed(err); } } @@ -923,13 +921,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); } } @@ -937,13 +935,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/mcp/McpArgs.java b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java index 773d0539a28..bf48eb3be93 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java @@ -263,8 +263,12 @@ private static long toLong(Object v, String name) { 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. 2^63 itself is out of range. - if (d < -9.223372036854775808E18 || d >= 9.223372036854775808E18) { + // 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; diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index 42bfadca554..266239ab32a 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -123,7 +123,7 @@ public static McpServer fromConfig(Config config, Extension devTools, String ser if (!config.getBoolean(ENABLED, anyTools || extension != null)) { return null; } - String token = config.get(TOKEN); + 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 " diff --git a/vm/backend/src/com/codename1/backend/mcp/McpTool.java b/vm/backend/src/com/codename1/backend/mcp/McpTool.java index 9ee1ff9c041..dd675f13075 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpTool.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpTool.java @@ -45,7 +45,7 @@ public interface McpTool { /// /// #### Throws /// - /// - `IllegalArgumentException`: @throws IllegalArgumentException for arguments the tool cannot use, which + /// - `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/metrics/Histogram.java b/vm/backend/src/com/codename1/backend/metrics/Histogram.java index 305e179c193..50b104b0367 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Histogram.java +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -92,7 +92,7 @@ public static double[] defaultBounds() { // 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.promName(this.labels[iter]); + String folded = Metrics.promLabel(this.labels[iter]); if ("le".equals(folded) || !exported.add(folded)) { throw new IllegalArgumentException("Histogram " + name + ": label key " + this.labels[iter] + " is exported to Prometheus as " + folded diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index aa7e04f5500..46b28cb699f 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -552,7 +552,7 @@ private static void labels(StringBuilder sb, Map attributes, String le) { sb.append(','); } first = false; - sb.append(promName(String.valueOf(e.getKey()))).append("=\"") + sb.append(promLabel(String.valueOf(e.getKey()))).append("=\"") .append(escapeLabel(String.valueOf(e.getValue()))).append('"'); } } @@ -576,6 +576,20 @@ static String promName(String name) { 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++) { diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java b/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java index 8d388b79f42..a6e8f8017bc 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpTracer.java @@ -219,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); @@ -239,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) { From e681885f6f01f56d82fb52e064c3b91bd920679a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:41:51 +0300 Subject: [PATCH 16/48] Backend: spare in-use sessions on lookup, check the whole bean graph off-request - A lookup no longer expires a session another request is still using: the database store writes the last use when a request ends, so a long request made the stored time look expired and the next request deleted the row and the beans under the first. The purge already spared these ids. - A class a @Bean method builds keeps its @Scheduled jobs, @McpTool methods and @ManagedResource surface; only its lifecycle methods were collected. - Websocket endpoints, beans with @Scheduled jobs and @ManagedResource beans are checked through their whole dependency graph for request- and session-scoped beans, and the error names the path. All three run with no request current; only an endpoint's direct injections were checked. - A prototype controller, or a prototype with MCP tools or managed attributes, is built once for the router: the reference was evaluated in the null test and again for the router, building a second instance. - A @Bean method that returns null stops the start, naming the method, instead of handing null to what injects it. - MCP: an explicit "id": null is a request and is answered; only an absent id is a notification. - A number argument given as "NaN" or "Infinity" is refused. - OTLP/JSON metrics write non-finite doubles as the protobuf JSON strings ("Infinity", "-Infinity", "NaN") rather than null. - Websocket callbacks report to their own server's tracer, through a per-thread owner the callbacks bind, rather than to whichever server installed its tracer last. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 94 ++++++++++++++++ .../backend/mcp/McpDispatchTest.java | 54 +++++++++ .../backend/otel/OtlpMetricsProtoTest.java | 18 +++ .../maven/processors/BackendBeans.java | 86 +++++++++++---- .../maven/processors/BackendWiringWriter.java | 23 +++- .../maven/processors/BackendBeansTest.java | 104 ++++++++++++++++++ .../src/com/codename1/backend/Backend.java | 63 ++++++++--- .../src/com/codename1/backend/Sessions.java | 12 +- .../src/com/codename1/backend/Tracing.java | 22 ++++ .../src/com/codename1/backend/Wiring.java | 11 ++ .../com/codename1/backend/mcp/McpArgs.java | 19 +++- .../com/codename1/backend/mcp/McpServer.java | 8 +- .../backend/otel/OtlpMetricExporter.java | 28 ++++- 13 files changed, 486 insertions(+), 56 deletions(-) create mode 100644 maven/backend/src/test/java/com/codename1/backend/mcp/McpDispatchTest.java diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index c66c6497d2d..6b135b57b14 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -1395,6 +1395,100 @@ void unsendableBearerTokensAreRefused() { } } + @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); + } + + /** A tracer that records nothing, for tests that only check which one is used. */ + static final 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. */ 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..3b8afb837ac --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/mcp/McpDispatchTest.java @@ -0,0 +1,54 @@ +/* + * 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"); + } +} 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 index 8343c9b0642..57e177102c2 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -37,6 +37,7 @@ 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; @@ -74,6 +75,23 @@ void histogramBuckets() throws Exception { 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 counter past 2^53 is exported exactly, through as_int") void countersAreIntegral() throws Exception { 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 index b7209aef3be..a419ba59ebd 100644 --- 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 @@ -870,6 +870,13 @@ private Bean factoryBean(Bean owner, MethodInfo m) { // 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) { @@ -1502,37 +1509,74 @@ private void resolve() { + b.scope + "-scoped; an endpoint serves many connections for the " + "life of the server, so it must be a singleton."); } - if (b.webSocket) { - // Its callbacks run outside the HTTP handler that makes a request - // current, so a scoped stand-in would throw on first use. - List all = new ArrayList(b.constructorPoints); - all.addAll(b.fields.values()); - for (Call c : b.setters) { - all.addAll(c.points); - } - for (Point p : all) { - for (Bean d : p.candidates) { - if (REQUEST.equals(d.scope) || SESSION.equals(d.scope)) { - ctx.error(b.cls, "Websocket endpoint " + b.describe() + " injects " - + d.describe() + ", which is " + d.scope + "-scoped. A " - + "websocket callback runs outside any HTTP request, so " - + "there is no " + d.scope + " to find it in; inject a " - + "singleton, and keep per-connection state in the " - + "WebSocketSession's attachment."); - } - } - } - } 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."); } } + // 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. Only the direct injections used to be checked. + for (Bean b : beans) { + String role = b.webSocket ? "Websocket endpoint" + : !b.jobs.isEmpty() ? "Bean with @Scheduled methods" + : b.managed != null ? "@ManagedResource bean" : 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" + : "A managed attribute is read"; + ctx.error(b.cls, role + " " + b.describe() + " reaches " + d.describe() + via + + ", which is " + d.scope + "-scoped. " + outside + " outside any HTTP " + + "request, so there is no " + d.scope + " to find it in; inject a singleton" + + (b.webSocket ? ", and keep per-connection state in the " + + "WebSocketSession's attachment." : ".")); + } 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) { 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 index e06f837053d..d59d923852e 100644 --- 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 @@ -220,7 +220,12 @@ private void create(StringBuilder sb, List routers) { if (b.managed == null && b.tools.isEmpty()) { continue; } - String ref = reference(b); + // 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") @@ -235,15 +240,17 @@ private void create(StringBuilder sb, List routers) { sb.append(" environment.registerTool(new ") .append(t.adapterBinary).append('(').append(ref).append("));\n"); } - sb.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 = reference(b); + 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 }\n"); + .append("));\n }\n }\n"); } sb.append(" com.codename1.backend.HttpServer.Handler[] out =\n") .append(" new com.codename1.backend.HttpServer.Handler[handlers.size()];\n"); @@ -310,7 +317,13 @@ private String construct(BackendBeans.Bean b) { ? BackendWeaver.bridge(b.factory.getName()) : b.factory.getName(); String target = b.owner != null ? reference(b.owner) : b.factoryOwnerClass.getSourceName(); - return target + "." + name + "(" + args + ")"; + // 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 + ")"; 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 index 8275e855e7b..6180fbb4590 100644 --- 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 @@ -1156,6 +1156,110 @@ public void anInheritedScheduledMethodRuns() throws Exception { } } + @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 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"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("Websocket endpoint chat (com.example.Chat) reaches " + + "visit (com.example.Visit) through helper (com.example.Helper)")); + assertTrue(errors, errors.contains("A websocket callback runs outside any HTTP request")); + assertTrue(errors, errors.contains("nightly (com.example.Nightly) reaches visit")); + assertTrue(errors, errors.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))); + } + + @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(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index e8c3f9d080f..9589ddea9ab 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -414,10 +414,19 @@ public static HttpServer.Request currentRequest() { /// 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 new HttpServer.WebSocketRegistry() { @Override public void route(String path, WebSocket endpoint) { - registry.route(path, endpoint == null ? null : new TaskBound(endpoint, tasks)); + registry.route(path, endpoint == null ? null + : new TaskBound(endpoint, tasks, tracer)); } @Override @@ -428,7 +437,7 @@ public WebSocket open(HttpServer.Request request) throws Exception { Object previous = Tasks.enter(tasks); try { WebSocket endpoint = router.open(request); - return endpoint == null ? null : new TaskBound(endpoint, tasks); + return endpoint == null ? null : new TaskBound(endpoint, tasks, tracer); } finally { Tasks.leave(previous); } @@ -438,87 +447,105 @@ public WebSocket open(HttpServer.Request request) throws Exception { }; } - /// An endpoint whose every callback runs carrying its server's executors. + /// 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; TaskBound(WebSocket endpoint, Tasks.Registry tasks) { + this(endpoint, tasks, null); + } + + TaskBound(WebSocket endpoint, Tasks.Registry tasks, Tracer tracer) { this.endpoint = endpoint; this.tasks = tasks; + this.tracer = tracer; + } + + private Object[] bind() { + return new Object[] {Tasks.enter(tasks), Tracing.own(tracer)}; + } + + private static void unbind(Object[] previous) { + Tracing.disown(previous[1]); + Tasks.leave(previous[0]); } @Override public void onOpen(WebSocketSession session) throws Exception { - Object previous = Tasks.enter(tasks); + Object[] previous = bind(); try { endpoint.onOpen(session); } finally { - Tasks.leave(previous); + unbind(previous); } } @Override public void onText(WebSocketSession session, String message) throws Exception { - Object previous = Tasks.enter(tasks); + Object[] previous = bind(); try { endpoint.onText(session, message); } finally { - Tasks.leave(previous); + unbind(previous); } } @Override public void onBinary(WebSocketSession session, byte[] message, int offset, int length) throws Exception { - Object previous = Tasks.enter(tasks); + Object[] previous = bind(); try { endpoint.onBinary(session, message, offset, length); } finally { - Tasks.leave(previous); + unbind(previous); } } @Override public void onPing(WebSocketSession session, byte[] payload, int offset, int length) throws Exception { - Object previous = Tasks.enter(tasks); + Object[] previous = bind(); try { endpoint.onPing(session, payload, offset, length); } finally { - Tasks.leave(previous); + unbind(previous); } } @Override public void onPong(WebSocketSession session, byte[] payload, int offset, int length) throws Exception { - Object previous = Tasks.enter(tasks); + Object[] previous = bind(); try { endpoint.onPong(session, payload, offset, length); } finally { - Tasks.leave(previous); + unbind(previous); } } @Override public void onClose(WebSocketSession session, int code, String reason) throws Exception { - Object previous = Tasks.enter(tasks); + Object[] previous = bind(); try { endpoint.onClose(session, code, reason); } finally { - Tasks.leave(previous); + unbind(previous); } } @Override public void onError(WebSocketSession session, Exception error) { - Object previous = Tasks.enter(tasks); + Object[] previous = bind(); try { endpoint.onError(session, error); } finally { - Tasks.leave(previous); + unbind(previous); } } } @@ -1331,7 +1358,7 @@ 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); + HttpServer.WebSocketRegistry registry = withTasks(direct, tasks, active); // The same two arguments a Handlers factory gets, // and for the same reason: an endpoint that needs // the database declares it rather than reaching diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index b0da1c9b9bc..9b5d66f0353 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -206,7 +206,13 @@ HttpSession find(String cookieValue, boolean create) throws IOException { purgeIfDue(s, now); if (cookieValue != null && cookieValue.length() > 0) { HttpSession found = s.load(cookieValue); - if (found != null && found.isValid() && !found.isExpired(now)) { + // 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) { @@ -234,6 +240,10 @@ HttpSession find(String cookieValue, boolean create) throws IOException { 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) { diff --git a/vm/backend/src/com/codename1/backend/Tracing.java b/vm/backend/src/com/codename1/backend/Tracing.java index cee22cae9e9..89fe7d46af6 100644 --- a/vm/backend/src/com/codename1/backend/Tracing.java +++ b/vm/backend/src/com/codename1/backend/Tracing.java @@ -54,6 +54,10 @@ 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(); private static volatile boolean reportedFailure; //NOPMD AvoidUsingVolatile - set once from any host thread private static final Span NOOP = new NoopSpan(); @@ -280,9 +284,27 @@ static Tracer active() { if (current != null && current.owner != null) { return current.owner; } + Object owner = OWNER.get(); + if (owner instanceof Tracer) { + return (Tracer) owner; + } return tracer; } + /// 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. diff --git a/vm/backend/src/com/codename1/backend/Wiring.java b/vm/backend/src/com/codename1/backend/Wiring.java index f2a116e1208..900f8b5a6f1 100644 --- a/vm/backend/src/com/codename1/backend/Wiring.java +++ b/vm/backend/src/com/codename1/backend/Wiring.java @@ -139,6 +139,17 @@ public static boolean propertyMatches(Config config, String key, String havingVa 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 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`. diff --git a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java index bf48eb3be93..5b816534622 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java @@ -281,13 +281,22 @@ private static long toLong(Object v, String name) { } private static double toDouble(Object v, String name) { + double d; if (v instanceof Number) { - return ((Number) v).doubleValue(); + 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); + } } - try { - return 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 index 266239ab32a..f9a5d495695 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -250,15 +250,19 @@ Object dispatch(Object message) { } 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"); Object methodValue = m.get("method"); if (!(methodValue instanceof String)) { // A response to something the server sent, or garbage; the server // sends nothing, so either way there is nothing to answer. - return id == null ? null : rpcError(id, -32600, "Invalid request"); + return notification ? null : rpcError(id, -32600, "Invalid request"); } String method = (String) methodValue; Map params = m.get("params") instanceof Map ? (Map) m.get("params") : new LinkedHashMap(); - if (id == null) { + if (notification) { return null; } try { diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index 8518132fd76..e7b83c7f969 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -284,7 +284,7 @@ static Map request(Map resource, List instruments, long nowMillis) { dp.put("timeUnixNano", time); if (instrument.getKind() == Instrument.HISTOGRAM) { dp.put("count", String.valueOf(point.get("count"))); - dp.put("sum", point.get("sum")); + dp.put("sum", jsonDouble(point.get("sum"))); List buckets = (List) point.get("buckets"); List counts = new ArrayList(buckets.size()); for (Object element : buckets) { @@ -293,8 +293,8 @@ static Map request(Map resource, List instruments, long nowMillis) { dp.put("bucketCounts", counts); dp.put("explicitBounds", point.get("bounds")); if (point.get("min") != null) { - dp.put("min", point.get("min")); - dp.put("max", point.get("max")); + dp.put("min", jsonDouble(point.get("min"))); + dp.put("max", jsonDouble(point.get("max"))); } } else { Object value = point.get("value"); @@ -306,7 +306,7 @@ static Map request(Map resource, List instruments, long nowMillis) { // would be rounded. A string, as OTLP JSON writes int64. dp.put("asInt", String.valueOf(value)); } else { - dp.put("asDouble", value); + dp.put("asDouble", jsonDouble(value)); } } dataPoints.add(dp); @@ -349,4 +349,24 @@ static Map request(Map resource, List instruments, long nowMillis) { 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; + } } From 88d4c8e459236841bb894be9ae6530d37fb5764c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:36:31 +0300 Subject: [PATCH 17/48] Backend: undo a stale memory rotation, finish every session, bound the bridge - The memory store shares one HttpSession between concurrent requests, so a second request rotating it after the first had rotated and answered would delete the id the first response carries, and signed the client out if that response arrived last. A request now records the id it found each session under; a rotation whose replaced id is not that one is undone and not announced, as the database store's conditional move already settled. - A request that ends several sessions finishes each one on its own: a store failure on one no longer skips the deletes, saves and bean clean-up of the others. - The stdio bridge sets connect and read timeouts (CN1_MCP_TIMEOUT_MS, two minutes by default), so a backend that accepts and stalls is reported under the request's id instead of blocking every later message. - Gauge callbacks and the metrics export contain any Throwable: an Error from an application's gauge used to end the exporter's only thread. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 100 ++++++++++++++++++ .../codename1/backend/mcp/DevToolsTest.java | 31 ++++++ .../backend/otel/OtlpMetricsProtoTest.java | 16 +++ .../codename1/backend/mcp/StdioBridge.java | 31 ++++++ .../src/com/codename1/backend/Backend.java | 45 ++++---- .../src/com/codename1/backend/HttpServer.java | 3 + .../com/codename1/backend/HttpSession.java | 7 ++ .../src/com/codename1/backend/Sessions.java | 27 +++++ .../com/codename1/backend/metrics/Gauge.java | 10 +- .../codename1/backend/metrics/Metrics.java | 2 +- .../backend/otel/OtlpMetricExporter.java | 7 +- 11 files changed, 255 insertions(+), 24 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 6b135b57b14..009729725b2 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -1464,6 +1464,106 @@ void nonFiniteNumberStringsAreRefused() { 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(); + } + } + /** A tracer that records nothing, for tests that only check which one is used. */ static final class QuietTracer implements Tracer { public boolean open(Config config) { 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 index c675819709e..bd3fa9e5fb2 100644 --- a/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java @@ -58,4 +58,35 @@ void bridgeAnswersUnderTheTopLevelId() throws Exception { 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); + } } 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 index 57e177102c2..e6154c2c5ef 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -92,6 +92,22 @@ void infiniteValuesSurviveBothEncodings() throws Exception { .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 { diff --git a/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java b/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java index 1adbc852141..19b35afc775 100644 --- a/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java +++ b/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java @@ -42,6 +42,28 @@ /// 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() { } @@ -72,8 +94,17 @@ public static void main(String[] args) throws Exception { /// 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"); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 9589ddea9ab..5c27978be5f 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -578,16 +578,22 @@ private static final class Serving implements HttpServer.Handler { /// What a request that threw still owes: its request beans /// destroyed, and the sessions it ended or changed stored. - private void failed(HttpServer.Request request, - boolean sessionFinished, long startedMillis, - Throwable err) { + /// + /// 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 && !sessionFinished - && e < ended.size() ; e++) { + for (int e = 0 ; ended != null && e < ended.size() ; e++) { + HttpSession ending = (HttpSession) ended.get(e); + if (attempted.contains(ending)) { + continue; + } try { - sessions.finish((HttpSession) ended.get(e), - null); + sessions.finish(request, ending, null); } catch (Exception storeErr) { System.err.println("Could not end the " + "session of a failed request: " @@ -595,14 +601,14 @@ private void failed(HttpServer.Request request, } } HttpSession session = request.resolvedSession(); - if (session != null && !sessionFinished) { + 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(session, null); + sessions.finish(request, session, null); } catch (Exception storeErr) { System.err.println("Could not store the " + "session of a failed request: " @@ -646,7 +652,7 @@ public HttpServer.Response handle(HttpServer.Request request) int status = 500; try { HttpServer.Response response = null; - boolean sessionFinished = false; + List attempted = new ArrayList(2); try { for (HttpServer.Handler element : chain) { response = element.handle(request); @@ -666,26 +672,27 @@ public HttpServer.Response handle(HttpServer.Request request) // handler's own status. List ended = request.endedSessions(); HttpSession session = request.resolvedSession(); - if (ended != null || session != null) { - sessionFinished = true; - } // First, so their clearing cookies come - // before the new session's. + // 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++) { - response = sessions.finish( - (HttpSession) ended.get(e), response); + HttpSession ending = (HttpSession) ended.get(e); + attempted.add(ending); + response = sessions.finish(request, ending, response); } if (session != null) { - response = sessions.finish(session, response); + attempted.add(session); + response = sessions.finish(request, session, response); } } catch (Exception err) { - failed(request, sessionFinished, startedMillis, err); + failed(request, attempted, startedMillis, err); throw err; } catch (Error err) { // The same clean-up: an invalidated session // must still be deleted, or the client's old // cookie keeps its signed-in state. - failed(request, sessionFinished, startedMillis, err); + failed(request, attempted, startedMillis, err); throw err; } // Null is a 404 from here, which is what a diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 6031e2aef45..18495a96317 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -120,6 +120,9 @@ public static final class Request { 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; diff --git a/vm/backend/src/com/codename1/backend/HttpSession.java b/vm/backend/src/com/codename1/backend/HttpSession.java index 601ebe69e00..0a1a9c3e5f7 100644 --- a/vm/backend/src/com/codename1/backend/HttpSession.java +++ b/vm/backend/src/com/codename1/backend/HttpSession.java @@ -229,6 +229,13 @@ 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; + } + synchronized void markRotationLost() { rotationLost = true; } diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 9b5d66f0353..a026482984f 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -297,12 +297,35 @@ void purgeIfDue(SessionStore s, long now) { /// 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 (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 @@ -423,6 +446,10 @@ synchronized void enter(HttpServer.Request request, HttpSession session) { return; } request.sessionsInUse.add(session); + if (request.sessionIdsFound == null) { + request.sessionIdsFound = new HashMap(); + } + request.sessionIdsFound.put(session, session.getId()); int[] count = (int[]) inUse.get(session); if (count == null) { count = new int[1]; diff --git a/vm/backend/src/com/codename1/backend/metrics/Gauge.java b/vm/backend/src/com/codename1/backend/metrics/Gauge.java index bed65287147..68f0297c066 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Gauge.java +++ b/vm/backend/src/com/codename1/backend/metrics/Gauge.java @@ -63,13 +63,18 @@ public static java.util.Map point(String key, Object label, double 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 (RuntimeException err) { + } catch (Throwable err) { return Double.NaN; } } @@ -80,7 +85,8 @@ public List points() { try { List points = multi.read(); return points == null ? new java.util.ArrayList() : points; - } catch (RuntimeException err) { + } catch (Throwable err) { + // Contained like read(): the callback is the application's. return new java.util.ArrayList(); } } diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index 46b28cb699f..e2785ba99a1 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -237,7 +237,7 @@ public double read() { sum += v; any = true; } - } catch (RuntimeException err) { + } catch (Throwable err) { // One source failing leaves the others' values. } } diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index e7b83c7f969..75e4949067c 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -197,11 +197,14 @@ public boolean export() { } } return true; - } catch (Exception err) { + } 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.getMessage()); + + err); if (failures == 1 || failures % 100 == 0) { System.err.println(lastError); } From be4c4cd6721e637b74637e7efee212dffa644a8b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:20:11 +0300 Subject: [PATCH 18/48] Backend: forget found session ids, run notifications, check executor kinds - The ids a request found its sessions under are cleared with its other session state, when it ends and when a pooled request is recycled: a keep-alive connection's request otherwise held every session it had served, attributes and all. - An MCP notification runs its method; only the answer is withheld. An id-less tools/call used to be acknowledged and never run. - cn1.task.executor..kind is trimmed, and anything but platform or virtual is refused: at start-up for the kinds the files set, and when the executor is created for one set only in the environment. A typo used to fall back silently to the annotation's kind. - An Error that fails the work of inSpan or inBackground -- custom spans, @Async calls, scheduled runs -- is recorded on the span before it is rethrown, rather than the span exporting as a success. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 26 ++++++++++++ .../codename1/backend/LambdaTracingTest.java | 29 ++++++++++++++ .../backend/mcp/McpDispatchTest.java | 40 +++++++++++++++++++ .../src/com/codename1/backend/HttpServer.java | 2 + .../src/com/codename1/backend/Sessions.java | 3 ++ .../src/com/codename1/backend/Tasks.java | 34 ++++++++++++++++ .../src/com/codename1/backend/Tracing.java | 10 +++++ .../com/codename1/backend/mcp/McpServer.java | 10 +++-- 8 files changed, 151 insertions(+), 3 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 009729725b2..d1759a2cf8e 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -1564,6 +1564,32 @@ public int size() { } } + @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", " ")); + } + /** A tracer that records nothing, for tests that only check which one is used. */ static final class QuietTracer implements Tracer { public boolean open(Config config) { 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 50708a0cacf..23ccc0a0f67 100644 --- a/maven/backend/src/test/java/com/codename1/backend/LambdaTracingTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/LambdaTracingTest.java @@ -234,6 +234,35 @@ public Object run(Span s) { 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 { 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 index 3b8afb837ac..fe87a117108 100644 --- a/maven/backend/src/test/java/com/codename1/backend/mcp/McpDispatchTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/mcp/McpDispatchTest.java @@ -51,4 +51,44 @@ void aNullIdIsNotANotification() throws Exception { 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/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 18495a96317..502aca6d0fa 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -561,6 +561,7 @@ void releaseRetained() { this.sessions = null; this.endedSessions = null; this.sessionsInUse = null; + this.sessionIdsFound = null; } /// The session of this request, creating one if it has none. @@ -680,6 +681,7 @@ void reset(Conn conn, String method, String target, String version, byte[] raw, this.sessions = null; this.endedSessions = null; this.sessionsInUse = null; + this.sessionIdsFound = null; } /// For HTTP/2, whose headers arrive already decoded from the HPACK state -- diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index a026482984f..20434b33303 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -465,6 +465,9 @@ synchronized void leave(HttpServer.Request request) { return; } 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; diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index 8b4bb478b69..796d6ad44a0 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -99,6 +99,23 @@ synchronized HttpServer virtualHost() { /// 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); @@ -178,6 +195,7 @@ static TaskExecutor executor(Registry registry, String name, int kind) { + " cannot be configured: " + err.getMessage(), err); } } + configured = checkedKind(prefix + "kind", configured); boolean virtual; if ("virtual".equalsIgnoreCase(configured)) { virtual = true; @@ -199,6 +217,22 @@ static TaskExecutor executor(Registry registry, String name, int kind) { } } + /// 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); diff --git a/vm/backend/src/com/codename1/backend/Tracing.java b/vm/backend/src/com/codename1/backend/Tracing.java index 89fe7d46af6..079f57c9a0d 100644 --- a/vm/backend/src/com/codename1/backend/Tracing.java +++ b/vm/backend/src/com/codename1/backend/Tracing.java @@ -264,6 +264,11 @@ 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); } @@ -342,6 +347,11 @@ static Object inBackground(String name, Span parent, Tracer own, Work work) } 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); } diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index f9a5d495695..b1b4346e65a 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -262,9 +262,13 @@ Object dispatch(Object message) { } String method = (String) methodValue; Map params = m.get("params") instanceof Map ? (Map) m.get("params") : new LinkedHashMap(); - if (notification) { - return null; - } + // 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)); From df9743bbb773cccb13c5783f3b8635d3129dd955 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:11:01 +0300 Subject: [PATCH 19/48] Backend: histogram shapes, empty batches, untraced servers, one metrics exporter - A histogram registered again under an existing name must have the same bucket boundaries and label keys; a different shape is refused rather than silently recording against the first one's buckets and labels. - MCP: an empty JSON-RPC batch is answered with Invalid Request, not the 202 an all-notification batch gets. - A backend with tracing off passes an untraced marker instead of null, to its listener, its request handling, websocket callbacks, scheduler and @Async calls. Null means "whatever tracer is installed", so another backend in the process installing one exported this server's requests and jobs under its own service name. - Only the first open metrics exporter of an identity exports; a second one with the same service and endpoint waits and takes over when the first stops, instead of sending every point twice. The same exporter opened twice is refused. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 67 ++++++++++++++++++- .../backend/otel/OtlpMetricsProtoTest.java | 39 +++++++++++ .../src/com/codename1/backend/AsyncTask.java | 6 +- .../src/com/codename1/backend/Backend.java | 20 ++++-- .../src/com/codename1/backend/Scheduler.java | 5 +- .../src/com/codename1/backend/Tracing.java | 52 +++++++++++++- .../com/codename1/backend/mcp/McpServer.java | 5 ++ .../codename1/backend/metrics/Histogram.java | 8 +++ .../codename1/backend/metrics/Metrics.java | 10 +++ .../backend/otel/OtlpMetricExporter.java | 46 ++++++++++--- 10 files changed, 238 insertions(+), 20 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index d1759a2cf8e..bc77d5edf41 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -1590,8 +1590,73 @@ void unknownExecutorKindsAreRefused() { 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 server with tracing off stays untraced when another server installs a tracer") + void anUntracedServerIsNotClaimed() throws Exception { + final List started = new ArrayList(); + Tracer other = new QuietTracer() { + public Span startSpan(String name, int kind, Span parent, String traceparent, + String tracestate) { + started.add(name); + return null; + } + }; + 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 { + // A span of the application's own, inside the request. + Tracing.startSpan("custom").end(); + return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); + } + }).start(); + Tracing.install(other); // another server, started after + try { + assertEquals("ok", read(open(port, "/"))); + assertTrue(started.isEmpty(), + "the untraced server's work went to another server's tracer: " + started); + // The fallback itself is unchanged: with no owner, the installed one. + Tracing.startServer(new HttpServer.Request("GET", "/", "HTTP/1.1", + new LinkedHashMap(), null), false, null); + assertEquals(1, started.size()); + } finally { + Tracing.install(null); + backend.stop(); + } + } + /** A tracer that records nothing, for tests that only check which one is used. */ - static final class QuietTracer implements Tracer { + static class QuietTracer implements Tracer { public boolean open(Config config) { return true; } 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 index e6154c2c5ef..2f4f211690d 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -188,4 +188,43 @@ void reopenedExporterKeepsExporting() throws Exception { 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(); + } } diff --git a/vm/backend/src/com/codename1/backend/AsyncTask.java b/vm/backend/src/com/codename1/backend/AsyncTask.java index e0e7c602429..c27e7391a26 100644 --- a/vm/backend/src/com/codename1/backend/AsyncTask.java +++ b/vm/backend/src/com/codename1/backend/AsyncTask.java @@ -43,6 +43,9 @@ 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; @@ -59,6 +62,7 @@ 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. @@ -75,7 +79,7 @@ public final void run() { Object result = null; Throwable error = null; try { - result = Tracing.inBackground(name, parent, new Tracing.Work() { + result = Tracing.inBackground(name, parent, owner, new Tracing.Work() { @Override public Object run(Span span) throws Exception { return call(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 5c27978be5f..5f78319fbf0 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -563,10 +563,14 @@ private static final class Serving implements HttpServer.Handler { 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; Serving(HttpServer.Handler[] chain, Sessions sessions, Tasks.Registry tasks, RequestLog requestLog, java.util.concurrent.atomic.AtomicBoolean instrumented, - Application app, boolean track) { + Application app, boolean track, Tracer tracer) { + this.tracer = tracer; this.chain = chain; this.sessions = sessions; this.tasks = tasks; @@ -641,6 +645,7 @@ public HttpServer.Response handle(HttpServer.Request request) // one's session to the other and be let in. request.sessions = sessions; Object previousTasks = Tasks.enter(tasks); + Object previousOwner = Tracing.own(tracer); if (track) { previous = CURRENT_REQUEST.get(); CURRENT_REQUEST.set(request); @@ -722,6 +727,7 @@ public HttpServer.Response handle(HttpServer.Request request) CURRENT_REQUEST.set(previous); } Tasks.leave(previousTasks); + Tracing.disown(previousOwner); sessions.leave(request); } } @@ -1365,7 +1371,8 @@ 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); + HttpServer.WebSocketRegistry registry = withTasks(direct, tasks, + active != null ? active : Tracing.NONE); // The same two arguments a Handlers factory gets, // and for the same reason: an endpoint that needs // the database declares it rather than reaching @@ -1380,7 +1387,8 @@ public void register(HttpServer.WebSocketRegistry direct) }; } server = HttpServer.start(host, listenPort, listenBacklog, workerCount, - new Serving(chain, sessions, tasks, requestLog, instrumented, app, track), + new Serving(chain, sessions, tasks, requestLog, instrumented, app, track, + active != null ? active : Tracing.NONE), context, routes); bound = true; } finally { @@ -1393,9 +1401,9 @@ public void register(HttpServer.WebSocketRegistry direct) synchronized (tasks) { tasks.server = server; } - if (active != null) { - server.setTracer(active); - } + // Always set: with tracing off, the untraced marker, so another traced + // server in the process cannot claim this one's requests. + server.setTracer(active != null ? active : Tracing.NONE); // 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. diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/backend/Scheduler.java index d2877c7faf2..ba458bd9118 100644 --- a/vm/backend/src/com/codename1/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -137,7 +137,10 @@ public void setMeasured(boolean measured) { /// Called by the generated application before it starts the scheduler. public synchronized void bind(Backend backend) { this.measured = backend.isMeasured(); - this.tracer = backend.getTracer(); + // 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; diff --git a/vm/backend/src/com/codename1/backend/Tracing.java b/vm/backend/src/com/codename1/backend/Tracing.java index 079f57c9a0d..ab614b6ef97 100644 --- a/vm/backend/src/com/codename1/backend/Tracing.java +++ b/vm/backend/src/com/codename1/backend/Tracing.java @@ -58,6 +58,44 @@ public final class Tracing { /// 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(); @@ -290,12 +328,22 @@ static Tracer active() { 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. @@ -325,7 +373,7 @@ static Object inBackground(String name, Span parent, Tracer own, Work work) // 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 || isSuppressed()) { + if (t == null || t == NONE || isSuppressed()) { //NOPMD CompareObjectsWithEquals - the untraced marker return work.run(NOOP); } Span span = null; @@ -452,7 +500,7 @@ static Span startServer(HttpServer.Request request, boolean secure) { /// 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 || request == null) { + if (t == null || t == NONE || request == null) { //NOPMD CompareObjectsWithEquals - the untraced marker return null; } Span span = null; diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index b1b4346e65a..e4b4254cc5a 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -226,6 +226,11 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { } 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); diff --git a/vm/backend/src/com/codename1/backend/metrics/Histogram.java b/vm/backend/src/com/codename1/backend/metrics/Histogram.java index 50b104b0367..d8b270d7fd0 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Histogram.java +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -103,6 +103,14 @@ public static double[] defaultBounds() { 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(); diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index e2785ba99a1..f35567581c0 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -173,6 +173,16 @@ private static synchronized Instrument register(String name, int kind, String de throw new IllegalArgumentException("Metric " + name + " already exists as another kind"); } + 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; diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index 75e4949067c..cbc53e1c73e 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -127,23 +127,40 @@ public boolean open(Config config) throws IOException { + "service and endpoint, or set " + ENABLED + "=false on one."); } } - if (!OPEN.contains(this)) { - OPEN.add(this); + 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"); + } + 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; + } + + /// Starts this exporter's thread: it is the one exporting the process's metrics. + private void startExporting() { final Run mine = new Run(); - synchronized (lock) { - run = mine; - } - thread = new Thread(new Runnable() { + Thread started = new Thread(new Runnable() { @Override public void run() { loop(mine); } }, "cn1-otel-metrics"); - thread.setDaemon(true); - thread.start(); - return true; + started.setDaemon(true); + synchronized (lock) { + run = mine; + thread = started; + } + started.start(); } private void loop(Run mine) { @@ -215,8 +232,19 @@ public boolean export() { @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); + } + } + 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. + successor.startExporting(); } Thread exporter; synchronized (lock) { From 80f3ddc4791e78f0a1ba047b35669cf5b6f16fa1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:44:51 +0300 Subject: [PATCH 20/48] Backend: tokenless MCP on loopback, JSON-RPC 2.0 only, DST folds, bridge ids - A development server whose MCP endpoint has no token binds 127.0.0.1 when no address was chosen, and refuses to start when one was chosen that is not loopback. The development tools write SQL and call every handler, and the listener used to bind every interface. Documented in the guide and the skill, with how to test from another machine. - MCP refuses a request whose jsonrpc is not exactly "2.0" before its method runs. - A cron time the clocks pass twice (01:30 on a fall-back night) fires at the first occurrence, as Spring and Quartz do; the second only when the search starts after the first. It used to take the second. - The stdio bridge answers an HTTP refusal -- a 401 for a missing token, most often -- under the host's own request id. The server's error came before it read the body, so it carried a null id and the host never saw its request complete. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/developer-guide/Backend-MCP.asciidoc | 9 ++- .../backend/ApplicationRuntimeTest.java | 79 +++++++++++++++++++ .../codename1/backend/mcp/DevToolsTest.java | 33 ++++++++ .../resources/skill/references/backend.md | 6 +- .../codename1/backend/mcp/StdioBridge.java | 62 ++++++++++++--- .../src/com/codename1/backend/Backend.java | 34 +++++++- .../com/codename1/backend/CronSchedule.java | 43 ++++++++-- .../com/codename1/backend/mcp/McpServer.java | 14 ++++ 8 files changed, 256 insertions(+), 24 deletions(-) diff --git a/docs/developer-guide/Backend-MCP.asciidoc b/docs/developer-guide/Backend-MCP.asciidoc index 9ec213103ca..57bf180198e 100644 --- a/docs/developer-guide/Backend-MCP.asciidoc +++ b/docs/developer-guide/Backend-MCP.asciidoc @@ -22,7 +22,11 @@ include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/b 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`. It also refuses a browser +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. @@ -75,7 +79,8 @@ answered 405. 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. +| 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 `*`. diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index bc77d5edf41..fd86faadd6e 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -1655,6 +1655,85 @@ public HttpServer.Response handle(HttpServer.Request request) } } + @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 at the first, or the second when started between") + void cronInAFallBackOverlap() { + CronSchedule c = CronSchedule.parse("0 30 1 * * *", "America/New_York"); + long hour = 3600000L; + // 2026-11-01: 01:30 EDT is 05:30Z and 01:30 EST is 06:30Z. + long midnightUtc = 1793491200000L; // 2026-11-01T00:00:00Z + assertEquals(midnightUtc + 5 * hour + 30 * 60000L, c.next(midnightUtc + 4 * hour)); + assertEquals(midnightUtc + 6 * hour + 30 * 60000L, c.next(midnightUtc + 6 * hour)); + // After the first, the next is the following day's, not the repeat. + assertEquals(midnightUtc + 30 * hour + 30 * 60000L, + c.next(midnightUtc + 5 * hour + 30 * 60000L)); + } + /** A tracer that records nothing, for tests that only check which one is used. */ static class QuietTracer implements Tracer { public boolean open(Config config) { 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 index bd3fa9e5fb2..8c30a88777f 100644 --- a/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java @@ -89,4 +89,37 @@ public void run() { } 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); + } + } } diff --git a/scripts/initializr/common/src/main/resources/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md index 5ff35eff631..8467d33a14e 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/backend.md +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -232,7 +232,11 @@ public List findOrders(@McpParam(value = "email", description = "Customer ``` 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. +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`. ## Testing diff --git a/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java b/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java index 19b35afc775..cb3a96e2d1c 100644 --- a/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java +++ b/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java @@ -124,7 +124,11 @@ static String post(URL url, String token, String body, int readTimeout) { while ((n = stream.read(chunk)) > 0) { buffer.write(chunk, 0, n); } - return new String(buffer.toByteArray(), StandardCharsets.UTF_8); + 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) { @@ -135,29 +139,61 @@ static String post(URL url, String token, String body, int readTimeout) { /// 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) { - // The backend is not up: answer the host rather than hang it, under - // the id it asked with -- and not at all for a notification. The - // TOP-LEVEL id, parsed: a pattern took the first "id" anywhere, so a - // tool argument named id got the answer and the host waited forever. - Object request; - try { - request = com.codename1.backend.Json.parse(body); - } catch (IOException parseErr) { - return null; - } catch (RuntimeException parseErr) { - return null; + 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)) { + 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. + static String errorUnderRequestId(String body, String message) { + Object request = parse(body); if (!(request instanceof java.util.Map) || !((java.util.Map) request).containsKey("id")) { return null; } java.util.Map error = new java.util.LinkedHashMap(); error.put("code", Integer.valueOf(-32000)); - error.put("message", "backend unreachable: " + err.getMessage()); + error.put("message", message); java.util.Map response = new java.util.LinkedHashMap(); response.put("jsonrpc", "2.0"); response.put("id", ((java.util.Map) request).get("id")); response.put("error", error); return com.codename1.backend.Json.write(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/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 5f78319fbf0..257932e45f5 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -734,7 +734,14 @@ public HttpServer.Response handle(HttpServer.Request request) } } - /// What an [Application] is built from. + /// Whether `host` names this machine's loopback interface. + static boolean isLoopback(String host) { + String h = host.trim(); + return "localhost".equalsIgnoreCase(h) || h.startsWith("127.") + || "::1".equals(h) || "[::1]".equals(h); + } + + /// What an [Application] is built from. public static final class Environment { private final Config config; private final DataSource dataSource; @@ -1242,6 +1249,29 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { 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; + if (mcpServer != null && !mcpServer.hasToken()) { + if (bindHost == null || bindHost.length() == 0) { + bindHost = "127.0.0.1"; + if (!quiet) { + System.out.println("cn1: the MCP endpoint has no token, so the server " + + "listens on 127.0.0.1 only; set " + + com.codename1.backend.mcp.McpServer.TOKEN + + " 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 " + + com.codename1.backend.mcp.McpServer.TOKEN + + ", or bind to a loopback address"); + } + } if (factory != null) { HttpServer.Handler[] built = factory.create(pool, manager); if (built != null) { @@ -1386,7 +1416,7 @@ public void register(HttpServer.WebSocketRegistry direct) } }; } - server = HttpServer.start(host, listenPort, listenBacklog, workerCount, + server = HttpServer.start(bindHost, listenPort, listenBacklog, workerCount, new Serving(chain, sessions, tasks, requestLog, instrumented, app, track, active != null ? active : Tracing.NONE), context, routes); diff --git a/vm/backend/src/com/codename1/backend/CronSchedule.java b/vm/backend/src/com/codename1/backend/CronSchedule.java index f9af380ae85..5b56894d450 100644 --- a/vm/backend/src/com/codename1/backend/CronSchedule.java +++ b/vm/backend/src/com/codename1/backend/CronSchedule.java @@ -392,20 +392,29 @@ public long next(long afterMillis) { continue; } long candidateLocal = day * DAY + found * 1000L; - long candidate = toUtc(candidateLocal); - if (candidate + offsetAt(candidate) != candidateLocal) { + 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; } - if (candidate > afterMillis) { - return candidate; + // A time the clocks pass twice -- 01:30 on a fall-back night -- is the + // FIRST of the two, as Spring and Quartz fire it; the second only when + // the search starts after the first, as a server started in that + // repeated hour does. + if (first > afterMillis) { + return first; } - // A DST fold mapped this back to or before the start: step past it. - t = candidate <= t ? t + 1000L : candidate + 1000L; + long second = localToUtc(candidateLocal, false); + if (second > afterMillis) { + return second; + } + // Both are at or before the start: step past them. + t = second <= t ? t + 1000L : second + 1000L; } return -1; } @@ -480,6 +489,28 @@ private int offsetAt(long utc) { (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; diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index e4b4254cc5a..dade7965f87 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -265,6 +265,13 @@ Object dispatch(Object message) { // sends nothing, so either way there is nothing to answer. return notification ? null : rpcError(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 notification ? null : rpcError(id, -32600, "Invalid request: jsonrpc " + + "must be \"2.0\""); + } String method = (String) methodValue; Map params = m.get("params") instanceof Map ? (Map) m.get("params") : new LinkedHashMap(); // A notification is still an invocation -- an id-less tools/call runs its @@ -423,6 +430,13 @@ private static String hostOf(String origin) { 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; From c8053ea1ef1b462095d23d95516622455e5fd471 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:33:23 +0300 Subject: [PATCH 21/48] Backend: spare pre-rotation ids, answer malformed MCP, drain before metrics off - A session rotated by a request still running is spared under the id its row and cookie still have, as well as its new one: a purge or another request's lookup used to see only the new id as busy and delete the row the running request was about to move. - MCP: an id-less object that is not a valid request (no method, or not JSON-RPC 2.0) is answered with Invalid Request under a null id; only a valid notification goes unanswered. A response object is ignored. - The stdio bridge answers a batch the backend refused or never received with one error per request id in it. - A request handler, websocket callback or executor task that calls Backend.stop() while another stop is under way returns instead of waiting: waiting held the very work the first stop's drain waited for. - Server metrics are turned off after the background-task drain, so a scheduled run that ends during it still records its duration. - A start that fails after the listener bound closes the session-scoped beans a request may already have built. - A float argument too small for a float is refused rather than made 0. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 138 ++++++++++++++++++ .../codename1/backend/mcp/DevToolsTest.java | 14 ++ .../codename1/backend/mcp/StdioBridge.java | 25 +++- .../src/com/codename1/backend/Backend.java | 23 ++- .../src/com/codename1/backend/HttpServer.java | 7 + .../src/com/codename1/backend/Sessions.java | 11 +- .../com/codename1/backend/TaskExecutor.java | 5 + .../com/codename1/backend/mcp/McpArgs.java | 8 +- .../com/codename1/backend/mcp/McpServer.java | 14 +- 9 files changed, 232 insertions(+), 13 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index fd86faadd6e..f743cb61921 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -1734,6 +1734,144 @@ void cronInAFallBackOverlap() { c.next(midnightUtc + 5 * hour + 30 * 60000L)); } + @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.Jdbc(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]; + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet() + .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"); + } + /** A tracer that records nothing, for tests that only check which one is used. */ static class QuietTracer implements Tracer { public boolean open(Config config) { 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 index 8c30a88777f..956066f9069 100644 --- a/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java @@ -122,4 +122,18 @@ void aRefusalKeepsTheRequestId() throws Exception { 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)); + } } diff --git a/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java b/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java index cb3a96e2d1c..e4c5adfb426 100644 --- a/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java +++ b/vm/backend/impl/javase/com/codename1/backend/mcp/StdioBridge.java @@ -150,7 +150,7 @@ static String unreachable(String body, Exception err) { /// 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)) { + if (!(request instanceof java.util.Map) && !(request instanceof java.util.List)) { return answer; } String detail = answer == null ? "" : answer.trim(); @@ -171,20 +171,39 @@ static String refused(String body, int status, String answer) { /// 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", ((java.util.Map) request).get("id")); + response.put("id", id); response.put("error", error); - return com.codename1.backend.Json.write(response); + return response; } private static Object parse(String text) { diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 257932e45f5..3b188afcb05 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -226,6 +226,15 @@ public void stop() { // 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(); @@ -267,17 +276,19 @@ private void stopOnce() { } } 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. + // 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]); } - // 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); // @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 @@ -1463,6 +1474,10 @@ public void register(HttpServer.WebSocketRegistry direct) } 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 (metricReader != null) { metricReader.shutdown(0); diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 502aca6d0fa..a0898c5bcd7 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -2301,6 +2301,13 @@ 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()); + } + /// workOutstanding(), minus what the calling handler is itself holding. /// /// A handler that calls stop() holds one in-flight request and one active diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 20434b33303..47306a62766 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -490,7 +490,16 @@ private java.util.Set idsInUse() { java.util.Set ids = new java.util.HashSet(); Iterator it = inUse.keySet().iterator(); while (it.hasNext()) { - ids.add(((HttpSession) it.next()).getId()); + 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; } diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index fa2174b812d..fe354c5686c 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -42,6 +42,11 @@ public final class TaskExecutor { /// 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; private final boolean virtual; private final int size; diff --git a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java index 5b816534622..35494019cb9 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java @@ -194,7 +194,13 @@ public static float floatValue(Map args, String name, boolean required) { if (Double.isNaN(d) || Double.isInfinite(d) || Math.abs(d) > Float.MAX_VALUE) { throw new IllegalArgumentException("\"" + name + "\" is out of range"); } - return (float) d; + 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) { diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index dade7965f87..1e78b0ccf53 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -261,15 +261,21 @@ Object dispatch(Object message) { boolean notification = !m.containsKey("id"); Object methodValue = m.get("method"); if (!(methodValue instanceof String)) { - // A response to something the server sent, or garbage; the server - // sends nothing, so either way there is nothing to answer. - return notification ? null : rpcError(id, -32600, "Invalid request"); + 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 notification ? null : rpcError(id, -32600, "Invalid request: jsonrpc " + return rpcError(notification ? null : id, -32600, "Invalid request: jsonrpc " + "must be \"2.0\""); } String method = (String) methodValue; From 3c2368434f11fd2550fb73eea2b67fe97e281fe7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:51:05 +0300 Subject: [PATCH 22/48] Backend: strict loopback, JSON-RPC id types, @Async scope, savepoint failures - A tokenless MCP listener's address must be loopback by definition: localhost, ::1, or a numeric 127/8 literal. A prefix test took a name such as 127.backend.example for loopback wherever it resolved. - The MCP start-up line advertises the address the listener is bound to (bracketed for IPv6), not 127.0.0.1 regardless. - MCP refuses a request whose id is an object, array or boolean, with a null id and before its method runs. - Beans with @Async methods join the scoped-dependency check: their tasks run on an executor with no request current, so a request- or session-scoped bean reached from one throws on first use. - A savepoint that cannot be set marks the outer transaction rollback-only, so a caller that catches the failure cannot commit half of a flush or report success for work the server rolled back. - A metrics exporter hand-over starts the successor only while it is still open and first in line, under the lock its own shutdown takes; a successor that stopped meanwhile used to get a thread nothing stopped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 56 +++++++++++++++++++ .../codename1/backend/TransactionsTest.java | 19 +++++++ .../backend/otel/OtlpMetricsProtoTest.java | 21 +++++++ .../maven/processors/BackendBeans.java | 23 +++++++- .../maven/processors/BackendBeansTest.java | 16 ++++++ .../src/com/codename1/backend/Backend.java | 47 ++++++++++++++-- .../com/codename1/backend/Transactions.java | 6 ++ .../com/codename1/backend/mcp/McpServer.java | 8 +++ .../backend/otel/OtlpMetricExporter.java | 21 ++++++- 9 files changed, 209 insertions(+), 8 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index f743cb61921..94e3777b7bf 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -1872,6 +1872,62 @@ public void stopping() { "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"); + } + /** A tracer that records nothing, for tests that only check which one is used. */ static class QuietTracer implements Tracer { public boolean open(Config config) { diff --git a/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java index db1a1c7619d..8591e9f9dc7 100644 --- a/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java @@ -137,6 +137,25 @@ void requiresNewIsIndependent() throws Exception { 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 { 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 index 2f4f211690d..767b9797a07 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -227,4 +227,25 @@ void oneExporterPerIdentityExports() throws Exception { 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"); + } } 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 index a419ba59ebd..5fb7079156c 100644 --- 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 @@ -1146,6 +1146,22 @@ private void pickEntryPackage() { */ 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(); @@ -1521,9 +1537,11 @@ private void resolve() { // there -- directly, or through a singleton that injects it -- throws on // first use. Only the direct injections used to be checked. 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" : null; + : b.managed != null ? "@ManagedResource bean" + : async ? "Bean with @Async methods" : null; if (role == null || b.cls == null) { continue; } @@ -1538,7 +1556,8 @@ private void resolve() { } String outside = b.webSocket ? "A websocket callback runs" : !b.jobs.isEmpty() ? "A scheduled job runs" - : "A managed attribute is read"; + : b.managed != null ? "A managed attribute is read" + : "An @Async call runs on an executor, even one a request made,"; ctx.error(b.cls, role + " " + b.describe() + " reaches " + d.describe() + via + ", which is " + d.scope + "-scoped. " + outside + " outside any HTTP " + "request, so there is no " + d.scope + " to find it in; inject a singleton" 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 index 6180fbb4590..9ac2332bd47 100644 --- 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 @@ -1214,6 +1214,22 @@ public void scopedBeansReachedThroughASingletonAreRefusedOffRequest() throws Exc assertNoErrors(process(compile(s))); } + @Test + public void anAsyncBeanReachingAScopedBeanIsRefused() 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"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("Bean with @Async methods mailer (com.example.Mailer) " + + "reaches visit (com.example.Visit) through helper (com.example.Helper)")); + } + @Test public void aPrototypeControllerIsBuiltOnce() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 3b188afcb05..7461823e006 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -745,14 +745,51 @@ public HttpServer.Response handle(HttpServer.Request request) } } + /// 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) || "::".equals(h) || "[::]".equals(h)) { + return "127.0.0.1"; + } + 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(); - return "localhost".equalsIgnoreCase(h) || h.startsWith("127.") - || "::1".equals(h) || "[::1]".equals(h); + if ("localhost".equalsIgnoreCase(h) || "::1".equals(h) || "[::1]".equals(h) + || "0:0:0:0:0:0:0:1".equals(h)) { + 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. + /// What an [Application] is built from. public static final class Environment { private final Config config; private final DataSource dataSource; @@ -1502,8 +1539,8 @@ public void register(HttpServer.WebSocketRegistry direct) if (!quiet) { // The line an agent's setup instructions point at. System.out.println("cn1: MCP endpoint at http" - + (context != null ? "s" : "") + "://127.0.0.1:" - + server.getPort() + mcpServer.getPath() + + (context != null ? "s" : "") + "://" + advertised(bindHost) + + ":" + server.getPort() + mcpServer.getPath() + (mcpServer.hasDevTools() ? " (with development tools)" : "")); } } diff --git a/vm/backend/src/com/codename1/backend/Transactions.java b/vm/backend/src/com/codename1/backend/Transactions.java index 02730ca1f91..40ce2d28cca 100644 --- a/vm/backend/src/com/codename1/backend/Transactions.java +++ b/vm/backend/src/com/codename1/backend/Transactions.java @@ -272,6 +272,12 @@ private static Transaction nested(Physical current) { 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); } diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index 1e78b0ccf53..8c1a48eb6c1 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -259,6 +259,14 @@ Object dispatch(Object message) { // 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")) { diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index cbc53e1c73e..18f938fee42 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -146,6 +146,25 @@ public boolean open(Config config) throws IOException { return true; } + /// 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() { + 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(); + } + } + /// Starts this exporter's thread: it is the one exporting the process's metrics. private void startExporting() { final Run mine = new Run(); @@ -244,7 +263,7 @@ public void shutdown(int timeoutMillis) { // 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. - successor.startExporting(); + successor.takeOver(); } Thread exporter; synchronized (lock) { From 70b26f263f9316900a51eff74edf143b6465cca1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:49:16 +0300 Subject: [PATCH 23/48] Backend: retire shared session beans, drain gauge reads, bound-address calls - An invalidation no longer destroys session-scoped beans another request still holds -- a database store hands each request its own copy. The beans are retired: that request keeps using them rather than building new ones under the deleted id, and the last one to leave destroys them. - Removing a shared gauge source waits (bounded) for reads of it already running, and a read skips a source removed since its list was copied; the server's own exporter now shuts down before its beans are destroyed and the pool closed, not after. A gauge used to be called during its bean's @PreDestroy, or against a closed pool. - backend_call sends its request to the address the listener is bound to (Backend.getListenAddress), not 127.0.0.1 regardless. - A histogram decides whether to export its unlabelled series under the lock that adds labelled ones, so a racing first observation no longer adds a transient zero series. - A task virtual thread abandoned at the drain deadline before its first turn has its token removed from VIRTUAL_TASKS, which otherwise kept the task and everything it captured for the life of the process. - An executor whose virtual hand-off is refused wakes a shutdown() waiting on the count it just gave back. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 124 ++++++++++++++++++ .../src/com/codename1/backend/Backend.java | 19 ++- .../src/com/codename1/backend/HttpServer.java | 14 ++ .../src/com/codename1/backend/Sessions.java | 92 ++++++++++++- .../com/codename1/backend/TaskExecutor.java | 3 + .../com/codename1/backend/mcp/DevTools.java | 5 +- .../codename1/backend/metrics/Histogram.java | 9 +- .../codename1/backend/metrics/Metrics.java | 44 +++++++ 8 files changed, 298 insertions(+), 12 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 94e3777b7bf..297266e69ba 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -1928,6 +1928,130 @@ public Object call(Map arguments) { 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("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.Jdbc(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); + } + /** A tracer that records nothing, for tests that only check which one is used. */ static class QuietTracer implements Tracer { public boolean open(Config config) { diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 7461823e006..0038d0ddf85 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -158,6 +158,15 @@ public static Builder builder(Config config) { return new Builder(config); } + private String listenAddress = "127.0.0.1"; + + /// 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; @@ -289,6 +298,12 @@ private void stopOnce() { 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) { + metricReader.shutdown(shutdownMillis); + } // @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 @@ -304,9 +319,6 @@ private void stopOnce() { if (dataSource != null) { dataSource.close(); } - if (metricReader != null) { - metricReader.shutdown(shutdownMillis); - } // 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. @@ -1522,6 +1534,7 @@ public void register(HttpServer.WebSocketRegistry direct) } } backend.measured = measuring; + backend.listenAddress = advertised(bindHost); // Backend.stop() tears the beans down from here on. createdApplication = null; startingTasks = null; diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index a0898c5bcd7..f88442cbc33 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -2636,6 +2636,9 @@ private void drainTaskInbox(VtHost me) { // 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) { @@ -2655,6 +2658,7 @@ 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; } @@ -2737,6 +2741,11 @@ boolean ringEmpty() { /// 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(); boolean hasQueuedTasks() { synchronized (inbox) { @@ -3043,6 +3052,11 @@ private void drainTasksAfterStop(VtHost me) { long handle = me.ringTake(); if (VirtualThread.descriptorOf(handle) < 0) { Runnable task = (Runnable) me.tasks.remove(Long.valueOf(handle)); + Long token = (Long) me.taskTokens.remove(Long.valueOf(handle)); + if (token != null) { + // A no-op for a task that started: runVirtual removed it. + takeVirtualTask(token.longValue()); + } VirtualThread.free(handle); if (task != null) { TaskExecutor.abandoned(task); diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 47306a62766..6b3fe42a0ec 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -346,11 +346,12 @@ HttpServer.Response finish(HttpServer.Request request, HttpSession session, } if (!session.isValid()) { s.delete(session.getId()); - // The beans end with the session, not with whatever next finds it gone. - destroy(take(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(take(previous)); + destroy(retire(previous, session)); } destroy(session.takeLocalBeans()); cookie = cookie("", 0); @@ -437,6 +438,11 @@ synchronized Object beanLock(HttpSession session) { /// 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]. synchronized void enter(HttpServer.Request request, HttpSession session) { @@ -459,10 +465,35 @@ synchronized void enter(HttpServer.Request request, HttpSession session) { } /// The request is over; the sessions it used may expire again. - synchronized void leave(HttpServer.Request request) { + 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; + return false; } request.sessionsInUse = null; // With the list: a pooled request would otherwise hold every session a @@ -483,6 +514,47 @@ synchronized void leave(HttpServer.Request request) { 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. @@ -507,6 +579,14 @@ private java.util.Set idsInUse() { 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 @@ -553,7 +633,9 @@ void close() { 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); diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index fe354c5686c..2e62d4aae66 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -131,6 +131,9 @@ public void execute(Runnable task) { } 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) { diff --git a/vm/backend/src/com/codename1/backend/mcp/DevTools.java b/vm/backend/src/com/codename1/backend/mcp/DevTools.java index bc46e079590..da1e9737d97 100644 --- a/vm/backend/src/com/codename1/backend/mcp/DevTools.java +++ b/vm/backend/src/com/codename1/backend/mcp/DevTools.java @@ -306,7 +306,10 @@ private Map sendRequest(Map a) throws Exception { // 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"; - Web.Result result = Web.request(asciiUpper(method), scheme + "://127.0.0.1:" + port + // 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())); diff --git a/vm/backend/src/com/codename1/backend/metrics/Histogram.java b/vm/backend/src/com/codename1/backend/metrics/Histogram.java index d8b270d7fd0..bdfca65b720 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Histogram.java +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -167,10 +167,13 @@ private static boolean same(Object a, Object b) { @Override public List points() { List out = new ArrayList(); - if (plain.count() > 0 || byFirst.isEmpty()) { - out.add(plain.point(bounds, null, false)); - } 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(); diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index f35567581c0..321702a6ea6 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -216,6 +216,9 @@ public static long startTimeMillis() { /// Gauges several servers contribute to: name -> List of Gauge.Source. private static final Map SHARED = new HashMap(); + /// Shared-gauge source reads in progress, under Metrics.class. A removal waits + /// for them: the server removing its source destroys the bean behind it next. + private static int sourceReads; /// Adds one server's source to the gauge called `name`, which reports /// the sum of every source still registered. A server's managed-resource @@ -241,6 +244,12 @@ public double read() { 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)) { @@ -249,6 +258,8 @@ public double read() { } } catch (Throwable err) { // One source failing leaves the others' values. + } finally { + endSourceRead(); } } return any ? sum : Double.NaN; @@ -271,8 +282,41 @@ public static synchronized void removeSource(String name, Gauge.Source source) { 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 (sourceReads > 0) { + 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; + } + sourceReads++; + return true; } + private static synchronized void endSourceRead() { + sourceReads--; + 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(); From cf0746dcaf47486e9b2bbf407798897c223a523a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:41:01 +0300 Subject: [PATCH 24/48] Backend: per-source gauge drains, executor kinds, unannounced rotations, params - Shared-gauge reads are counted per source, so a gauge stuck in its callback delays only its own removal rather than every gauge a stopping server removes, two seconds each. - One named executor asked for two kinds of thread is refused: at build time between @Async and @Scheduled declarations, and at run time for executors asked for in code. Whichever ran first used to decide, so a PLATFORM database method could run on the virtual hosts. AUTO agrees with either, and configuration still overrides. - A gauge without a name is refused, as counters and histograms are; it rendered a nameless Prometheus sample the scraper rejected. - A rotation a failed request cannot announce is undone on the session itself, and the request's other changes are saved under the id the client still has. Only the beans used to move back. - MCP params that are not an object are Invalid params, not treated as an empty call. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 110 ++++++++++++++++++ .../maven/processors/BackendBeans.java | 44 +++++++ .../maven/processors/BackendBeansTest.java | 16 +++ .../src/com/codename1/backend/Sessions.java | 11 ++ .../src/com/codename1/backend/Tasks.java | 24 ++++ .../com/codename1/backend/mcp/McpServer.java | 10 +- .../codename1/backend/metrics/Metrics.java | 34 ++++-- 7 files changed, 240 insertions(+), 9 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 297266e69ba..59d7abbc458 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -2052,6 +2052,116 @@ public void run() { 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.Jdbc(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); + } + /** A tracer that records nothing, for tests that only check which one is used. */ static class QuietTracer implements Tracer { public boolean open(Config config) { 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 index 5fb7079156c..7179405420d 100644 --- 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 @@ -357,6 +357,9 @@ static BackendBeans prepare(ProcessorContext ctx) throws ProcessingException { if (!ctx.hasErrors()) { out.collectAspects(); } + if (!ctx.hasErrors()) { + out.checkExecutorKinds(); + } if (!ctx.hasErrors()) { out.plan(); } @@ -1988,6 +1991,47 @@ private void warnInheritedOutsideClassAspect(AnnotatedClass cls, String what) { /// 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 database method + /// could end up on the virtual hosts. AUTO agrees with either; unnamed + /// executors are already named by their kind. + 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."); + } + } + private void collectAspects() { for (AnnotatedClass cls : ctx.getClassIndex().values()) { if (!concerns(cls) || cls.isInterface()) { 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 index 9ac2332bd47..04f851ae093 100644 --- 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 @@ -1230,6 +1230,22 @@ public void anAsyncBeanReachingAScopedBeanIsRefused() throws Exception { + "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 aPrototypeControllerIsBuiltOnce() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 6b3fe42a0ec..25751d48028 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -341,6 +341,17 @@ HttpServer.Response finish(HttpServer.Request request, HttpSession session, beans.put(previous, moved); } } + // 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. + session.undoRotation(previous); + if (session.isDirty()) { + s.save(session, null); + session.clean(); + } + keep(session, null); } return null; } diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index 796d6ad44a0..de19212c613 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -23,6 +23,7 @@ package com.codename1.backend; import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -75,6 +76,11 @@ 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; @@ -177,6 +183,17 @@ static TaskExecutor executor(Registry registry, String name, int kind) { 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) { + registry.requestedKinds.put(key, Integer.valueOf(kind)); + } return existing; } if (registry.shutdown) { @@ -211,12 +228,19 @@ static TaskExecutor executor(Registry registry, String name, int kind) { } 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 -- diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index 8c1a48eb6c1..64fe85fad6a 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -287,7 +287,15 @@ Object dispatch(Object message) { + "must be \"2.0\""); } String method = (String) methodValue; - Map params = m.get("params") instanceof Map ? (Map) m.get("params") : new LinkedHashMap(); + 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); diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index 321702a6ea6..8db0b16abbd 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -116,6 +116,11 @@ public static Gauge gauge(String name, String description, String unit, } private static synchronized void replaceGauge(Gauge g) { + 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() @@ -216,9 +221,11 @@ public static long startTimeMillis() { /// Gauges several servers contribute to: name -> List of Gauge.Source. private static final Map SHARED = new HashMap(); - /// Shared-gauge source reads in progress, under Metrics.class. A removal waits - /// for them: the server removing its source destroys the bean behind it next. - private static int sourceReads; + /// 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 @@ -259,7 +266,7 @@ public double read() { } catch (Throwable err) { // One source failing leaves the others' values. } finally { - endSourceRead(); + endSourceRead(element); } } return any ? sum : Double.NaN; @@ -286,7 +293,7 @@ public static synchronized void removeSource(String name, Gauge.Source source) { // 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 (sourceReads > 0) { + while (SOURCE_READS.containsKey(source)) { long left = deadline - System.currentTimeMillis(); if (left <= 0) { break; @@ -305,12 +312,23 @@ private static synchronized boolean beginSourceRead(List all, Object source) { if (!all.contains(source)) { return false; } - sourceReads++; + 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() { - sourceReads--; + 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(); } From f0cc877dfede5cd3d1d162a025a9bdbc6f20cb32 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:24:06 +0300 Subject: [PATCH 25/48] Backend: one cron run per repeated time, strict MCP args, masking, DevTools reuse - A cron time the clocks pass twice fires once, at the first occurrence, from whichever side of the change the search starts. It used to skip to the next day from the old offset and return the repeat from the new one, so the answer depended on which side of the change the search began. After a run at the first, the scheduler searches from just past it, where returning the repeat would run a daily job twice. The search steps past the repeat within the day, so the other times of that day still count. Documented in the scheduling chapter. - tools/call arguments that are not an object are Invalid params; the tool used to run with none. - backend_config masks keys naming headers or auth, and values carrying a credential as name=value -- cn1.otel.headers=api-key=..., a datasource URL's ?password=... -- whatever their key. - One DevTools instance given to two servers no longer points the first server's tools at the second: a later installation gets its own instance. - The MCP and management paths are canonicalized as request targets are (Config.getRoutePath), so /%6dcp is served at /mcp instead of never. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Backend-Scheduling.asciidoc | 5 +- .../backend/ApplicationRuntimeTest.java | 115 ++++++++++++++++-- .../codename1/backend/mcp/DevToolsTest.java | 11 ++ .../src/com/codename1/backend/Config.java | 9 ++ .../com/codename1/backend/CronSchedule.java | 19 +-- .../src/com/codename1/backend/Management.java | 2 +- .../com/codename1/backend/mcp/DevTools.java | 42 ++++++- .../com/codename1/backend/mcp/McpServer.java | 12 +- 8 files changed, 190 insertions(+), 25 deletions(-) diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc index 5022b673718..fe17ff6334b 100644 --- a/docs/developer-guide/Backend-Scheduling.asciidoc +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -198,7 +198,10 @@ 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. +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 diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 59d7abbc458..1870d86299b 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -1721,17 +1721,25 @@ void onlyJsonRpc2IsDispatched() throws Exception { } @Test - @DisplayName("a time the clocks pass twice fires at the first, or the second when started between") + @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; - // 2026-11-01: 01:30 EDT is 05:30Z and 01:30 EST is 06:30Z. + 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 - assertEquals(midnightUtc + 5 * hour + 30 * 60000L, c.next(midnightUtc + 4 * hour)); - assertEquals(midnightUtc + 6 * hour + 30 * 60000L, c.next(midnightUtc + 6 * hour)); - // After the first, the next is the following day's, not the repeat. - assertEquals(midnightUtc + 30 * hour + 30 * 60000L, - c.next(midnightUtc + 5 * hour + 30 * 60000L)); + 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 @@ -2162,6 +2170,99 @@ void nonObjectParamsAreRefused() throws Exception { 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")); + } + + @Test + @DisplayName("one DevTools given to two servers answers each from its own server") + void devToolsStayWithTheirServer() throws Exception { + com.codename1.backend.mcp.DevTools shared = new com.codename1.backend.mcp.DevTools(); + int portA = freePort(); + int portB = freePort(); + Backend a = startAnswering(portA, "from A", shared); + Backend b = startAnswering(portB, "from B", shared); + try { + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + portA + + "/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("from A"), "server A's tool reached another server: " + + answer); + } finally { + b.stop(); + a.stop(); + } + } + + 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(); + } + /** A tracer that records nothing, for tests that only check which one is used. */ static class QuietTracer implements Tracer { public boolean open(Config config) { 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 index 956066f9069..8b2cee495e5 100644 --- a/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java @@ -136,4 +136,15 @@ void aFailedBatchAnswersEveryId() throws Exception { 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/vm/backend/src/com/codename1/backend/Config.java b/vm/backend/src/com/codename1/backend/Config.java index 7feb95a57cc..de6147f7072 100644 --- a/vm/backend/src/com/codename1/backend/Config.java +++ b/vm/backend/src/com/codename1/backend/Config.java @@ -250,6 +250,15 @@ 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. /// diff --git a/vm/backend/src/com/codename1/backend/CronSchedule.java b/vm/backend/src/com/codename1/backend/CronSchedule.java index 5b56894d450..f68bbed82ab 100644 --- a/vm/backend/src/com/codename1/backend/CronSchedule.java +++ b/vm/backend/src/com/codename1/backend/CronSchedule.java @@ -402,19 +402,20 @@ public long next(long afterMillis) { t = candidate > t ? candidate : t + 1000L; continue; } - // A time the clocks pass twice -- 01:30 on a fall-back night -- is the - // FIRST of the two, as Spring and Quartz fire it; the second only when - // the search starts after the first, as a server started in that - // repeated hour does. + // 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); - if (second > afterMillis) { - return second; - } - // Both are at or before the start: step past them. - t = second <= t ? t + 1000L : second + 1000L; + t = second >= t ? second + 1000L : t + 1000L; } return -1; } diff --git a/vm/backend/src/com/codename1/backend/Management.java b/vm/backend/src/com/codename1/backend/Management.java index a50f6263ae1..e8d4db2c65a 100644 --- a/vm/backend/src/com/codename1/backend/Management.java +++ b/vm/backend/src/com/codename1/backend/Management.java @@ -84,7 +84,7 @@ public static Management fromConfig(Config config) throws IOException { + "by anyone who can reach the port; set a token, or leave the " + "endpoints off."); } - String path = config.get(PATH, "/manage"); + String path = config.getRoutePath(PATH, "/manage"); while (path.endsWith("/")) { path = path.substring(0, path.length() - 1); } diff --git a/vm/backend/src/com/codename1/backend/mcp/DevTools.java b/vm/backend/src/com/codename1/backend/mcp/DevTools.java index da1e9737d97..4e1a9cc525d 100644 --- a/vm/backend/src/com/codename1/backend/mcp/DevTools.java +++ b/vm/backend/src/com/codename1/backend/mcp/DevTools.java @@ -64,9 +64,22 @@ 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 = backend; + Backend running = installed(); if (running == null) { throw new IllegalStateException("The development tools are not installed"); } @@ -75,7 +88,14 @@ private Backend backend() { @Override public void install(McpServer server, Backend running) { - this.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", @@ -224,7 +244,7 @@ Object run(Map a) throws Exception { } private Backend.Application application() { - return backend == null ? null : backend().getApplication(); + return installed() == null ? null : backend().getApplication(); } private Map config() throws Exception { @@ -250,12 +270,26 @@ private Map config() throws Exception { static boolean secret(String key, String value) { String k = key; - String[] marks = {"password", "secret", "token", "key", "credential"}; + // "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("://"); diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index 64fe85fad6a..add5f20afeb 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -129,7 +129,7 @@ public static McpServer fromConfig(Config config, Extension devTools, String ser + TOKEN + " is not set, so anyone who can reach the port could call its " + "tools. Set a token, or " + ENABLED + "=false."); } - String path = config.get(PATH, "/mcp"); + String path = config.getRoutePath(PATH, "/mcp"); if (!path.startsWith("/")) { throw new IOException(PATH + " must start with /"); } @@ -383,8 +383,14 @@ private Map callTool(Map params) throws Exception { if (!(name instanceof String)) { throw new IllegalArgumentException("tools/call needs a tool name"); } - Map arguments = params.get("arguments") instanceof Map ? (Map) params.get("arguments") - : new LinkedHashMap(); + 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; From 2804983e02e3637616eacdc81a9585e6ecc411bb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:53:53 +0300 Subject: [PATCH 26/48] Backend: tear down after an in-flight stop caller, one server per builder - A stop() called from a request, a websocket callback or a task drains as before -- discounting its caller -- but defers the teardown (beans destroyed, pool closed) to a thread that waits, up to the shutdown timeout, for that caller to leave. Requests and callbacks are counted in and out for it. The caller used to go on running against destroyed beans and a closed pool, and its own request stored its session into them. The tracer's shutdown is arranged on the caller's request span first, so that request still exports its trace. - A histogram ignores values past its label keys when telling series apart; a one-label histogram recorded with two second values exported two series under one label set. - A new session whose first save throws has its session-scoped beans destroyed and any half-written row removed: no cookie was sent, so nothing could reach it again. - Backend.Builder.sessionStore installs a store before the server listens, and Sessions.setStore refuses once a session has been looked up: a store set after start() lost the sessions already made. - A builder refuses to start a second server while its first is running: both shared the generated application's beans and scheduler, and stopping the first destroyed the second's. It may start again after. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 141 ++++++++++++ .../src/com/codename1/backend/Backend.java | 212 ++++++++++++++++-- .../src/com/codename1/backend/Sessions.java | 39 +++- .../com/codename1/backend/TaskExecutor.java | 16 ++ .../src/com/codename1/backend/Tasks.java | 12 + .../src/com/codename1/backend/Tracing.java | 18 +- .../codename1/backend/metrics/Histogram.java | 9 +- 7 files changed, 425 insertions(+), 22 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 1870d86299b..25c2ab47d20 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -2263,6 +2263,147 @@ public HttpServer.Response handle(HttpServer.Request request) }).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(); + } + /** A tracer that records nothing, for tests that only check which one is used. */ static class QuietTracer implements Tracer { public boolean open(Config config) { diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 0038d0ddf85..16c25e16857 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -257,16 +257,104 @@ public void stop() { } 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 { - stopOnce(); + 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); - synchronized (this) { - stopping = false; - stopped = true; - notifyAll(); + 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 synchronized void markStopped() { + stopping = false; + stopped = true; + notifyAll(); + } + + /// 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; + } } } } @@ -274,7 +362,8 @@ public void stop() { private boolean stopping; private boolean stopped; - private void stopOnce() { + /// 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) { @@ -304,6 +393,13 @@ private void stopOnce() { if (metricReader != null) { metricReader.shutdown(shutdownMillis); } + } + + /// 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 @@ -322,7 +418,7 @@ private void stopOnce() { // 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); } } @@ -445,11 +541,20 @@ static HttpServer.WebSocketRegistry withTasks(final HttpServer.WebSocketRegistry 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)); + : new TaskBound(endpoint, tasks, tracer, inFlight)); } @Override @@ -460,7 +565,8 @@ public WebSocket open(HttpServer.Request request) throws Exception { Object previous = Tasks.enter(tasks); try { WebSocket endpoint = router.open(request); - return endpoint == null ? null : new TaskBound(endpoint, tasks, tracer); + return endpoint == null ? null + : new TaskBound(endpoint, tasks, tracer, inFlight); } finally { Tasks.leave(previous); } @@ -478,24 +584,36 @@ 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); + 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 static void unbind(Object[] previous) { + private void unbind(Object[] previous) { Tracing.disown(previous[1]); Tasks.leave(previous[0]); + if (inFlight != null) { + inFlight.leave(); + } } @Override @@ -589,11 +707,14 @@ private static final class Serving implements HttpServer.Handler { /// 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) { + Application app, boolean track, Tracer tracer, InFlight inFlight) { this.tracer = tracer; + this.inFlight = inFlight; this.chain = chain; this.sessions = sessions; this.tasks = tasks; @@ -667,6 +788,7 @@ public HttpServer.Response handle(HttpServer.Request request) // 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); if (track) { @@ -711,7 +833,18 @@ public HttpServer.Response handle(HttpServer.Request request) } if (session != null) { attempted.add(session); - response = sessions.finish(request, session, response); + 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) { failed(request, attempted, startedMillis, err); @@ -752,6 +885,8 @@ public HttpServer.Response handle(HttpServer.Request request) Tasks.leave(previousTasks); Tracing.disown(previousOwner); sessions.leave(request); + // Last: a stop() this request made tears down only now. + inFlight.leave(); } } } @@ -1153,9 +1288,51 @@ public Builder quiet() { 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 { + claimStart(); + try { + Backend running = startOnce(); + synchronized (this) { + live = running; + } + return running; + } finally { + synchronized (this) { + starting = false; + } + } + } + + /// The Backend this builder last started, until it stops. + private Backend live; + private boolean starting; + + /// One live Backend per builder. The generated application is one object, + /// and a second create() replaces its beans and scheduler: stopping the + /// first server would then destroy what the second still uses. A builder + /// may start again once its previous server has stopped. + private synchronized void claimStart() { + if (starting || (live != null && !live.isStopped())) { + throw new IllegalStateException("This builder's server is still running; " + + "stop it before starting it again, or use another builder"); + } + starting = true; + } + + private Backend startOnce() throws Exception { if (config == null) { config = Config.load(); } @@ -1277,6 +1454,7 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { // 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 @@ -1440,6 +1618,9 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { // 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 @@ -1462,7 +1643,7 @@ public void register(HttpServer.WebSocketRegistry direct) // 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); + 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 @@ -1478,7 +1659,7 @@ public void register(HttpServer.WebSocketRegistry direct) } server = HttpServer.start(bindHost, listenPort, listenBacklog, workerCount, new Serving(chain, sessions, tasks, requestLog, instrumented, app, track, - active != null ? active : Tracing.NONE), + active != null ? active : Tracing.NONE, inFlight), context, routes); bound = true; } finally { @@ -1535,6 +1716,7 @@ public void register(HttpServer.WebSocketRegistry direct) } backend.measured = measuring; backend.listenAddress = advertised(bindHost); + backend.inFlight = inFlight; // Backend.stop() tears the beans down from here on. createdApplication = null; startingTasks = null; diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 25751d48028..561c575f0f9 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -62,6 +62,9 @@ public final class Sessions { /// 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. @@ -163,11 +166,24 @@ static boolean isToken(String name) { return true; } - /// Replaces the store, for one of the application's own. + /// 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; } @@ -201,6 +217,7 @@ HttpSession find(String cookieValue, boolean create) throws IOException { synchronized (this) { s = store; timeout = timeoutSeconds; + used = true; } long now = System.currentTimeMillis(); purgeIfDue(s, now); @@ -621,6 +638,26 @@ private synchronized Object[] take(String 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) { diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index 2e62d4aae66..48d9eca81aa 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -88,6 +88,22 @@ 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; diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index de19212c613..0acd7a42819 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -306,6 +306,18 @@ 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. diff --git a/vm/backend/src/com/codename1/backend/Tracing.java b/vm/backend/src/com/codename1/backend/Tracing.java index ab614b6ef97..dcf7500a292 100644 --- a/vm/backend/src/com/codename1/backend/Tracing.java +++ b/vm/backend/src/com/codename1/backend/Tracing.java @@ -587,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. diff --git a/vm/backend/src/com/codename1/backend/metrics/Histogram.java b/vm/backend/src/com/codename1/backend/metrics/Histogram.java index bdfca65b720..647c03216ba 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Histogram.java +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -124,9 +124,16 @@ public void record(double value) { /// 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 ? first : null; + Object b = labels.length > 1 && labels[1] != null ? second : null; + Object c = labels.length > 2 && labels[2] != null ? third : null; Series s; synchronized (this) { - s = find(first, second, third); + s = find(a, b, c); } s.record(value, bounds); } From a6b2aec685a4b98eb4e407893d13d5370ae2e0b7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:46:49 +0300 Subject: [PATCH 27/48] Backend: one transaction one pool, unwind overrunning tasks, session bean edges - A @Transactional method bound to one pool that touches another is refused (TransactionException.IllegalState), naming REQUIRES_NEW and NOT_SUPPORTED. The second pool used to hand out an ordinary connection that committed each statement, so a failure rolled back only the first database. - A virtual task still running at the drain deadline is no longer freed: free() does not unwind, so its finally blocks and monitor exits never ran and a lock it held stayed owned. A task that never started is still dropped; one that started runs on to completion past the deadline, as an overrunning platform task does, while the stop proceeds. - A host's wake byte is written, and its wake pipe closed, under the inbox lock, so a submitter cannot write into a descriptor shutdown has closed and the process has reused. - A request- or session-scoped bean inheriting an @Async method is refused like one declaring it. - A copy that rotates after another request invalidated its session uses the retired beans rather than building a set under the new id. - A histogram recorded with no label values goes to its unlabelled series rather than a second one with the same empty labels. - A database session's bean holder gets the touch-interval grace its row gets, so a request in that window does not find its beans destroyed and build a second set. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 75 +++++++++++++++ .../codename1/backend/TransactionsTest.java | 23 +++++ .../maven/processors/BackendBeans.java | 4 +- .../maven/processors/BackendBeansTest.java | 13 +++ .../src/com/codename1/backend/HttpServer.java | 93 ++++++++++++++----- .../src/com/codename1/backend/Sessions.java | 15 ++- .../com/codename1/backend/Transactions.java | 9 +- .../codename1/backend/metrics/Histogram.java | 6 ++ 8 files changed, 210 insertions(+), 28 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 25c2ab47d20..06e021e1182 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -2404,6 +2404,81 @@ void aBuilderRunsOneServerAtATime() throws Exception { 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.Jdbc(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 jdbcBeansGetTheRowsGrace(@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.Jdbc(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(); + } + } + /** A tracer that records nothing, for tests that only check which one is used. */ static class QuietTracer implements Tracer { public boolean open(Config config) { diff --git a/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java index 8591e9f9dc7..d52f19defc5 100644 --- a/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java @@ -137,6 +137,29 @@ void requiresNewIsIndependent() throws Exception { 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 savepoint that cannot be set leaves the outer transaction unable to commit") void aFailedSavepointMarksTheOuterRollbackOnly() throws Exception { 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 index 7179405420d..61938bfaa88 100644 --- 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 @@ -542,7 +542,9 @@ private Bean classBean(AnnotatedClass cls) { bindProperties(bean, props, cls); } if (REQUEST.equals(bean.scope) || SESSION.equals(bean.scope)) { - for (MethodInfo m : cls.getMethods()) { + // 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 (m.getAnnotation(ASYNC) != null || (cls.getClassAnnotation(ASYNC) != null && m.isPublic() && !m.isConstructor() && !m.isStatic())) { 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 index 04f851ae093..e701fafeca4 100644 --- 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 @@ -1246,6 +1246,19 @@ public void oneExecutorNameWithTwoThreadKindsIsRefused() throws Exception { assertNoErrors(process(compile(s))); } + @Test + public void aScopedBeanInheritingAnAsyncMethodIsRefused() 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"); + String errors = String.valueOf(process(compile(s)).getErrors()); + assertTrue(errors, errors.contains("@Async method com.example.Visit.later is on a " + + "@RequestScope bean")); + } + @Test public void aPrototypeControllerIsBuiltOnce() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index f88442cbc33..e9cfbc1f5e5 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -2575,9 +2575,12 @@ static boolean submitVirtualTask(Runnable task, HttpServer server) { return false; } host.inbox.add(Long.valueOf(token)); - } - if (host.wakeWrite >= 0) { - Reactor.wake(host.wakeWrite); + // 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; } @@ -2590,6 +2593,14 @@ private static void releaseTaskInbox(VtHost host) { 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()); @@ -2597,12 +2608,6 @@ private static void releaseTaskInbox(VtHost host) { TaskExecutor.fallBack(task); } } - if (host.wakeRead >= 0) { - ServerSocket.closeFd(host.wakeRead); - ServerSocket.closeFd(host.wakeWrite); - host.wakeRead = -1; - host.wakeWrite = -1; - } } /// The task a virtual thread's body runs, handed over once. @@ -3044,31 +3049,47 @@ private void drainTasksAfterStop(VtHost me) { return; } } - // Out of time: free what is left rather than leaking its stack. Its - // Future never completes, so say so. + // 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; int left = me.ringCount; while (left-- > 0 && !me.ringEmpty()) { long handle = me.ringTake(); - if (VirtualThread.descriptorOf(handle) < 0) { - Runnable task = (Runnable) me.tasks.remove(Long.valueOf(handle)); - Long token = (Long) me.taskTokens.remove(Long.valueOf(handle)); - if (token != null) { - // A no-op for a task that started: runVirtual removed it. - takeVirtualTask(token.longValue()); - } - VirtualThread.free(handle); - if (task != null) { - TaskExecutor.abandoned(task); - } - abandoned++; - } else { + 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++; } if (abandoned > 0) { System.err.println(abandoned + " background task(s) on virtual threads did " - + "not finish within the shutdown window and were abandoned"); + + "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) { @@ -3077,6 +3098,28 @@ private void drainTasksAfterStop(VtHost me) { } } + /// 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 (!me.ringEmpty()) { + 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) { + return; + } + } + } + /// 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, diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 561c575f0f9..c9645599e68 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -276,8 +276,13 @@ void purgeIfDue(SessionStore s, long now) { 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 Jdbc ? Jdbc.touchInterval(h.maxInactiveSeconds) : 0; if (h.maxInactiveSeconds > 0 - && now - h.lastAccessed > h.maxInactiveSeconds * 1000L + && now - h.lastAccessed > h.maxInactiveSeconds * 1000L + grace && !busy.contains(e.getKey())) { it.remove(); if (expired == null) { @@ -622,6 +627,14 @@ private Held holderFor(HttpSession session) { 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) { diff --git a/vm/backend/src/com/codename1/backend/Transactions.java b/vm/backend/src/com/codename1/backend/Transactions.java index 40ce2d28cca..83c84a7698c 100644 --- a/vm/backend/src/com/codename1/backend/Transactions.java +++ b/vm/backend/src/com/codename1/backend/Transactions.java @@ -531,7 +531,14 @@ private static Database materialize(Physical p, DataSource pool) throws IOExcept } p.pool = pool; } else if (pool != null && p.pool != pool) { //NOPMD CompareObjectsWithEquals - pools and connections are compared by identity - return null; + // 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; diff --git a/vm/backend/src/com/codename1/backend/metrics/Histogram.java b/vm/backend/src/com/codename1/backend/metrics/Histogram.java index 647c03216ba..5b8fe941015 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Histogram.java +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -131,6 +131,12 @@ public void record(double value, Object first, Object second, Object third) { Object a = labels.length > 0 && labels[0] != null ? first : null; Object b = labels.length > 1 && labels[1] != null ? second : null; Object c = labels.length > 2 && labels[2] != null ? 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); From b654de96db9bee79091ac8569fb99458a337a37a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:27:22 +0300 Subject: [PATCH 28/48] Backend: conditional @Primary fallbacks, scoped factory owners, metric labels - A conditional @Primary no longer discards the other candidates: the wiring uses it when its condition holds and the active alternative when not (Wiring.preferred). A prod-only primary beside a dev bean used to make the dev profile fail to start. More than one unconditional alternative is refused at build time. - A non-static @Bean method on a request- or session-scoped configuration that builds a bean of another scope is refused at build time: it is called through the scoped stand-in at start-up, with no request to find it in, and the server refused to start. - Histogram label values are widened before choosing a series (an Integer and a Long 200 export alike), the overflow label key is reserved -- also in the spelling Prometheus folds it to -- and Gauge.point refuses an empty label key. Each produced duplicate or nameless samples that make a scraper reject the whole exposition. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 18 ++++++++ .../maven/processors/BackendBeans.java | 43 +++++++++++++++++- .../maven/processors/BackendWiringWriter.java | 3 +- .../maven/processors/BackendBeansTest.java | 44 +++++++++++++++++++ .../src/com/codename1/backend/Wiring.java | 11 +++++ .../com/codename1/backend/metrics/Gauge.java | 5 +++ .../codename1/backend/metrics/Histogram.java | 31 +++++++++++-- 7 files changed, 150 insertions(+), 5 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 06e021e1182..11621d30d1a 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -2479,6 +2479,24 @@ public void sessionEnded(Object[] beans) { } } + @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) { 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 index 61938bfaa88..010c22a85e2 100644 --- 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 @@ -146,6 +146,9 @@ static final class Point { 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; @@ -856,6 +859,19 @@ private Bean factoryBean(Bean owner, MethodInfo m) { 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 @@ -1730,7 +1746,32 @@ private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { primaries.add(b); } } - if (primaries.size() == 1) { + 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) 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 index d59d923852e..53682d9a2d7 100644 --- 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 @@ -460,7 +460,8 @@ String point(BackendBeans.Point p) { } if (p.choice || (p.candidates.size() == 1 && p.candidates.get(0).isConditional())) { StringBuilder sb = new StringBuilder("(").append(type) - .append(") com.codename1.backend.Wiring.single(new Object[] {"); + .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(", "); 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 index e701fafeca4..3e54b6d3b93 100644 --- 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 @@ -1259,6 +1259,50 @@ public void aScopedBeanInheritingAnAsyncMethodIsRefused() throws Exception { + "@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(); diff --git a/vm/backend/src/com/codename1/backend/Wiring.java b/vm/backend/src/com/codename1/backend/Wiring.java index 900f8b5a6f1..f59b9d16961 100644 --- a/vm/backend/src/com/codename1/backend/Wiring.java +++ b/vm/backend/src/com/codename1/backend/Wiring.java @@ -150,6 +150,17 @@ public static Object produced(Object bean, String factory) { 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`. diff --git a/vm/backend/src/com/codename1/backend/metrics/Gauge.java b/vm/backend/src/com/codename1/backend/metrics/Gauge.java index 68f0297c066..1495f45e3cc 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Gauge.java +++ b/vm/backend/src/com/codename1/backend/metrics/Gauge.java @@ -57,6 +57,11 @@ public interface MultiSource { /// 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); diff --git a/vm/backend/src/com/codename1/backend/metrics/Histogram.java b/vm/backend/src/com/codename1/backend/metrics/Histogram.java index 5b8fe941015..6cb1b63bd29 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Histogram.java +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -93,6 +93,13 @@ public static double[] defaultBounds() { // "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 @@ -128,9 +135,9 @@ public void record(double value, Object first, Object second, Object third) { // 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 ? first : null; - Object b = labels.length > 1 && labels[1] != null ? second : null; - Object c = labels.length > 2 && labels[2] != null ? third : null; + 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. @@ -173,6 +180,24 @@ private Series find(Object first, Object second, Object third) { 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; + } + private static boolean same(Object a, Object b) { return a == b || (a != null && a.equals(b)); //NOPMD CompareObjectsWithEquals - identity first, then equals } From 7e7cf54a72d0c7e3f8f1b1084d4e0955495e3565 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:44:09 +0300 Subject: [PATCH 29/48] Backend: joined failures mark rollback-only, AUTO decided per task, napping waiters, exporter hand-off - A joined DataSource.inTransaction or EntityManager.transaction body that throws now marks the outer transaction rollback-only, as Spring's participating transactions do; an outer caller that swallowed the exception used to commit the helper's half-done writes. - An AUTO executor decides virtual-or-platform per submission, not at creation, so one first requested during start-up (before the hosts exist) no longer stays on platform threads for good. - A virtual thread waiting cooperatively on an @Async Future naps on its host with a deadline (VirtualThread.current + HttpServer.napUntil) instead of being re-queued at once; the host loop polls with the earliest nap as its timeout rather than spinning a core. - A replacement OTLP metric exporter's thread joins its predecessor before its first export, so two exports of one registry never overlap. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../codename1/backend/TransactionsTest.java | 21 +++++ .../backend/otel/OtlpMetricsProtoTest.java | 49 +++++++++++ .../com/codename1/backend/VirtualThread.java | 5 ++ .../com/codename1/backend/VirtualThread.java | 8 ++ vm/backend/native/cn1_backend_server.c | 6 ++ .../src/com/codename1/backend/AsyncTask.java | 13 ++- .../src/com/codename1/backend/DataSource.java | 15 +++- .../src/com/codename1/backend/HttpServer.java | 82 ++++++++++++++++++- .../com/codename1/backend/TaskExecutor.java | 20 ++++- .../src/com/codename1/backend/Tasks.java | 13 ++- .../com/codename1/backend/Transactions.java | 9 ++ .../codename1/backend/orm/EntityManager.java | 22 ++++- .../backend/otel/OtlpMetricExporter.java | 50 +++++++---- 13 files changed, 281 insertions(+), 32 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java index d52f19defc5..44187cd9fc0 100644 --- a/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/TransactionsTest.java @@ -160,6 +160,27 @@ void aSecondPoolIsRefusedInsideATransaction(@TempDir File dir) throws Exception } } + @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 { 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 index 767b9797a07..18e6dc57cfd 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -248,4 +248,53 @@ void aStoppedSuccessorIsNotStarted() throws Exception { 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); + } + } } diff --git a/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java b/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java index 476e35b85fa..1abe5e11c28 100644 --- a/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java +++ b/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java @@ -59,6 +59,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/parparvm/com/codename1/backend/VirtualThread.java b/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java index 32561d9a1ca..cdb8e305e8f 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java +++ b/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java @@ -99,6 +99,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(); @@ -118,6 +125,7 @@ public static boolean supported() { 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_server.c b/vm/backend/native/cn1_backend_server.c index 0569699bea9..2a7a220916b 100644 --- a/vm/backend/native/cn1_backend_server.c +++ b/vm/backend/native/cn1_backend_server.c @@ -1461,6 +1461,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. * diff --git a/vm/backend/src/com/codename1/backend/AsyncTask.java b/vm/backend/src/com/codename1/backend/AsyncTask.java index c27e7391a26..bf9f12d4019 100644 --- a/vm/backend/src/com/codename1/backend/AsyncTask.java +++ b/vm/backend/src/com/codename1/backend/AsyncTask.java @@ -196,12 +196,21 @@ public Object get(long timeout, TimeUnit unit) /// #### 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()) { - if (System.currentTimeMillis() >= deadline) { + long now = System.currentTimeMillis(); + if (now >= deadline) { return false; } - VirtualThread.yieldNow(); + HttpServer.napUntil(Math.min(deadline, now + nap)); + nap = Math.min(20, nap * 2); } return true; } diff --git a/vm/backend/src/com/codename1/backend/DataSource.java b/vm/backend/src/com/codename1/backend/DataSource.java index 246c5c2b877..a21342aac93 100644 --- a/vm/backend/src/com/codename1/backend/DataSource.java +++ b/vm/backend/src/com/codename1/backend/DataSource.java @@ -373,8 +373,19 @@ public Object inTransaction(Work body) throws Exception { 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. - return body.run(joined); + // 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/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index e9cfbc1f5e5..e01fe578df2 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -2667,9 +2667,63 @@ private void advanceTask(VtHost me, long handle) { VirtualThread.free(handle); return; } + ringOrNap(me, handle); + } + + /// 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. /// @@ -2751,6 +2805,11 @@ boolean ringEmpty() { /// 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(); boolean hasQueuedTasks() { synchronized (inbox) { @@ -3033,6 +3092,8 @@ 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()) { @@ -3058,6 +3119,7 @@ private void drainTasksAfterStop(VtHost me) { // 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(); @@ -3102,7 +3164,12 @@ private void drainTasksAfterStop(VtHost me) { /// 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 (!me.ringEmpty()) { + while (true) { + // A napping task is a running one: back on the ring each pass. + wakeNappers(me, Long.MAX_VALUE); + if (me.ringEmpty()) { + return; + } int budget = me.ringCount; int tasks = 0; while (budget-- > 0 && !me.ringEmpty()) { @@ -3179,9 +3246,18 @@ private void runVirtualThreadHostLoop(VtHost me, int index) { // network, so polling before running them would delay them by the // whole poll timeout. boolean ranSome = drainRunnable(me); + long nextNap = wakeNappers(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 @@ -3318,7 +3394,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 diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index 48d9eca81aa..ebf147ebfb0 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -64,19 +64,33 @@ static boolean runningOnThisThread() { 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 final boolean auto; + public String getName() { return name; } - /// Whether this executor asks for virtual threads. + /// 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 boolean isVirtual() { - return virtual; + return virtual || (auto && HttpServer.acceptsVirtualTasks()); } public int getSize() { @@ -127,7 +141,7 @@ public void execute(Runnable task) { if (task == null) { return; } - if (virtual) { + if (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 diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index 0acd7a42819..7eaa975aabd 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -219,12 +219,19 @@ static TaskExecutor executor(Registry registry, String name, int kind) { } else if ("platform".equalsIgnoreCase(configured)) { virtual = false; } else if (kind == AUTO) { - virtual = HttpServer.acceptsVirtualTasks(); + // 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 ? "virtual" : "platform") + " threads"); + 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; } diff --git a/vm/backend/src/com/codename1/backend/Transactions.java b/vm/backend/src/com/codename1/backend/Transactions.java index 83c84a7698c..7fdfd9316c4 100644 --- a/vm/backend/src/com/codename1/backend/Transactions.java +++ b/vm/backend/src/com/codename1/backend/Transactions.java @@ -637,6 +637,15 @@ public static com.codename1.orm.session.Session session(EntityManager entities) /// 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 diff --git a/vm/backend/src/com/codename1/backend/orm/EntityManager.java b/vm/backend/src/com/codename1/backend/orm/EntityManager.java index 4f45c85fae4..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 @@ -269,11 +270,26 @@ public Object transaction(final Work body) throws Exception { if (joined != null) { // Inside a @Transactional method: join its transaction, as a manager // already inside one does above. - return body.run(new EntityManager(null, joined, dialect, tables, true)); + 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/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index 18f938fee42..f97496e5c1b 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -152,6 +152,11 @@ public boolean open(Config config) throws IOException { /// 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; @@ -161,16 +166,28 @@ void takeOver() { return; } } - startExporting(); + 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"); @@ -259,24 +276,25 @@ public void shutdown(int timeoutMillis) { 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. - successor.takeOver(); - } - Thread exporter; - synchronized (lock) { - if (run == null || run.stopping) { - return; - } - 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 connect and read timeouts. - run.finalExport = timeoutMillis > 0; - exporter = thread; - lock.notifyAll(); + // 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 { From 5c3926d1bb08fb0a2b0dc9c194b692cd6eea6e92 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:08:01 +0300 Subject: [PATCH 30/48] Backend: namespaced JDBC sessions, exporter transport check, start-up drain - JDBC session rows carry a namespace (cn1.session.namespace, by default the generated application's package) and every query is limited to the store's own, so two different servers on one host sharing a database no longer accept each other's session cookie. Replicas share a namespace. - A second metrics exporter with different headers or protocol is refused like one with a different service or endpoint: only one exports, so its credentials were silently replaced by the first's. Header values are never printed. - A failed start drains the tasks @PostConstruct started with the configured shutdown timeout before destroying their beans, instead of interrupting them and tearing down at once. - A metric reader that answered false from open() is neither kept nor shut down; a reader whose shutdown throws no longer stops the teardown. - A class-level @Async counts only for the methods its class declares, as the weaver applies it, in the @Scheduled, @McpTool and scoped-bean checks. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../developer-guide/Backend-Sessions.asciidoc | 8 ++ .../backend/ApplicationRuntimeTest.java | 94 +++++++++++++++++++ .../backend/otel/OtlpMetricsProtoTest.java | 38 ++++++++ .../maven/processors/BackendBeans.java | 45 +++++++-- .../maven/processors/BackendBeansTest.java | 25 +++++ .../src/com/codename1/backend/Backend.java | 48 ++++++++-- .../src/com/codename1/backend/Sessions.java | 87 +++++++++++++---- .../backend/otel/OtlpMetricExporter.java | 22 +++++ 8 files changed, 334 insertions(+), 33 deletions(-) diff --git a/docs/developer-guide/Backend-Sessions.asciidoc b/docs/developer-guide/Backend-Sessions.asciidoc index 69175a6308d..e55ce605be4 100644 --- a/docs/developer-guide/Backend-Sessions.asciidoc +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -156,6 +156,14 @@ attributes are stored as JSON, so a value must be a string, a number, a boolean, 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. +The table is shared, so each row belongs to a namespace, and a server only reads +the rows of its own. `cn1.session.namespace` sets it; by default it's the package +of the application the build generates, so every instance of one project shares +sessions and two projects that use the same database don't. Without that, two +different servers on one host would each accept the session cookie the browser +sends to both, including a signed-in user's. Set the same namespace on two +projects only when they should share their users' 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. diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 11621d30d1a..e8f05f4e68f 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -448,6 +448,33 @@ public void run() { // --------------------------------------------------------------- sessions + @Test + @DisplayName("two servers' database session stores never read each other's sessions") + void jdbcSessionNamespaces(@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.Jdbc orders = new Sessions.Jdbc(pool, "com.example.orders"); + Sessions.Jdbc admin = new Sessions.Jdbc(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.Jdbc(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 jdbcSessionStore(@org.junit.jupiter.api.io.TempDir java.io.File dir) throws Exception { @@ -669,6 +696,73 @@ public HttpServer.Response handle(HttpServer.Request request) { 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("a websocket callback carries its own server's executors") void websocketCallbacksCarryTheirTasks() throws Exception { 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 index 18e6dc57cfd..f4ba24b583c 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -159,6 +159,44 @@ void oneMetricsIdentityPerProcess() throws Exception { } } + @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 { 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 index 010c22a85e2..9479477fa7e 100644 --- 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 @@ -548,9 +548,7 @@ private Bean classBean(AnnotatedClass cls) { // 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 (m.getAnnotation(ASYNC) != null - || (cls.getClassAnnotation(ASYNC) != null && m.isPublic() - && !m.isConstructor() && !m.isStatic())) { + if (wovenAsync(cls, m)) { // The task would hold the scoped instance after its request or // session ended and destroyed it. String scope = REQUEST.equals(bean.scope) ? "request" : "session"; @@ -1220,6 +1218,41 @@ private List inheritedMembers(AnnotatedClass cls) { 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)) { @@ -1233,8 +1266,7 @@ private void collectJobs(Bean bean) { + "that takes no arguments."); continue; } - if (m.getAnnotation(ASYNC) != null - || (cls.getClassAnnotation(ASYNC) != null && m.isPublic())) { + 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. @@ -1310,8 +1342,7 @@ private void collectTools(Bean bean) { ctx.error(cls, "@McpTool method " + where + " must be an instance method."); continue; } - if (m.getAnnotation(ASYNC) != null - || (cls.getClassAnnotation(ASYNC) != null && m.isPublic())) { + 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 " 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 index 3e54b6d3b93..b333cad30af 100644 --- 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 @@ -1098,6 +1098,31 @@ public void aFieldDependencyIsInitializedBeforeItsUser() throws Exception { } } + @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 asyncToolsAndDuplicateParametersAreBuildErrors() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 16c25e16857..c36ac4fc8bd 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -391,7 +391,13 @@ private void drain() { // 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) { - metricReader.shutdown(shutdownMillis); + // 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); + } } } @@ -1412,8 +1418,11 @@ private void abandonStart(DataSource pool) { startingTasks = null; if (tasks != null) { // Whatever @PostConstruct submitted stops before the beans it - // uses are destroyed, as in stop(). - Tasks.shutdown(tasks, 0); + // 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 @@ -1430,6 +1439,25 @@ private void abandonStart(DataSource pool) { } } + /// 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; + } + } + /// [#start] once the database, if any, is open. private Backend startWith(DataSource pool, Tracer active) throws Exception { EntityManager manager = openEntityManager(pool); @@ -1687,9 +1715,17 @@ public void register(HttpServer.WebSocketRegistry direct) // 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 may hold anything to stop. False only for a + // reader that ANSWERED false -- the contract's "nothing was + // started" -- so it 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. One whose + // open() threw is still shut down; it may have started part of it. + boolean readerOpen = metricReader != null; try { if (metricReader != null) { - measuring |= metricReader.open(config); + readerOpen = metricReader.open(config); + measuring |= readerOpen; } if (measuring) { com.codename1.backend.metrics.Metrics.enableServer(server, pool); @@ -1697,7 +1733,7 @@ public void register(HttpServer.WebSocketRegistry direct) } backend = new Backend(server, pool, manager, config, drain, active, application, - metricReader != null && measuring ? metricReader : null, + readerOpen ? metricReader : null, managedBeans, sessions, tasks, requestLog, environment == null ? new ArrayList() : environment.gauges); owned = true; @@ -1709,7 +1745,7 @@ public void register(HttpServer.WebSocketRegistry direct) // the outer clean-up ends only the singletons, and after them. sessions.close(); com.codename1.backend.metrics.Metrics.disableServer(server, pool); - if (metricReader != null) { + if (readerOpen) { metricReader.shutdown(0); } } diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index c9645599e68..ab877277ef3 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -76,6 +76,20 @@ public Sessions() { this.application = application; } + /// The JDBC namespace of a server that sets no `cn1.session.namespace`: the + /// package of its application, which the build generates into the project's + /// own. Every replica and every redeploy of one project has the same, and two + /// projects sharing a database do not -- so neither accepts the other's + /// session cookie. Empty for a server with no generated application. + static String defaultNamespace(Backend.Application application) { + if (application == null) { + return ""; + } + String name = application.getClass().getName(); + int dot = name.lastIndexOf('.'); + return dot < 0 ? "" : name.substring(0, dot); + } + /// 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 @@ -142,7 +156,8 @@ public static Sessions configure(Config config, boolean tls, DataSource pool, throw new IOException("cn1.session.store=jdbc needs a database, and this " + "server has none"); } - out.setStore(new Jdbc(pool)); + out.setStore(new Jdbc(pool, config.get("cn1.session.namespace", + defaultNamespace(application)))); } else if (!"memory".equalsIgnoreCase(kind)) { throw new IOException("cn1.session.store is \"" + kind + "\"; it must be memory or jdbc"); @@ -837,15 +852,34 @@ public synchronized int 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: browsers do not scope cookies by port, so two different servers on + /// one host sharing a database would otherwise each accept the other's + /// session -- its sign-in included. Replicas of one server share a + /// namespace, and with it their sessions. public static final class Jdbc 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 Jdbc(DataSource pool) { + this(pool, ""); + } + + /// A store whose sessions only servers with the same `namespace` see. + public Jdbc(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 { @@ -859,7 +893,8 @@ private synchronized void prepare() throws IOException { + 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)", null); + + d.columnType(Dialect.BIGINT) + " NOT NULL, namespace " + + d.columnType(Dialect.TEXT) + " NOT NULL)", null); ready = true; } @@ -867,7 +902,8 @@ private synchronized void prepare() throws IOException { public HttpSession load(String id) throws IOException { prepare(); Map row = pool.queryOne("SELECT created, last_accessed, max_inactive, attributes " - + "FROM " + TABLE + " WHERE id = ?", new Object[] {id}); + + "FROM " + TABLE + " WHERE id = ? AND namespace = ?", + new Object[] {id, namespace}); if (row == null) { return null; } @@ -908,11 +944,12 @@ public void save(HttpSession session, String previousId) throws IOException { 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) VALUES (?, ?, ?, ?, ?, 0)", + + "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())}); + Json.write(session.attributesCopy()), namespace}); session.storedAccessed = session.getLastAccessedTime(); if (previousId != null) { delete(previousId); @@ -924,7 +961,8 @@ public void save(HttpSession session, String previousId) throws IOException { 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 = ?", new Object[] {session.getId()}); + + 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 @@ -951,9 +989,9 @@ public void save(HttpSession session, String previousId) throws IOException { 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 " - + "version = ?", new Object[] {Json.write(merged), + + "namespace = ? AND version = ?", new Object[] {Json.write(merged), Integer.valueOf(maxInactive), used, used, Long.valueOf(version + 1), - session.getId(), Long.valueOf(version)}); + session.getId(), namespace, Long.valueOf(version)}); if (updated > 0) { session.storedAccessed = session.getLastAccessedTime(); return; @@ -980,7 +1018,8 @@ private void rotate(final HttpSession session, final String previousId) final Map mine = session.attributesCopy(); Object moved; try { - moved = pool.inTransaction(new Rotation(session, previousId, lock, changed, mine)); + moved = pool.inTransaction(new Rotation(session, previousId, lock, changed, mine, + namespace)); } catch (IOException err) { throw err; } catch (Exception err) { @@ -1000,20 +1039,23 @@ private static final class Rotation implements DataSource.Work { 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) { + 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 = ?" + lock, new Object[] {previousId}); + + 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. @@ -1037,12 +1079,13 @@ public Object run(Database db) throws Exception { ? session.getMaxInactiveInterval() : (int) number(row.get("max_inactive")); db.execute("INSERT INTO " + TABLE + " (id, created, last_accessed, " - + "max_inactive, attributes, version) VALUES (?, ?, ?, ?, ?, 0)", + + "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)}); - db.execute("DELETE FROM " + TABLE + " WHERE id = ?", - new Object[] {previousId}); + Integer.valueOf(maxInactive), Json.write(merged), namespace}); + db.execute("DELETE FROM " + TABLE + " WHERE id = ? AND namespace = ?", + new Object[] {previousId, namespace}); return Boolean.TRUE; } } @@ -1058,8 +1101,8 @@ void touchIfStale(HttpSession session) throws IOException { // 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 = ?", - new Object[] {at, at, session.getId()}); + + "> ? THEN last_accessed ELSE ? END WHERE id = ? AND namespace = ?", + new Object[] {at, at, session.getId(), namespace}); session.storedAccessed = now; } @@ -1076,7 +1119,8 @@ static long touchInterval(int maxInactiveSeconds) { @Override public void delete(String id) throws IOException { prepare(); - pool.execute("DELETE FROM " + TABLE + " WHERE id = ?", new Object[] {id}); + pool.execute("DELETE FROM " + TABLE + " WHERE id = ? AND namespace = ?", + new Object[] {id, namespace}); } @Override @@ -1096,6 +1140,9 @@ int purgeExpired(long now, java.util.Set busy) throws IOException { 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(); @@ -1114,8 +1161,8 @@ int purgeExpired(long now, java.util.Set busy) throws IOException { // 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 + " < ?)" - + spare.toString(), params.toArray()); + + "last_accessed + max_inactive * 1000.0 + " + TOUCH_INTERVAL + " < ?) " + + "AND namespace = ?" + spare.toString(), params.toArray()); } @Override diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index f97496e5c1b..73056333b03 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -116,6 +116,13 @@ public boolean open(Config config) throws IOException { // 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 != this && (!other.resource.equals(resource) //NOPMD CompareObjectsWithEquals - the exporter itself, by identity @@ -126,6 +133,16 @@ public boolean open(Config config) throws IOException { + "under two names; give both servers the same OpenTelemetry " + "service and endpoint, or set " + ENABLED + "=false on one."); } + if (other != this && (other.protobuf != protobuf //NOPMD CompareObjectsWithEquals - the exporter itself, by identity + || !sameHeaders(other.headers, headers))) { + // 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."); + } } if (OPEN.contains(this)) { // A second thread would export the same streams beside the first, @@ -146,6 +163,11 @@ public boolean open(Config config) throws IOException { return true; } + /// 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 From 34ffd0f0d6541c138343cf8e7738210d81aa399b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:14:37 +0300 Subject: [PATCH 31/48] Backend: align with Spring on handler Errors, scope checks and shared sessions; review fixes Spring Boot behaviour where nothing costs more to match it: - A handler's Error is answered 500, as Spring Boot's Tomcat answers it, instead of dropping the connection; only a VirtualMachineError other than a stack overflow is rethrown, as Tomcat rethrows it. - A websocket endpoint, job, managed bean or @Async bean that can reach a request- or session-scoped bean, and @Async on a scoped bean, build with a warning rather than an error: Spring starts them and the scoped stand-in throws IllegalStateException only when used with no request current. - JDBC sessions default to one shared namespace, as Spring Session shares its table; cn1.session.namespace keeps two projects apart. - A metric registered again in another unit keeps the first, as Micrometer does, and is warned about once. Review fixes: - An AUTO executor is pinned to the kind a later caller asks for by name, so the result no longer depends on call order. - The task queue gauge reads only the servers that measure. - An exporter opened a second time is refused before its settings change, and a server whose reader's open() threw no longer shuts it down. - A string tool argument refuses an array or object. - A request leaves its sessions before restoring the task and trace context, so a retired session's @PreDestroy runs as its own server's. - A timed Future.get saturates its deadline instead of overflowing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Backend-Scheduling.asciidoc | 11 +- .../developer-guide/Backend-Sessions.asciidoc | 14 +- docs/developer-guide/Backend-Web.asciidoc | 8 +- .../backend/ApplicationRuntimeTest.java | 280 +++++++++++++++++- .../maven/processors/BackendBeans.java | 24 +- .../maven/processors/BackendBeansTest.java | 52 +++- .../src/com/codename1/backend/AsyncTask.java | 13 +- .../src/com/codename1/backend/Backend.java | 39 ++- .../src/com/codename1/backend/HttpServer.java | 27 +- .../src/com/codename1/backend/Sessions.java | 28 +- .../com/codename1/backend/TaskExecutor.java | 31 +- .../src/com/codename1/backend/Tasks.java | 25 ++ .../com/codename1/backend/mcp/McpArgs.java | 8 + .../backend/metrics/MetricReader.java | 4 +- .../codename1/backend/metrics/Metrics.java | 34 ++- .../backend/otel/OtlpMetricExporter.java | 50 ++-- 16 files changed, 538 insertions(+), 110 deletions(-) diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc index fe17ff6334b..0ad57803e2c 100644 --- a/docs/developer-guide/Backend-Scheduling.asciidoc +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -48,11 +48,12 @@ 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. -Two combinations are build errors. `@Async` on a `@RequestScope` or -`@SessionScope` bean would hand the task an instance that's destroyed when its -request or session ends, possibly before the task runs. `@Async` on a `@Scheduled` method 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 `@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 diff --git a/docs/developer-guide/Backend-Sessions.asciidoc b/docs/developer-guide/Backend-Sessions.asciidoc index e55ce605be4..dca16b9a74d 100644 --- a/docs/developer-guide/Backend-Sessions.asciidoc +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -156,13 +156,13 @@ attributes are stored as JSON, so a value must be a string, a number, a boolean, 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. -The table is shared, so each row belongs to a namespace, and a server only reads -the rows of its own. `cn1.session.namespace` sets it; by default it's the package -of the application the build generates, so every instance of one project shares -sessions and two projects that use the same database don't. Without that, two -different servers on one host would each accept the session cookie the browser -sends to both, including a signed-in user's. Set the same namespace on two -projects only when they should share their users' sessions. +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 diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index a126fef5d33..6b7e031db6e 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -216,9 +216,11 @@ injected the same way, and two endpoints claiming one path is a build error rath 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 can't inject a `@RequestScope` or `@SessionScope` bean either: its -callbacks run outside any HTTP request, so there's no request or session to find -the bean in. Keep per-connection state in the session's attachment instead. +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. ==== A server that writes its own main diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index e8f05f4e68f..f35f2212919 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -246,6 +246,28 @@ void counterOverflow() { 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 float argument out of the float range is refused, not made infinite") void floatArgumentRange() { @@ -259,6 +281,27 @@ void floatArgumentRange() { 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 { @@ -433,6 +476,34 @@ protected Object call() { Tasks.shutdown(1000); } + @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 { @@ -642,12 +713,8 @@ public HttpServer.Response handle(HttpServer.Request request) pair = pair.substring(0, pair.indexOf(';')); HttpURLConnection out = open(port, "/out"); out.setRequestProperty("Cookie", pair); - try { - // An Error drops the connection rather than answering 500. - out.getResponseCode(); - } catch (IOException dropped) { - // expected either way - } + // 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"); @@ -735,6 +802,50 @@ public void stopped() { "the beans were destroyed while a start-up task was still using them"); } + @Test + @DisplayName("one exporter given to a second server is refused without touching the first") + void sharedExporterSurvivesARefusedSecondServer() throws Exception { + ServerSocket probe = new ServerSocket(0); + int closed = probe.getLocalPort(); + probe.close(); + final com.codename1.backend.otel.OtlpMetricExporter exporter = + new com.codename1.backend.otel.OtlpMetricExporter("shared"); + Properties first = new Properties(); + first.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + first.setProperty(com.codename1.backend.otel.OtlpMetricExporter.ENDPOINT, + "http://127.0.0.1:" + closed + "/v1/metrics"); + first.setProperty(com.codename1.backend.otel.OtlpMetricExporter.INTERVAL, "20"); + final HttpServer.Handler none = new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }; + Backend running = Backend.builder(Config.of(first, "test")).quiet().metrics(exporter) + .handler(none).start(); + try { + final Properties second = new Properties(); + second.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + second.setProperty(com.codename1.backend.otel.OtlpMetricExporter.ENDPOINT, + "http://127.0.0.1:9/elsewhere"); + assertThrows(Exception.class, () -> Backend.builder(Config.of(second, "test")) + .quiet().metrics(exporter).handler(none).start()); + assertEquals("http://127.0.0.1:" + closed + "/v1/metrics", + exporter.status().get("endpoint"), + "the refused server reconfigured the running exporter"); + // Still running: failures keep being counted against the first endpoint. + long before = ((Number) exporter.status().get("failures")).longValue(); + long deadline = System.currentTimeMillis() + 5000; + while(System.currentTimeMillis() < deadline + && ((Number) exporter.status().get("failures")).longValue() < before + 2) { + Thread.sleep(20); + } + assertTrue(((Number) exporter.status().get("failures")).longValue() >= before + 2, + "the refused server stopped the running exporter"); + } finally { + running.stop(); + } + } + @Test @DisplayName("a metric reader that declines to open is never shut down") void declinedMetricReaderIsNotStopped() throws Exception { @@ -763,6 +874,80 @@ public HttpServer.Response handle(HttpServer.Request request) { 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 task queue gauge leaves out a server that does not measure") + void queueGaugeSkipsUnmeasuredServers() throws Exception { + Properties measuredSettings = new Properties(); + measuredSettings.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + Backend measured = Backend.builder(Config.of(measuredSettings, "dev")).quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }).start(); + final CountDownLatch release = new CountDownLatch(1); + int port = freePort(); + Properties quietSettings = new Properties(); + quietSettings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + quietSettings.setProperty("cn1.task.executor.unmeasured.threads", "1"); + // A development profile turns the management endpoints, and with them + // measuring, on by default. + quietSettings.setProperty(Management.ENABLED, "false"); + Backend unmeasured = Backend.builder(Config.of(quietSettings, "test")).quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + // On this server's thread, so on its executors: one task + // holds the only thread and the next one waits. + TaskExecutor e = Tasks.executor("unmeasured", Tasks.PLATFORM); + Runnable wait = new Runnable() { + public void run() { + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + } + }; + e.execute(wait); + e.execute(wait); + return HttpServer.Response.text(200, "queued"); + } + }).start(); + try { + assertTrue(measured.isMeasured()); + assertFalse(unmeasured.isMeasured()); + assertEquals("queued", read(open(port, "/fill"))); + com.codename1.backend.metrics.Instrument gauge = Metrics.get("cn1.task.queue_depth"); + assertNotNull(gauge); + String points = String.valueOf(gauge.points()); + assertFalse(points.contains("unmeasured"), + "a server with metrics off reported its queue: " + points); + } finally { + release.countDown(); + unmeasured.stop(); + measured.stop(); + } + } + @Test @DisplayName("a websocket callback carries its own server's executors") void websocketCallbacksCarryTheirTasks() throws Exception { @@ -2070,6 +2255,89 @@ public HttpServer.Response handle(HttpServer.Request request) } } + @Test + @DisplayName("a retired session's beans are destroyed with their own server's executors") + void retiredSessionTeardownKeepsItsServer() throws Exception { + final TaskExecutor[] mine = new TaskExecutor[1]; + final TaskExecutor[] atDestroy = new TaskExecutor[1]; + final CountDownLatch holding = 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).scopedBeans(1)[0] = "cart"; + return HttpServer.Response.text(200, "in"); + } + if (t.startsWith("/hold")) { + request.getSession(false).scopedBeans(1); + mine[0] = Tasks.executor("probe", Tasks.PLATFORM); + holding.countDown(); + release.await(10, TimeUnit.SECONDS); + return HttpServer.Response.text(200, "held"); + } + request.getSession(false).invalidate(); + return HttpServer.Response.text(200, "out"); + } + }}; + } + + public void sessionEnded(Object[] beans) { + atDestroy[0] = Tasks.executor("probe", Tasks.PLATFORM); + } + }).start(); + // Newer, so it is what a thread with no server of its own falls back to. + Properties otherSettings = new Properties(); + otherSettings.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + Backend other = Backend.builder(Config.of(otherSettings, "test")).quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return null; + } + }).start(); + try { + HttpURLConnection in = open(port, "/in"); + assertEquals("in", read(in)); + String cookie = in.getHeaderField("Set-Cookie"); + final String pair = cookie.substring(0, cookie.indexOf(';')); + final int p = port; + final String[] held = new String[1]; + Thread holder = new Thread(new Runnable() { + public void run() { + try { + HttpURLConnection h = open(p, "/hold"); + h.setRequestProperty("Cookie", pair); + held[0] = read(h); + } catch (IOException err) { + held[0] = String.valueOf(err); + } + } + }); + holder.start(); + assertTrue(holding.await(10, TimeUnit.SECONDS)); + HttpURLConnection out = open(port, "/out"); + out.setRequestProperty("Cookie", pair); + assertEquals("out", read(out)); + release.countDown(); + holder.join(10000); + assertEquals("held", held[0]); + assertNotNull(atDestroy[0], "the retired beans were never destroyed"); + assertTrue(atDestroy[0] == mine[0], + "a retired session's @PreDestroy ran with another server's executors"); + } finally { + release.countDown(); + other.stop(); + 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) 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 index 9479477fa7e..bda9288f222 100644 --- 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 @@ -550,9 +550,10 @@ private Bean classBean(AnnotatedClass cls) { 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. + // 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.error(cls, "@Async method " + cls.getSourceName() + "." + m.getName() + 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 " @@ -1587,7 +1588,14 @@ private void resolve() { // 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. Only the direct injections used to be checked. + // 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" @@ -1610,11 +1618,13 @@ private void resolve() { : !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.error(b.cls, role + " " + b.describe() + " reaches " + d.describe() + via - + ", which is " + d.scope + "-scoped. " + outside + " outside any HTTP " - + "request, so there is no " + d.scope + " to find it in; inject a singleton" + 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." : ".")); + + "WebSocketSession's attachment" : "") + ", unless that path never " + + "touches it."); } if (!ctx.hasErrors()) { orderConstruction(); 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 index b333cad30af..2e154924e50 100644 --- 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 @@ -1060,8 +1060,12 @@ public void asyncOnARequestBeanOrAScheduledMethodIsABuildError() throws Exceptio + "}\n"); ProcessorContext ctx = process(compile(s)); String errors = String.valueOf(ctx.getErrors()); - assertTrue(errors, errors.contains("is on a @RequestScope bean")); - assertTrue(errors, errors.contains("is on a @SessionScope bean")); + // 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")); } @@ -1227,20 +1231,24 @@ public void scopedBeansReachedThroughASingletonAreRefusedOffRequest() throws Exc + " public Nightly(Helper helper) { }\n" + " @Scheduled(fixedRate = 60000) public void run() { }\n" + "}\n"); - String errors = String.valueOf(process(compile(s)).getErrors()); - assertTrue(errors, errors.contains("Websocket endpoint chat (com.example.Chat) reaches " + // 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(errors, errors.contains("A websocket callback runs outside any HTTP request")); - assertTrue(errors, errors.contains("nightly (com.example.Nightly) reaches visit")); - assertTrue(errors, errors.contains("A scheduled job runs outside any HTTP request")); + 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 anAsyncBeanReachingAScopedBeanIsRefused() throws Exception { + 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" @@ -1250,8 +1258,10 @@ public void anAsyncBeanReachingAScopedBeanIsRefused() throws Exception { + " @Autowired private Helper helper;\n" + " @Async public void send() { }\n" + "}\n"); - String errors = String.valueOf(process(compile(s)).getErrors()); - assertTrue(errors, errors.contains("Bean with @Async methods mailer (com.example.Mailer) " + // 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)")); } @@ -1272,15 +1282,16 @@ public void oneExecutorNameWithTwoThreadKindsIsRefused() throws Exception { } @Test - public void aScopedBeanInheritingAnAsyncMethodIsRefused() throws Exception { + 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"); - String errors = String.valueOf(process(compile(s)).getErrors()); - assertTrue(errors, errors.contains("@Async method com.example.Visit.later is on a " + assertNoErrors(process(compile(s))); + String warned = String.valueOf(warnings); + assertTrue(warned, warned.contains("@Async method com.example.Visit.later is on a " + "@RequestScope bean")); } @@ -1391,7 +1402,8 @@ public void sessionBeansAndSocketsCannotHoldRequestState() throws Exception { ProcessorContext ctx = process(compile(s)); String errors = String.valueOf(ctx.getErrors()); assertTrue(errors, errors.contains("a session bean outlives the request")); - assertTrue(errors, errors.contains("A websocket callback runs outside any HTTP request")); + assertTrue(String.valueOf(warnings), String.valueOf(warnings) + .contains("A websocket callback runs outside any HTTP request")); } @Test @@ -1536,6 +1548,9 @@ private File compile(Map sources) throws Exception { 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()); } @@ -1550,8 +1565,15 @@ private ProcessorContext process(File classes, RestControllerAnnotationProcessor for (File f : extraClasspath) { cp.add(f.getAbsolutePath()); } + warnings.clear(); ProcessorContext ctx = new ProcessorContext(classes, tmp.newFolder(), index, - new SystemStreamLog(), tmp.newFolder(), new Properties(), null, + 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); diff --git a/vm/backend/src/com/codename1/backend/AsyncTask.java b/vm/backend/src/com/codename1/backend/AsyncTask.java index bf9f12d4019..b711980940c 100644 --- a/vm/backend/src/com/codename1/backend/AsyncTask.java +++ b/vm/backend/src/com/codename1/backend/AsyncTask.java @@ -164,7 +164,7 @@ public Object get() throws InterruptedException, ExecutionException { @Override public Object get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { - long deadline = System.currentTimeMillis() + unit.toMillis(timeout); + long deadline = deadline(System.currentTimeMillis(), unit.toMillis(timeout)); if (VirtualThread.isVirtual()) { if (!awaitCooperatively(deadline)) { throw new TimeoutException(name + " did not finish in time"); @@ -183,6 +183,17 @@ public Object get(long timeout, TimeUnit unit) } } + /// `now + millis`, saturated at Long.MAX_VALUE. 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, diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index c36ac4fc8bd..6f3fb4f0e85 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -885,14 +885,23 @@ public HttpServer.Response handle(HttpServer.Request request) // no-op: the beans are taken once. endRequestBeans(request); } finally { - if (track) { - CURRENT_REQUEST.set(previous); + 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); + // Last: a stop() this request made tears down only now. + inFlight.leave(); } - Tasks.leave(previousTasks); - Tracing.disown(previousOwner); - sessions.leave(request); - // Last: a stop() this request made tears down only now. - inFlight.leave(); } } } @@ -1715,13 +1724,15 @@ public void register(HttpServer.WebSocketRegistry direct) // 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 may hold anything to stop. False only for a - // reader that ANSWERED false -- the contract's "nothing was - // started" -- so it 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. One whose - // open() threw is still shut down; it may have started part of it. - boolean readerOpen = metricReader != null; + // 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); diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index e01fe578df2..0f11cf361b8 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -5183,14 +5183,15 @@ private void serveOneRelease(int fd) { // fails is recorded as the failure it is. 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"); @@ -5554,7 +5555,7 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) // 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, serverTracer); - Exception handlerError = null; + 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. @@ -5570,7 +5571,8 @@ 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"); @@ -5886,7 +5888,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. diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index ab877277ef3..6943077c9a3 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -76,20 +76,6 @@ public Sessions() { this.application = application; } - /// The JDBC namespace of a server that sets no `cn1.session.namespace`: the - /// package of its application, which the build generates into the project's - /// own. Every replica and every redeploy of one project has the same, and two - /// projects sharing a database do not -- so neither accepts the other's - /// session cookie. Empty for a server with no generated application. - static String defaultNamespace(Backend.Application application) { - if (application == null) { - return ""; - } - String name = application.getClass().getName(); - int dot = name.lastIndexOf('.'); - return dot < 0 ? "" : name.substring(0, dot); - } - /// 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 @@ -156,8 +142,7 @@ public static Sessions configure(Config config, boolean tls, DataSource pool, throw new IOException("cn1.session.store=jdbc needs a database, and this " + "server has none"); } - out.setStore(new Jdbc(pool, config.get("cn1.session.namespace", - defaultNamespace(application)))); + out.setStore(new Jdbc(pool, config.get("cn1.session.namespace", ""))); } else if (!"memory".equalsIgnoreCase(kind)) { throw new IOException("cn1.session.store is \"" + kind + "\"; it must be memory or jdbc"); @@ -854,10 +839,13 @@ public synchronized int size() { /// 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: browsers do not scope cookies by port, so two different servers on - /// one host sharing a database would otherwise each accept the other's - /// session -- its sign-in included. Replicas of one server share a - /// namespace, and with it their sessions. + /// 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 Jdbc implements SessionStore { private static final String TABLE = "cn1_http_session"; /// How stale the stored last-access time may get before a read refreshes it. diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index ebf147ebfb0..a38fdc06389 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -48,7 +48,9 @@ static boolean runningOnThisThread() { } private final String name; - private final boolean virtual; + /// 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; @@ -81,7 +83,20 @@ static boolean runningOnThisThread() { this.registry = registry; } - private final boolean auto; + 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 database 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; @@ -89,7 +104,7 @@ public String getName() { /// 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 boolean isVirtual() { + public synchronized boolean isVirtual() { return virtual || (auto && HttpServer.acceptsVirtualTasks()); } @@ -141,19 +156,23 @@ public void execute(Runnable task) { if (task == null) { return; } - if (virtual || auto) { + 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. - synchronized (this) { + 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)) { @@ -380,7 +399,7 @@ public void run() { } @Override - public String toString() { + public synchronized String toString() { return name + (virtual ? " (virtual)" : " (" + size + " threads)"); } diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index 7eaa975aabd..053e762473e 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -192,7 +192,10 @@ static TaskExecutor executor(Registry registry, String name, int kind) { + "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; } @@ -307,6 +310,28 @@ public static List executors() { 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) { diff --git a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java index 35494019cb9..8d83ef66245 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpArgs.java @@ -84,8 +84,16 @@ 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); } diff --git a/vm/backend/src/com/codename1/backend/metrics/MetricReader.java b/vm/backend/src/com/codename1/backend/metrics/MetricReader.java index ae8010f4709..e02d69f30c6 100644 --- a/vm/backend/src/com/codename1/backend/metrics/MetricReader.java +++ b/vm/backend/src/com/codename1/backend/metrics/MetricReader.java @@ -32,7 +32,9 @@ /// 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. + /// 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`. diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index 8db0b16abbd..66563dd1774 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -178,6 +178,7 @@ private static synchronized Instrument register(String name, int kind, String de 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 @@ -202,6 +203,30 @@ private static synchronized Instrument register(String name, int kind, String de 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()); @@ -235,6 +260,10 @@ public static long startTimeMillis() { 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; @@ -395,7 +424,10 @@ public List read() { // "default" executor, and two points with one label set are one // series twice -- duplicate samples a scrape rejects. Map byName = new LinkedHashMap(); - List all = com.codename1.backend.Tasks.executors(); + // 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; diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index 73056333b03..15a6fd1c279 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -77,6 +77,10 @@ public final class OtlpMetricExporter implements MetricReader { 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 @@ -84,12 +88,17 @@ public boolean open(Config config) throws IOException { if (config.getBoolean(OtlpTracer.DISABLED, false) || !config.getBoolean(ENABLED, true)) { return false; } + // 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)) { - protobuf = true; + asProtobuf = true; } else if ("http/json".equals(protocol)) { - protobuf = false; + asProtobuf = false; } else { throw new IOException(PROTOCOL + " is '" + protocol + "'; this server exports " + "OTLP over HTTP, so use http/protobuf or http/json"); @@ -100,17 +109,23 @@ public boolean open(Config config) throws IOException { config.get(OtlpTracer.ENDPOINT, "http://localhost:4318").trim(), "/v1/metrics"); } - endpoint = target.trim(); - if (!OtlpTracer.hasHttpAuthority(endpoint)) { + 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(endpoint) + "'"); + + "a host and is '" + BatchExporter.redact(url) + "'"); } - headers = new ArrayList(); + List sent = new ArrayList(); String own = config.get(HEADERS); - OtlpTracer.parseHeaders(own != null ? own : config.get(OtlpTracer.HEADERS), headers); - intervalMillis = OtlpTracer.positive(config, INTERVAL, 60000); - resource = OtlpTracer.resource(config, defaultServiceName); + 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 @@ -125,16 +140,14 @@ public boolean open(Config config) throws IOException { // the order the headers are listed in means nothing. for (Object element : OPEN) { OtlpMetricExporter other = (OtlpMetricExporter) element; - if (other != this && (!other.resource.equals(resource) //NOPMD CompareObjectsWithEquals - the exporter itself, by identity - || !other.endpoint.equals(endpoint))) { + 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 != this && (other.protobuf != protobuf //NOPMD CompareObjectsWithEquals - the exporter itself, by identity - || !sameHeaders(other.headers, headers))) { + 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 " @@ -144,12 +157,11 @@ public boolean open(Config config) throws IOException { + ENABLED + "=false on one."); } } - 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"); - } + 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 From c5079bba958595bf2f2cfcc00e37741ef8ecf518 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:37:07 +0300 Subject: [PATCH 32/48] Backend: undo failed rotations, text-keyed histogram series, early JSON, optional conditionals - A session-id rotation whose save fails is undone -- the id, and the beans a lookup after the rotation moved -- so the client's old cookie still finds them instead of a second set being built. - Histogram series are told apart by the text Prometheus prints, so Boolean.TRUE and "true", or 1L and "1", are one series, not duplicate samples; the first value's type is kept for OTLP. - respondJson's body is serialised before request-scoped beans are destroyed, as Spring MVC writes the body first; only when there are beans to end, so the zero-copy write is unchanged otherwise. - An optional field or setter whose candidates are all conditional is injected only when one is active, keeping the field's initializer as Spring does; @Autowired(required = false) on a method now makes its arguments optional, as in Spring. - A route returning a Future (an @Async route) is refused with a message that says why: the client would get the pending task, not the result. - Caller-supplied waits saturate their deadline in every shutdown and borrow. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 150 ++++++++++++++++++ .../maven/processors/BackendBeans.java | 6 + .../maven/processors/BackendWiringWriter.java | 60 +++++++ .../RestControllerAnnotationProcessor.java | 15 ++ .../maven/processors/BackendBeansTest.java | 52 ++++++ .../src/com/codename1/backend/AsyncTask.java | 8 +- .../src/com/codename1/backend/Backend.java | 17 +- .../src/com/codename1/backend/DataSource.java | 2 +- .../src/com/codename1/backend/HttpServer.java | 10 ++ .../src/com/codename1/backend/Scheduler.java | 2 +- .../src/com/codename1/backend/Sessions.java | 41 ++++- .../com/codename1/backend/TaskExecutor.java | 2 +- .../src/com/codename1/backend/Tasks.java | 2 +- .../codename1/backend/metrics/Histogram.java | 17 +- 14 files changed, 366 insertions(+), 18 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index f35f2212919..628e6d6d8b2 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -268,6 +268,21 @@ void metricUnitMismatch() throws Exception { assertEquals("ms", first.getUnit()); } + @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() { @@ -476,6 +491,34 @@ protected Object call() { Tasks.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 { @@ -2255,6 +2298,113 @@ public HttpServer.Response handle(HttpServer.Request request) } } + @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 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("a retired session's beans are destroyed with their own server's executors") void retiredSessionTeardownKeepsItsServer() throws Exception { 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 index bda9288f222..c4a53c9dc4a 100644 --- 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 @@ -681,9 +681,15 @@ private void members(Bean bean, AnnotatedClass cls, AnnotatedClass declaring, 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); } 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 index 53682d9a2d7..4d476e9c993 100644 --- 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 @@ -349,18 +349,57 @@ private void members(StringBuilder sb, BackendBeans.Bean b, String ref, String i 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) { @@ -398,6 +437,27 @@ private static boolean skippable(BackendBeans.Point p) { && !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) { 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 1f1a7a4fbea..0656cabf465 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 @@ -905,6 +905,21 @@ 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 (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)) { ctx.error(cls, cls.getBinaryName() + "." + m.getName() + " returns " + route.returnJavaType + ", which the generated router cannot encode: " 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 index 2e154924e50..d6bd1669e34 100644 --- 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 @@ -739,6 +739,45 @@ public void aConfigurationThatStepsAsideTakesItsFactoriesWithIt() throws Excepti } } + @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(); @@ -1127,6 +1166,19 @@ public void aClassLevelAsyncReachesOnlyItsOwnMethods() throws Exception { 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(); diff --git a/vm/backend/src/com/codename1/backend/AsyncTask.java b/vm/backend/src/com/codename1/backend/AsyncTask.java index b711980940c..9419bdb0579 100644 --- a/vm/backend/src/com/codename1/backend/AsyncTask.java +++ b/vm/backend/src/com/codename1/backend/AsyncTask.java @@ -183,7 +183,13 @@ public Object get(long timeout, TimeUnit unit) } } - /// `now + millis`, saturated at Long.MAX_VALUE. toMillis() already + /// `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. diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 6f3fb4f0e85..92f86804981 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -774,9 +774,24 @@ private void failed(HttpServer.Request request, List attempted, /// 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(); if (beans != null) { + if (response != null) { + response.serializeDeferredJson(); + } app.requestEnded(beans); } } @@ -820,7 +835,7 @@ public HttpServer.Response handle(HttpServer.Request request) // stored: a @PreDestroy that changes the // session, or starts one, would otherwise // change it after its only save. - endRequestBeans(request); + endRequestBeans(request, response); // Inside the logged region: a session // store that fails to save is a 500 the // client receives, and the request log diff --git a/vm/backend/src/com/codename1/backend/DataSource.java b/vm/backend/src/com/codename1/backend/DataSource.java index a21342aac93..05a0fafca94 100644 --- a/vm/backend/src/com/codename1/backend/DataSource.java +++ b/vm/backend/src/com/codename1/backend/DataSource.java @@ -244,7 +244,7 @@ synchronized Database borrowFromPool() 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); diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 0f11cf361b8..d9dad69c1c3 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -967,6 +967,16 @@ void releaseRetained() { this.extraHeaders = null; } + /// 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 diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/backend/Scheduler.java index ba458bd9118..9e9489aec20 100644 --- a/vm/backend/src/com/codename1/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -264,7 +264,7 @@ public void stop(long waitMillis) { stopped = true; notifyAll(); } - long deadline = System.currentTimeMillis() + Math.max(0, waitMillis); + long deadline = AsyncTask.deadline(System.currentTimeMillis(), Math.max(0, waitMillis)); synchronized (this) { while (anyRunning()) { long left = deadline - System.currentTimeMillis(); diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 6943077c9a3..c33535d5a6c 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -357,18 +357,12 @@ HttpServer.Response finish(HttpServer.Request request, HttpSession session, if (session.isNew()) { destroy(take(session.getId())); } else { - synchronized (this) { - Held moved = (Held) beans.remove(session.getId()); - if (moved != null) { - beans.put(previous, moved); - } - } // 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. - session.undoRotation(previous); + revertRotation(session, previous); if (session.isDirty()) { s.save(session, null); session.clean(); @@ -391,7 +385,15 @@ HttpServer.Response finish(HttpServer.Request request, HttpSession session, } else { if (session.isDirty()) { boolean announce = session.isNew() || previous != null; - s.save(session, previous); + 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 @@ -416,6 +418,29 @@ HttpServer.Response finish(HttpServer.Request request, HttpSession session, 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) { diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index a38fdc06389..85ddb474113 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -284,7 +284,7 @@ public synchronized void shutdown(long waitMillis) { notifyAll(); boolean fromOwnTask = RUNNING.get() == this; //NOPMD CompareObjectsWithEquals - the executor itself, by identity int self = fromOwnTask ? 1 : 0; - long deadline = System.currentTimeMillis() + Math.max(0, waitMillis); + 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) { diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index 053e762473e..59324d367c5 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -362,7 +362,7 @@ static void shutdown(Registry registry, long waitMillis) { registry.shutdown = true; all = new ArrayList(registry.executors.values()); } - long deadline = System.currentTimeMillis() + Math.max(0, waitMillis); + 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/metrics/Histogram.java b/vm/backend/src/com/codename1/backend/metrics/Histogram.java index 6cb1b63bd29..5adfd719f56 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Histogram.java +++ b/vm/backend/src/com/codename1/backend/metrics/Histogram.java @@ -154,12 +154,17 @@ public void record(double value, Object first, Object second, Object third) { 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. - Object key = first == null ? NO_VALUE : first; + // + // 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 (same(s.values[1], second) && same(s.values[2], third)) { + if (sameText(s.values[1], second) && sameText(s.values[2], third)) { return s; } } @@ -198,8 +203,12 @@ private static Object canonical(Object v) { return v; } - private static boolean same(Object a, Object b) { - return a == b || (a != null && a.equals(b)); //NOPMD CompareObjectsWithEquals - identity first, then equals + /// 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 From 4bd7142f753885bd826b700454614a2fce081e65 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:53:32 +0300 Subject: [PATCH 33/48] Backend: saturated schedules, active-only routes, one setter per property, partial metric success - Fixed-delay, fixed-rate and initial-delay schedules and lock leases saturate their timestamps: a period of Long.MAX_VALUE wrapped into the past and ran the job back to back. - The route listing (backend_routes) names only the controllers and endpoints this start registered; a conditional one that is off serves 404. - @ConfigurationProperties binds one setter per property, as Spring Boot's binder does: the overload matching the getter's type, else the first. - The OTLP metric exporter reads partial_success and reports rejected data points instead of counting the export healthy. - The websocket wrapper forwards getSubprotocols(), so an endpoint's protocols are negotiated. - A metric name used by both @Timed and @Counted is a build error, rather than a registration that throws after the body has run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 62 +++++++++++++ .../backend/otel/OtlpMetricsProtoTest.java | 52 +++++++++++ .../maven/processors/BackendBeans.java | 79 +++++++++++++++- .../maven/processors/BackendWiringWriter.java | 29 ++++-- .../RestControllerAnnotationProcessor.java | 4 +- .../maven/processors/BackendBeansTest.java | 90 +++++++++++++++++++ .../src/com/codename1/backend/Backend.java | 13 +++ .../src/com/codename1/backend/Scheduler.java | 18 ++-- .../backend/otel/OtlpMetricExporter.java | 45 ++++++++++ .../codename1/backend/otel/OtlpSchema.java | 13 ++- 10 files changed, 385 insertions(+), 20 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 628e6d6d8b2..e39a40927ca 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -418,6 +418,41 @@ protected Object call() { 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 { @@ -991,6 +1026,33 @@ public void run() { } } + @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 { 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 index f4ba24b583c..bcba6436c13 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -137,6 +137,58 @@ void negativeUpDownCounter() throws Exception { .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 { 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 index c4a53c9dc4a..cf69ab8811a 100644 --- 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 @@ -320,6 +320,9 @@ static final class Aspect { 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(); final Map plans = new TreeMap(); /// Eager singletons (and the prototypes they need) in construction order. final List order = new ArrayList(); @@ -951,7 +954,12 @@ private void bindProperties(Bean bean, AnnotationValues props, AnnotatedClass wh prefix = prefix.substring(0, prefix.length() - 1); } bean.propertiesPrefix = prefix; - Set seen = new LinkedHashSet(); + // 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()) { @@ -963,8 +971,13 @@ private void bindProperties(Bean bean, AnnotationValues props, AnnotatedClass wh if (args.length != 1 || !bindable(args[0])) { continue; } - if (seen.add(m.getName() + m.getDescriptor())) { - bean.propertySetters.add(m); + 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. @@ -972,6 +985,7 @@ private void bindProperties(Bean bean, AnnotationValues props, AnnotatedClass wh 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 " @@ -979,6 +993,29 @@ private void bindProperties(Bean bean, AnnotationValues props, AnnotatedClass wh } } + /// 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()) { @@ -2122,6 +2159,41 @@ private void noteExecutorKind(Map kinds, String executor, Stri } } + /// 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) { + aspectMetrics.put(name, new String[] {kind, by}); + return; + } + 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."); + } + } + private void collectAspects() { for (AnnotatedClass cls : ctx.getClassIndex().values()) { if (!concerns(cls) || cls.isInterface()) { @@ -2182,6 +2254,7 @@ private void collectAspects() { 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()) 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 index 4d476e9c993..a0b088995dc 100644 --- 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 @@ -73,7 +73,8 @@ static final class Router { /// /// @param routers the controllers, with their generated routers, in order /// @param sockets path -> endpoint binary name - /// @param routes method, path and handler of every route, for the listing + /// @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(); @@ -113,6 +114,10 @@ private void fields(StringBuilder sb) { 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; @@ -155,6 +160,7 @@ private void create(StringBuilder sb, List routers) { // 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; @@ -250,7 +256,10 @@ private void create(StringBuilder sb, List routers) { .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 }\n }\n"); + .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"); @@ -626,7 +635,10 @@ private void webSockets(StringBuilder sb, Map sockets) { 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 }\n"); + .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"); } @@ -927,12 +939,15 @@ private static void collectDependencies(BackendBeans.Bean b, List out) { } private static void describeRoutes(StringBuilder sb, List routes) { - sb.append(" public java.util.List describeRoutes() {\n"); + sb.append(" public synchronized java.util.List describeRoutes() {\n"); sb.append(" java.util.List out = new java.util.ArrayList();\n"); for (String[] r : routes) { - sb.append(" out.add(route(").append(BackendSources.quote(r[0])).append(", ") - .append(BackendSources.quote(r[1])).append(", ").append(BackendSources.quote(r[2])) - .append("));\n"); + // 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, " 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 0656cabf465..5ba7e7deb82 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 @@ -2575,13 +2575,13 @@ String generateWiring(String packageName) { qualify(c.packageName, c.routerSimpleName))); for (Route r : c.routes) { routes.add(new String[] {r.httpMethod, r.pattern, - c.binaryName + "." + r.javaMethod}); + 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}); + routes.add(new String[] {"WEBSOCKET", e.path, e.binaryName, e.binaryName}); } return new BackendWiringWriter(beans).write(packageName, routers, sockets, routes); } 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 index d6bd1669e34..c68c5b945b1 100644 --- 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 @@ -739,6 +739,96 @@ public void aConfigurationThatStepsAsideTakesItsFactoriesWithIt() throws Excepti } } + @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")); + 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(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 92f86804981..16661b9adb0 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -695,6 +695,19 @@ public void onError(WebSocketSession session, Exception error) { 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 diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/backend/Scheduler.java index 9e9489aec20..1fb7495716c 100644 --- a/vm/backend/src/com/codename1/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/backend/Scheduler.java @@ -241,7 +241,7 @@ public synchronized void start() { + job.cron); } } else { - job.next = now + Math.max(0, job.initialDelay); + job.next = AsyncTask.deadline(now, Math.max(0, job.initialDelay)); } } if (jobs.isEmpty()) { @@ -345,7 +345,7 @@ public void run() { job.failures++; job.lastError = String.valueOf(err); if (job.kind == FIXED_DELAY) { - job.next = now + job.period; + job.next = AsyncTask.deadline(now, job.period); } } } @@ -366,11 +366,17 @@ private long following(Job job, long now) { // under the monitor every other job and management call waits on. if (next <= now) { long missed = (now - next) / job.period + 1; - next += missed * job.period; + // 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; } - return now + job.period; + // 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. @@ -432,7 +438,7 @@ private void runJob(final Job job) { job.lastError = error; } if (job.kind == FIXED_DELAY && !stopped) { - job.next = end + job.period; + job.next = AsyncTask.deadline(end, job.period); } notifyAll(); } @@ -452,7 +458,7 @@ String claim(Job job) throws IOException { // 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 = now + job.lockAtMostFor; + 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)}); diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index 15a6fd1c279..283c557374c 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -73,6 +73,8 @@ public final class OtlpMetricExporter implements MetricReader { 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) { @@ -175,6 +177,30 @@ public boolean open(Config config) throws IOException { 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); + } + /// 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)); @@ -283,6 +309,22 @@ public boolean export() { 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, @@ -352,6 +394,9 @@ public Map status() { 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); } diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java index 5ad70e74a22..5ecc46abc0a 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpSchema.java @@ -780,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; @@ -788,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) { From 98e65a7ec354c6e0f315d7a30cef49bfc5f6105d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:33:26 +0300 Subject: [PATCH 34/48] Backend: non-blocking chained futures, handshake stop, tracer before accept, Future results; fix TelemetryTest flake - An @Async call returning another's pending AsyncTask completes when that one does instead of blocking its worker, which deadlocked a one-thread executor (and any pool under enough concurrent outer calls). Any other Future is still awaited, as Spring's interceptor does. - The websocket handshake (router, getSubprotocols, onOpen) is marked as serving work: a stop() from it discounts its own connection and defers the teardown until it returns; the fallback router counts in flight too. - A server's tracer, including the untraced marker, is set before any worker accepts, so a request accepted meanwhile cannot report to another server's tracer. - An @McpTool or @ManagedOperation returning a Future is a build error, as a route's is: the caller would get the pending task, not the result. - TelemetryTest drains the network queue before each test and waits for its own span: a previous test's late export landed in the collector mock and failed build-test (21) intermittently. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 37 ++++++ .../backend/WebSocketReviewFixesTest.java | 50 ++++++++ .../maven/processors/BackendBeans.java | 56 +++++++++ .../maven/processors/BackendBeansTest.java | 22 ++++ .../codename1/telemetry/TelemetryTest.java | 37 +++++- .../src/com/codename1/backend/AsyncTask.java | 111 ++++++++++++++++-- .../src/com/codename1/backend/Backend.java | 14 ++- .../src/com/codename1/backend/HttpServer.java | 56 +++++++-- 8 files changed, 352 insertions(+), 31 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index e39a40927ca..9016ad00bcb 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -526,6 +526,43 @@ protected Object call() { 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 { 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/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 index cf69ab8811a..2cae971fe28 100644 --- 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 @@ -33,6 +33,7 @@ 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; @@ -1395,6 +1396,16 @@ private void collectTools(Bean bean) { + "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", "")); @@ -1525,6 +1536,16 @@ private void collectManaged(Bean bean) { 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; @@ -1567,6 +1588,41 @@ private void collectManaged(Bean bean) { 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) { 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 index c68c5b945b1..90456355c03 100644 --- 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 @@ -739,6 +739,28 @@ public void aConfigurationThatStepsAsideTakesItsFactoriesWithIt() throws Excepti } } + @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" + + "}\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")); + } + @Test public void oneMetricNameForTwoKindsIsABuildError() throws Exception { Map s = new LinkedHashMap(); 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/vm/backend/src/com/codename1/backend/AsyncTask.java b/vm/backend/src/com/codename1/backend/AsyncTask.java index 9419bdb0579..2868a0e49d6 100644 --- a/vm/backend/src/com/codename1/backend/AsyncTask.java +++ b/vm/backend/src/com/codename1/backend/AsyncTask.java @@ -51,6 +51,8 @@ public abstract class AsyncTask implements Runnable, Future { private boolean started; private Object value; private Throwable failure; + /// Run once this completes, outside its lock; see [#whenDone]. + private java.util.List listeners; /// #### Parameters /// @@ -78,6 +80,7 @@ public final void run() { } Object result = null; Throwable error = null; + AsyncTask chained = null; try { result = Tracing.inBackground(name, parent, owner, new Tracing.Work() { @Override @@ -85,7 +88,17 @@ public Object run(Span span) throws Exception { return call(); } }); - if (result instanceof Future) { + 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) { @@ -93,12 +106,23 @@ public Object run(Span span) throws Exception { } 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; - done = true; - notifyAll(); + 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 @@ -116,24 +140,85 @@ public Object run(Span span) throws Exception { /// 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. - synchronized void abandon(String reason) { - if (done) { - return; + void abandon(String reason) { + java.util.List fire; + synchronized (this) { + if (done) { + return; + } + failure = new IllegalStateException(name + ": " + reason); + fire = completeLocked(); } - failure = new IllegalStateException(name + ": " + reason); + 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 synchronized boolean cancel(boolean mayInterruptIfRunning) { - if (started || done) { - return false; + public boolean cancel(boolean mayInterruptIfRunning) { + java.util.List fire; + synchronized (this) { + if (started || done) { + return false; + } + cancelled = true; + fire = completeLocked(); } - cancelled = true; - done = true; - notifyAll(); + fire(fire); return true; } diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 16661b9adb0..62707e13427 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -569,11 +569,19 @@ public void fallback(final HttpServer.WebSocketHandler router) { @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); } } @@ -1725,7 +1733,7 @@ public void register(HttpServer.WebSocketRegistry direct) server = HttpServer.start(bindHost, listenPort, listenBacklog, workerCount, new Serving(chain, sessions, tasks, requestLog, instrumented, app, track, active != null ? active : Tracing.NONE, inFlight), - context, routes); + context, routes, active != null ? active : Tracing.NONE); bound = true; } finally { if (!bound && ownsContext) { @@ -1737,9 +1745,9 @@ public void register(HttpServer.WebSocketRegistry direct) synchronized (tasks) { tasks.server = server; } - // Always set: with tracing off, the untraced marker, so another traced + // 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. - server.setTracer(active != null ? active : Tracing.NONE); // 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. diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index d9dad69c1c3..ccaaa3cd545 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -1753,6 +1753,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 @@ -1827,6 +1838,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. @@ -2297,6 +2311,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. /// @@ -2315,7 +2332,8 @@ private static List refusalHeaders() { /// 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_H2.get()) + || Boolean.TRUE.equals(SERVING_UPGRADE.get()); } /// workOutstanding(), minus what the calling handler is itself holding. @@ -2335,6 +2353,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(); } @@ -2536,14 +2561,10 @@ static boolean acceptsVirtualTasks() { } /// The tracer this server's requests report to, when it has one of its own. - private volatile Tracer serverTracer; //NOPMD AvoidUsingVolatile - set by setTracer after start, read by every worker - - /// Traces this server's requests with `tracer` rather than whichever - /// tracer is installed process-wide -- which, with two servers, is the one - /// that started last. Called by the server's builder. - void setTracer(Tracer tracer) { - this.serverTracer = tracer; - } + /// 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() { @@ -3711,6 +3732,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 @@ -3856,6 +3892,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; } From 1d43b28fba18a465e5eed31dccb70f07fc958600 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:54:59 +0300 Subject: [PATCH 35/48] Backend: only the rotating request announces a session rotation; exporter reopen waits; Future attributes - Memory-store sessions: a request records the id it looked the session up by (its cookie), not the shared object's current id, and only the request that called changeSessionId() stores and announces the rotation. A request arriving mid-login used to undo the login's rotation -- or complete it and send the new id to whoever held the old cookie -- defeating the fixation defence. - A reopened OTLP metric exporter waits for its previous thread's last export before applying the new configuration, instead of starting a second loop beside it; refused if that export outlasts a minute. - An @ManagedAttribute getter returning a Future, or woven @Async, is a build error, as operations and tools already are. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 76 +++++++++++++++++++ .../backend/otel/OtlpMetricsProtoTest.java | 61 +++++++++++++++ .../maven/processors/BackendBeans.java | 9 +++ .../maven/processors/BackendBeansTest.java | 3 + .../src/com/codename1/backend/Backend.java | 3 + .../src/com/codename1/backend/HttpServer.java | 11 ++- .../com/codename1/backend/HttpSession.java | 30 ++++++++ .../src/com/codename1/backend/Sessions.java | 25 +++++- .../backend/otel/OtlpMetricExporter.java | 36 +++++++++ 9 files changed, 248 insertions(+), 6 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 9016ad00bcb..403759d9935 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -2427,6 +2427,82 @@ public void requestEnded(Object[] beans) { } } + @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); + read(meanwhile); + 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 { 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 index bcba6436c13..171276d6d35 100644 --- a/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/otel/OtlpMetricsProtoTest.java @@ -387,4 +387,65 @@ public void run() { 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/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 index 2cae971fe28..cd220d37f9d 100644 --- 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 @@ -1519,6 +1519,15 @@ private void collectManaged(Bean bean) { + "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 " 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 index 90456355c03..0bd4003d516 100644 --- 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 @@ -752,6 +752,7 @@ public void futuresFromToolsAndOperationsAreBuildErrors() throws Exception { + " 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 " @@ -759,6 +760,8 @@ public void futuresFromToolsAndOperationsAreBuildErrors() throws Exception { 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 diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 62707e13427..4fa773fb3ea 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -833,6 +833,8 @@ public HttpServer.Response handle(HttpServer.Request request) 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); @@ -935,6 +937,7 @@ public HttpServer.Response handle(HttpServer.Request request) } Tasks.leave(previousTasks); Tracing.disown(previousOwner); + HttpSession.leaveRequest(previousServing); // Last: a stop() this request made tears down only now. inFlight.leave(); } diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index ccaaa3cd545..d5c07c37b14 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -596,17 +596,20 @@ public HttpSession getSession(boolean create) { + "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(sessionResolved ? null - : Sessions.cookieValue(getHeader("cookie"), owner.getCookieName()), - create); + session = owner.find(presented, create); } catch (IOException err) { throw new IllegalStateException("The session store failed: " + err.getMessage(), err); } sessionResolved = true; if (session != null) { - owner.enter(this, session); + // 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; } diff --git a/vm/backend/src/com/codename1/backend/HttpSession.java b/vm/backend/src/com/codename1/backend/HttpSession.java index 0a1a9c3e5f7..014d14805e4 100644 --- a/vm/backend/src/com/codename1/backend/HttpSession.java +++ b/vm/backend/src/com/codename1/backend/HttpSession.java @@ -173,10 +173,38 @@ public String changeSessionId() { } 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"); @@ -215,6 +243,7 @@ synchronized void clean() { dirty = false; fresh = false; previousId = null; + rotatedBy = null; rotationLost = false; changed.clear(); maxInactiveChanged = false; @@ -234,6 +263,7 @@ synchronized boolean isMaxInactiveChanged() { synchronized void undoRotation(String previous) { id = previous; previousId = null; + rotatedBy = null; } synchronized void markRotationLost() { diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index c33535d5a6c..52260533714 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -348,6 +348,14 @@ HttpServer.Response finish(HttpServer.Request request, HttpSession session, 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 @@ -503,7 +511,20 @@ synchronized Object beanLock(HttpSession session) { private final Map retired = new HashMap(); /// A request has resolved `session`; counted until [#leave]. - synchronized void enter(HttpServer.Request request, HttpSession session) { + 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)) { @@ -513,7 +534,7 @@ synchronized void enter(HttpServer.Request request, HttpSession session) { if (request.sessionIdsFound == null) { request.sessionIdsFound = new HashMap(); } - request.sessionIdsFound.put(session, session.getId()); + request.sessionIdsFound.put(session, foundUnder); int[] count = (int[]) inUse.get(session); if (count == null) { count = new int[1]; diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index 283c557374c..ce8d2868dcd 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -90,6 +90,7 @@ 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 @@ -201,6 +202,41 @@ private boolean partialSuccess(Web.Result result, long[] rejected, String[] mess 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)); From c821e65ce448304c9345f5915525fd8cc6d37d05 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:00:36 +0300 Subject: [PATCH 36/48] Backend: Prometheus-folded aspect metric names, replaced shared gauges, prototype jobs warned - @Timed/@Counted names are checked at build time the way the runtime claims them for Prometheus: two names that fold alike (latency.ms, latency_ms), or a counter's _total against a histogram's series, are refused, instead of throwing in the woven finally after the body ran. - A gauge that replaces one addSource() built drops the stale shared registration, so the old server's last removeSource no longer deletes it. - A prototype bean with @Scheduled methods builds with a warning, as Spring runs it: one instance runs its jobs for the server's life and a prototype is never destroyed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 21 +++++++ .../maven/processors/BackendBeans.java | 58 +++++++++++++++++-- .../maven/processors/BackendBeansTest.java | 20 +++++++ .../codename1/backend/metrics/Metrics.java | 17 ++++-- 4 files changed, 106 insertions(+), 10 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 403759d9935..b341f39c304 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -268,6 +268,27 @@ void metricUnitMismatch() throws Exception { 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() { 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 index cd220d37f9d..70713baf8a6 100644 --- 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 @@ -324,6 +324,8 @@ static final class Aspect { /// 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(); @@ -1690,6 +1692,18 @@ private void resolve() { 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 @@ -2248,17 +2262,49 @@ private void claimAspectMetrics(AnnotatedClass cls, MethodInfo m, AnnotationValu private void claimAspectMetric(AnnotatedClass cls, String name, String kind, String by) { String[] earlier = aspectMetrics.get(name); - if (earlier == null) { - aspectMetrics.put(name, new String[] {kind, by}); + 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; } - 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."); + 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()) { 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 index 0bd4003d516..a44cd4ae981 100644 --- 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 @@ -780,6 +780,13 @@ public void oneMetricNameForTwoKindsIsABuildError() throws Exception { + "}\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" @@ -1256,6 +1263,19 @@ public void aFieldDependencyIsInitializedBeforeItsUser() throws Exception { } } + @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 diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index 66563dd1774..b422df4c671 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -103,7 +103,7 @@ public static Histogram histogram(String name, String description, String unit, public static Gauge gauge(String name, String description, String unit, Gauge.Source source) { Gauge g = new Gauge(name, description, unit, source); - replaceGauge(g); + replaceGauge(g, false); return g; } @@ -111,11 +111,13 @@ public static Gauge gauge(String name, String description, String unit, public static Gauge gauge(String name, String description, String unit, Gauge.MultiSource source) { Gauge g = new Gauge(name, description, unit, source); - replaceGauge(g); + replaceGauge(g, false); return g; } - private static synchronized void replaceGauge(Gauge 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. @@ -128,6 +130,13 @@ private static synchronized void replaceGauge(Gauge g) { } 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. @@ -300,7 +309,7 @@ public double read() { } return any ? sum : Double.NaN; } - })); + }), true); SHARED.put(name, all); } sources.add(source); From 035be3064b785f9b87e3312319aed657956d070a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:16:24 +0300 Subject: [PATCH 37/48] Backend: old session id retired at rotation, request beans drained fully, interface aspects refused - The memory store answers a lookup only when the session still has that id: a login's changeSessionId() retires the old, possibly planted id at once, as a servlet container does, instead of when the login request saves. An undone rotation restores the id and the entry answers again. - Request-scoped beans that a @PreDestroy builds are destroyed however deep the chain goes (bounded), not just one pass further. - @Transactional, @Async, @Timed or @Counted on a project interface -- on a default method, an abstract one, or the type -- is a build error: the build weaves classes, and the annotation was silently applied to nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 49 ++++++++++++++++++- .../maven/processors/BackendBeans.java | 35 ++++++++++++- .../maven/processors/BackendBeansTest.java | 19 +++++++ .../src/com/codename1/backend/Backend.java | 21 ++++++-- .../src/com/codename1/backend/Sessions.java | 12 ++++- 5 files changed, 130 insertions(+), 6 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index b341f39c304..5c50f1c83fe 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -2418,6 +2418,52 @@ public HttpServer.Response handle(HttpServer.Request request) } } + @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("a JSON body a request bean owns is written before the bean is destroyed") void deferredJsonIsWrittenBeforeRequestBeansEnd() throws Exception { @@ -2505,7 +2551,8 @@ public void run() { assertTrue(rotated.await(10, TimeUnit.SECONDS)); HttpURLConnection meanwhile = open(port, "/peek"); // the old cookie, mid-login meanwhile.setRequestProperty("Cookie", old); - read(meanwhile); + 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"); 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 index 70713baf8a6..26ce03a97eb 100644 --- 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 @@ -2170,6 +2170,35 @@ private void visit(Bean b, Map state, List path) { /// 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()) { @@ -2307,7 +2336,11 @@ static String promName(String name) { private void collectAspects() { for (AnnotatedClass cls : ctx.getClassIndex().values()) { - if (!concerns(cls) || cls.isInterface()) { + if (concerns(cls) && cls.isInterface()) { + refuseInterfaceAspects(cls); + continue; + } + if (!concerns(cls)) { continue; } AnnotationValues classTx = cls.getClassAnnotation(TRANSACTIONAL); 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 index a44cd4ae981..aa39bf54820 100644 --- 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 @@ -1263,6 +1263,25 @@ public void aFieldDependencyIsInitializedBeforeItsUser() throws Exception { } } + @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 aPrototypeWithJobsIsWarnedAbout() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 4fa773fb3ea..b7f45d08904 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -793,6 +793,9 @@ private void failed(HttpServer.Request request, List attempted, 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); @@ -809,11 +812,23 @@ private void endRequestBeans(HttpServer.Request request, HttpServer.Response response) { if (app != null) { Object[] beans = request.takeScopedBeans(); - if (beans != null) { - if (response != null) { - response.serializeDeferredJson(); + if (beans != null && response != null) { + response.serializeDeferredJson(); + } + // 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(); } } } diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 52260533714..a4e3cdbdea8 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -836,7 +836,17 @@ public static final class Memory implements SessionStore { @Override public synchronized HttpSession load(String id) { - return (HttpSession) sessions.get(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 From 5b45f82f5d4cccb8b9d027bf35afe96a0a8e183f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:20:00 +0300 Subject: [PATCH 38/48] Backend: one backend per process; conditional deps checked at start as in Spring - Backend.start() refuses a second live Backend in the same process (a stopping one is waited for, so stop-then-start still works). Scaling out is more processes -- sessions in the JDBC store, job locks in the database -- and the runtime's process-wide state (tracer, metrics and exporter, default executors, virtual-thread hosts) assumes one server. The tests that exercised two concurrent servers are removed; one test pins the rule. - A required dependency whose candidates are all conditional is checked at start, as Spring checks it (Wiring.single names the injection point); the developer guide no longer claims the build proves coverage. - A @Scheduled method returning a Future builds with a warning: its return is ignored, as in Spring, and the run ends when the method returns. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/developer-guide/Backend-Beans.asciidoc | 9 +- docs/developer-guide/Backend.asciidoc | 5 + .../backend/ApplicationRuntimeTest.java | 377 ++---------------- .../backend/otel/OtlpTracerTest.java | 50 --- .../maven/processors/BackendBeans.java | 14 + .../maven/processors/BackendBeansTest.java | 12 + .../src/com/codename1/backend/Backend.java | 90 +++-- .../src/com/codename1/backend/HttpServer.java | 3 + 8 files changed, 135 insertions(+), 425 deletions(-) diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc index bba8a7d5de2..d2ef536d2d9 100644 --- a/docs/developer-guide/Backend-Beans.asciidoc +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -245,9 +245,12 @@ key count as a match, and several keys in `value` must all match. `prefix` is pu in front of each key with a dot. Both are evaluated once, at start-up, as one `if` around the bean's construction. -Because the build sees both branches, it also checks both: every bean that needs a -conditional one must be covered whichever way the condition goes, by another bean -of the same type under the other condition or by being conditional itself. +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 diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index 5cb72c325d1..fff8040f0d0 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -167,6 +167,11 @@ 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 diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 5c50f1c83fe..7a7c07af050 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -877,6 +877,35 @@ void unknownCronZone() { CronSchedule.parse("0 0 0 * * *", "Europe/Berlin"); } + @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 { @@ -938,50 +967,6 @@ public void stopped() { "the beans were destroyed while a start-up task was still using them"); } - @Test - @DisplayName("one exporter given to a second server is refused without touching the first") - void sharedExporterSurvivesARefusedSecondServer() throws Exception { - ServerSocket probe = new ServerSocket(0); - int closed = probe.getLocalPort(); - probe.close(); - final com.codename1.backend.otel.OtlpMetricExporter exporter = - new com.codename1.backend.otel.OtlpMetricExporter("shared"); - Properties first = new Properties(); - first.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); - first.setProperty(com.codename1.backend.otel.OtlpMetricExporter.ENDPOINT, - "http://127.0.0.1:" + closed + "/v1/metrics"); - first.setProperty(com.codename1.backend.otel.OtlpMetricExporter.INTERVAL, "20"); - final HttpServer.Handler none = new HttpServer.Handler() { - public HttpServer.Response handle(HttpServer.Request request) { - return null; - } - }; - Backend running = Backend.builder(Config.of(first, "test")).quiet().metrics(exporter) - .handler(none).start(); - try { - final Properties second = new Properties(); - second.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); - second.setProperty(com.codename1.backend.otel.OtlpMetricExporter.ENDPOINT, - "http://127.0.0.1:9/elsewhere"); - assertThrows(Exception.class, () -> Backend.builder(Config.of(second, "test")) - .quiet().metrics(exporter).handler(none).start()); - assertEquals("http://127.0.0.1:" + closed + "/v1/metrics", - exporter.status().get("endpoint"), - "the refused server reconfigured the running exporter"); - // Still running: failures keep being counted against the first endpoint. - long before = ((Number) exporter.status().get("failures")).longValue(); - long deadline = System.currentTimeMillis() + 5000; - while(System.currentTimeMillis() < deadline - && ((Number) exporter.status().get("failures")).longValue() < before + 2) { - Thread.sleep(20); - } - assertTrue(((Number) exporter.status().get("failures")).longValue() >= before + 2, - "the refused server stopped the running exporter"); - } finally { - running.stop(); - } - } - @Test @DisplayName("a metric reader that declines to open is never shut down") void declinedMetricReaderIsNotStopped() throws Exception { @@ -1029,61 +1014,6 @@ void autoExecutorIsPinnedByAnExplicitKind() { } } - @Test - @DisplayName("the task queue gauge leaves out a server that does not measure") - void queueGaugeSkipsUnmeasuredServers() throws Exception { - Properties measuredSettings = new Properties(); - measuredSettings.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); - Backend measured = Backend.builder(Config.of(measuredSettings, "dev")).quiet() - .handler(new HttpServer.Handler() { - public HttpServer.Response handle(HttpServer.Request request) { - return null; - } - }).start(); - final CountDownLatch release = new CountDownLatch(1); - int port = freePort(); - Properties quietSettings = new Properties(); - quietSettings.setProperty(Config.SERVER_PORT, String.valueOf(port)); - quietSettings.setProperty("cn1.task.executor.unmeasured.threads", "1"); - // A development profile turns the management endpoints, and with them - // measuring, on by default. - quietSettings.setProperty(Management.ENABLED, "false"); - Backend unmeasured = Backend.builder(Config.of(quietSettings, "test")).quiet() - .handler(new HttpServer.Handler() { - public HttpServer.Response handle(HttpServer.Request request) { - // On this server's thread, so on its executors: one task - // holds the only thread and the next one waits. - TaskExecutor e = Tasks.executor("unmeasured", Tasks.PLATFORM); - Runnable wait = new Runnable() { - public void run() { - try { - release.await(10, TimeUnit.SECONDS); - } catch (InterruptedException err) { - Thread.currentThread().interrupt(); - } - } - }; - e.execute(wait); - e.execute(wait); - return HttpServer.Response.text(200, "queued"); - } - }).start(); - try { - assertTrue(measured.isMeasured()); - assertFalse(unmeasured.isMeasured()); - assertEquals("queued", read(open(port, "/fill"))); - com.codename1.backend.metrics.Instrument gauge = Metrics.get("cn1.task.queue_depth"); - assertNotNull(gauge); - String points = String.valueOf(gauge.points()); - assertFalse(points.contains("unmeasured"), - "a server with metrics off reported its queue: " + points); - } finally { - release.countDown(); - unmeasured.stop(); - measured.stop(); - } - } - @Test @DisplayName("the websocket wrapper forwards the endpoint's subprotocols") void wrappedEndpointKeepsItsSubprotocols() { @@ -1154,38 +1084,6 @@ void sessionBeansAreSharedAcrossCopies() { assertEquals("cart", second.scopedBeans(2)[0]); } - @Test - @DisplayName("the built-in server gauges add up every running server, and drop a stopped one") - void serverGaugesAggregate() throws Exception { - Backend[] servers = new Backend[2]; - int[] ports = {freePort(), freePort()}; - for(int i = 0 ; i < 2 ; i++) { - Properties settings = new Properties(); - settings.setProperty(Config.SERVER_PORT, String.valueOf(ports[i])); - servers[i] = Backend.builder(Config.of(settings, "dev")).quiet() - .handler(new HttpServer.Handler() { - public HttpServer.Response handle(HttpServer.Request request) - throws Exception { - return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); - } - }).start(); - } - try { - read(open(ports[0], "/x")); - read(open(ports[1], "/x")); - read(open(ports[1], "/x")); - double both = served(); - double expected = number(servers[0]) + number(servers[1]); - assertEquals(expected, both, 0.0, "the gauge reported one server, not the sum"); - servers[1].stop(); - assertEquals(number(servers[0]), served(), 0.0, - "a stopped server was still counted"); - } finally { - servers[0].stop(); - servers[1].stop(); - } - } - private static double served() { Map point = (Map)Metrics.get("cn1.server.requests_served").points().get(0); return ((Number)point.get("value")).doubleValue(); @@ -1255,34 +1153,6 @@ private static int manage(int port, String path, String body) throws IOException return c.getResponseCode(); } - @Test - @DisplayName("a server that does not measure keeps its requests out of another's histogram") - void uninstrumentedServerIsNotMeasured() throws Exception { - int[] ports = {freePort(), freePort()}; - Properties measured = new Properties(); - measured.setProperty(Config.SERVER_PORT, String.valueOf(ports[0])); - Properties plain = new Properties(); - plain.setProperty(Config.SERVER_PORT, String.valueOf(ports[1])); - HttpServer.Handler ok = new HttpServer.Handler() { - public HttpServer.Response handle(HttpServer.Request request) throws Exception { - return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); - } - }; - Backend a = Backend.builder(Config.of(measured, "dev")).quiet().handler(ok).start(); - Backend b = Backend.builder(Config.of(plain, "prod")).quiet().handler(ok).start(); - try { - read(open(ports[0], "/a")); - long before = requestCount(); - read(open(ports[1], "/b")); - read(open(ports[1], "/b")); - assertEquals(before, requestCount(), - "requests to a server without metrics were recorded"); - } finally { - a.stop(); - b.stop(); - } - } - private static long requestCount() { long n = 0; List points = Metrics.get("http.server.request.duration").points(); @@ -1320,46 +1190,6 @@ public HttpServer.Response handle(HttpServer.Request request) } } - @Test - @DisplayName("each server has its own executors, and stopping one leaves the other's running") - void executorsArePerServer() throws Exception { - final TaskExecutor[] seen = new TaskExecutor[2]; - Backend[] servers = new Backend[2]; - int[] ports = {freePort(), freePort()}; - for(int i = 0 ; i < 2 ; i++) { - final int index = i; - Properties settings = new Properties(); - settings.setProperty(Config.SERVER_PORT, String.valueOf(ports[i])); - servers[i] = Backend.builder(Config.of(settings, "test")).quiet() - .handler(new HttpServer.Handler() { - public HttpServer.Response handle(HttpServer.Request request) - throws Exception { - seen[index] = Tasks.executor("jobs", Tasks.PLATFORM); - return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); - } - }).start(); - } - try { - assertEquals("ok", read(open(ports[0], "/"))); - assertEquals("ok", read(open(ports[1], "/"))); - assertTrue(seen[0] != null && seen[1] != null && seen[0] != seen[1], - "two servers shared one executor"); - servers[1].stop(); - assertTrue(seen[1].isShutdown()); - assertFalse(seen[0].isShutdown(), "stopping one server shut the other's executor"); - final CountDownLatch ran = new CountDownLatch(1); - seen[0].execute(new Runnable() { - public void run() { - ran.countDown(); - } - }); - assertTrue(ran.await(5, TimeUnit.SECONDS)); - } finally { - servers[0].stop(); - servers[1].stop(); - } - } - @Test @DisplayName("a task still queued when the shutdown deadline passes never starts") void shutdownDropsQueuedWorkAtTheDeadline() throws Exception { @@ -2058,45 +1888,6 @@ void anEmptyBatchIsInvalid() throws Exception { assertTrue(body.contains("-32600") && body.contains("\"id\":null"), body); } - @Test - @DisplayName("a server with tracing off stays untraced when another server installs a tracer") - void anUntracedServerIsNotClaimed() throws Exception { - final List started = new ArrayList(); - Tracer other = new QuietTracer() { - public Span startSpan(String name, int kind, Span parent, String traceparent, - String tracestate) { - started.add(name); - return null; - } - }; - 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 { - // A span of the application's own, inside the request. - Tracing.startSpan("custom").end(); - return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); - } - }).start(); - Tracing.install(other); // another server, started after - try { - assertEquals("ok", read(open(port, "/"))); - assertTrue(started.isEmpty(), - "the untraced server's work went to another server's tracer: " + started); - // The fallback itself is unchanged: with no owner, the installed one. - Tracing.startServer(new HttpServer.Request("GET", "/", "HTTP/1.1", - new LinkedHashMap(), null), false, null); - assertEquals(1, started.size()); - } finally { - Tracing.install(null); - backend.stop(); - } - } - @Test @DisplayName("a tokenless MCP endpoint binds loopback, and refuses an explicit public address") void tokenlessMcpStaysOnLoopback() throws Exception { @@ -2648,89 +2439,6 @@ public HttpServer.Response handle(HttpServer.Request request) } } - @Test - @DisplayName("a retired session's beans are destroyed with their own server's executors") - void retiredSessionTeardownKeepsItsServer() throws Exception { - final TaskExecutor[] mine = new TaskExecutor[1]; - final TaskExecutor[] atDestroy = new TaskExecutor[1]; - final CountDownLatch holding = 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).scopedBeans(1)[0] = "cart"; - return HttpServer.Response.text(200, "in"); - } - if (t.startsWith("/hold")) { - request.getSession(false).scopedBeans(1); - mine[0] = Tasks.executor("probe", Tasks.PLATFORM); - holding.countDown(); - release.await(10, TimeUnit.SECONDS); - return HttpServer.Response.text(200, "held"); - } - request.getSession(false).invalidate(); - return HttpServer.Response.text(200, "out"); - } - }}; - } - - public void sessionEnded(Object[] beans) { - atDestroy[0] = Tasks.executor("probe", Tasks.PLATFORM); - } - }).start(); - // Newer, so it is what a thread with no server of its own falls back to. - Properties otherSettings = new Properties(); - otherSettings.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); - Backend other = Backend.builder(Config.of(otherSettings, "test")).quiet() - .handler(new HttpServer.Handler() { - public HttpServer.Response handle(HttpServer.Request request) { - return null; - } - }).start(); - try { - HttpURLConnection in = open(port, "/in"); - assertEquals("in", read(in)); - String cookie = in.getHeaderField("Set-Cookie"); - final String pair = cookie.substring(0, cookie.indexOf(';')); - final int p = port; - final String[] held = new String[1]; - Thread holder = new Thread(new Runnable() { - public void run() { - try { - HttpURLConnection h = open(p, "/hold"); - h.setRequestProperty("Cookie", pair); - held[0] = read(h); - } catch (IOException err) { - held[0] = String.valueOf(err); - } - } - }); - holder.start(); - assertTrue(holding.await(10, TimeUnit.SECONDS)); - HttpURLConnection out = open(port, "/out"); - out.setRequestProperty("Cookie", pair); - assertEquals("out", read(out)); - release.countDown(); - holder.join(10000); - assertEquals("held", held[0]); - assertNotNull(atDestroy[0], "the retired beans were never destroyed"); - assertTrue(atDestroy[0] == mine[0], - "a retired session's @PreDestroy ran with another server's executors"); - } finally { - release.countDown(); - other.stop(); - 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) @@ -2977,33 +2685,6 @@ void configuredPathsAreCanonical() throws Exception { "/manage")); } - @Test - @DisplayName("one DevTools given to two servers answers each from its own server") - void devToolsStayWithTheirServer() throws Exception { - com.codename1.backend.mcp.DevTools shared = new com.codename1.backend.mcp.DevTools(); - int portA = freePort(); - int portB = freePort(); - Backend a = startAnswering(portA, "from A", shared); - Backend b = startAnswering(portB, "from B", shared); - try { - HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + portA - + "/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("from A"), "server A's tool reached another server: " - + answer); - } finally { - b.stop(); - a.stop(); - } - } - private static Backend startAnswering(int port, final String text, com.codename1.backend.mcp.DevTools tools) throws Exception { Properties settings = new Properties(); 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 c240d9e034a..bbf6dd71f45 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 @@ -323,56 +323,6 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { "the status description carried the excluded message"); } - @Test - @DisplayName("a second traced server leaves the first one's tracer running and in use") - void twoTracedServersKeepTheirOwnTracers() throws Exception { - int[] ports = {freePort(), freePort()}; - Backend[] servers = new Backend[2]; - String[] names = {"first-service", "second-service"}; - for(int i = 0 ; i < 2 ; i++) { - Properties settings = settings(ports[i]); - settings.setProperty(OtlpTracer.SERVICE_NAME, names[i]); - servers[i] = Backend.builder(Config.of(settings, "test")) - .quiet() - .tracing(new OtlpTracer()) - .handler(new HttpServer.Handler() { - public HttpServer.Response handle(HttpServer.Request request) - throws Exception { - return request.respond(200, "text/plain", "ok".getBytes("UTF-8")); - } - }) - .start(); - } - try { - // The first server's request, after the second installed its tracer. - HttpURLConnection c = (HttpURLConnection)new URL( - "http://127.0.0.1:" + ports[0] + "/first").openConnection(); - assertEquals(200, c.getResponseCode()); - c.getInputStream().close(); - } finally { - servers[0].stop(); - servers[1].stop(); - } - String service = null; - for(int iter = 0 ; iter < exports.size() ; iter++) { - ExportTraceServiceRequest request = ExportTraceServiceRequest.parseFrom( - (byte[])exports.get(iter)); - for(int r = 0 ; r < request.getResourceSpansCount() ; r++) { - String name = attribute(request.getResourceSpans(r).getResource() - .getAttributesList(), "service.name"); - for(int sc = 0 ; sc < request.getResourceSpans(r).getScopeSpansCount() ; sc++) { - for(Span span : request.getResourceSpans(r).getScopeSpans(sc).getSpansList()) { - if("/first".equals(attribute(span.getAttributesList(), "url.path"))) { - service = name; - } - } - } - } - } - assertEquals("first-service", service, - "the first server's request was not exported by its own tracer"); - } - @Test @DisplayName("installing a tracer shuts down the one it replaces, and only that one") void replacingATracerShutsDownThePreviousOne() { 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 index 26ce03a97eb..e2c2aa96c99 100644 --- 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 @@ -1323,6 +1323,14 @@ private void collectJobs(Bean bean) { + "@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(); @@ -1953,6 +1961,12 @@ private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { 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); } 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 index aa39bf54820..4c77af30128 100644 --- 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 @@ -1282,6 +1282,18 @@ public void aspectsOnInterfacesAreBuildErrors() throws Exception { + "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(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index b7f45d08904..496aa0ddc51 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -320,6 +320,68 @@ private synchronized void markStopped() { notifyAll(); } + /// 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 + /// JDBC 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; @@ -1371,34 +1433,14 @@ public Builder sessionStore(SessionStore store) { /// Starts the server and returns, without installing a signal handler or /// waiting. Tests want this; a process wants [#run]. public Backend start() throws Exception { - claimStart(); + claimProcess(); + Backend running = null; try { - Backend running = startOnce(); - synchronized (this) { - live = running; - } + running = startOnce(); return running; } finally { - synchronized (this) { - starting = false; - } - } - } - - /// The Backend this builder last started, until it stops. - private Backend live; - private boolean starting; - - /// One live Backend per builder. The generated application is one object, - /// and a second create() replaces its beans and scheduler: stopping the - /// first server would then destroy what the second still uses. A builder - /// may start again once its previous server has stopped. - private synchronized void claimStart() { - if (starting || (live != null && !live.isStopped())) { - throw new IllegalStateException("This builder's server is still running; " - + "stop it before starting it again, or use another builder"); + settleProcess(running); } - starting = true; } private Backend startOnce() throws Exception { diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index d5c07c37b14..3ae63b82dde 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -2003,6 +2003,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; } From 3acab799110bc22db4dac2d9b359d4b9f8015ba4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:07:10 +0300 Subject: [PATCH 39/48] Backend: class-specific woven bodies, virtual-task stop, per-bean job names, full termination; build on master - Moved method bodies are named per declaring class, so a same-package subclass's woven override no longer overrides the base class's body: an explicit super.foo() runs the base body instead of recursing. - stop() called from a virtual @Async task no longer waits out the drain for its own host: each virtual task records the host it runs on. - Two beans of one class with @Scheduled methods (two @Bean factories) get their jobs named by bean, as Spring schedules each, instead of refusing the start with a duplicate name. - awaitTermination() also waits for a deferred teardown, so a process does not exit while @PreDestroy and the pool close are still running. - The wake-pipe native reads the array payload through CN1_ARRAY_DATA: master's 4-byte header (#5903) removed the data pointer. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 35 ++++++++++++++ .../maven/processors/BackendBeans.java | 21 +++++++- .../maven/processors/BackendSources.java | 5 +- .../maven/processors/BackendWeaver.java | 18 ++++++- .../maven/processors/BackendBeansTest.java | 48 ++++++++++++++++++- vm/backend/native/cn1_backend_server.c | 4 +- .../src/com/codename1/backend/Backend.java | 14 ++++++ .../src/com/codename1/backend/HttpServer.java | 35 +++++++++++++- .../src/com/codename1/backend/Tasks.java | 6 +-- 9 files changed, 172 insertions(+), 14 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 7a7c07af050..69a72af8635 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -877,6 +877,41 @@ void unknownCronZone() { CronSchedule.parse("0 0 0 * * *", "Europe/Berlin"); } + @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 { 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 index e2c2aa96c99..4b48def908d 100644 --- 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 @@ -1124,6 +1124,23 @@ private void checkUniqueNames() { } } } + // 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) { @@ -2063,7 +2080,7 @@ List proxiedMethods(AnnotatedClass cls, boolean includeFinal) { for (MethodInfo m : c.getMethods()) { if (m.isConstructor() || m.isStatic() || m.isPrivate() || m.isSynthetic() || "".equals(m.getName()) - || m.getName().endsWith(BackendWeaver.BODY_SUFFIX) + || BackendWeaver.isBody(m.getName()) || m.getName().startsWith(BackendWeaver.BRIDGE_PREFIX)) { continue; } @@ -2366,7 +2383,7 @@ private void collectAspects() { Aspects found = null; for (MethodInfo m : cls.getMethods()) { if (m.isConstructor() || m.isSynthetic() || "".equals(m.getName()) - || m.getName().endsWith(BackendWeaver.BODY_SUFFIX) + || BackendWeaver.isBody(m.getName()) || m.getName().startsWith(BackendWeaver.BRIDGE_PREFIX)) { continue; } 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 index 2d6f8cf74e2..77df7ae53b1 100644 --- 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 @@ -73,8 +73,9 @@ void aspects(BackendBeans.Aspects a, Map out) { String retType = isVoid ? "void" : typeName(ret); String params = parameters(owner, isStatic, args); String callArgs = arguments(isStatic, args.length); - String inner = (isStatic ? owner : "self") + "." + m.getName() - + BackendWeaver.BODY_SUFFIX + "(" + plainArguments(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"); 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 index 6a653785273..0a35c6d5496 100644 --- 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 @@ -70,6 +70,22 @@ 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) { + return method + BODY_SUFFIX + Integer.toHexString(ownerInternalName.hashCode()); + } + + /// 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"; @@ -173,7 +189,7 @@ public MethodVisitor visitMethod(int access, String name, String descriptor, // @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, name + BODY_SUFFIX, + MethodVisitor body = writer.visitMethod(bodyAccess, bodyName(owner, name), descriptor, signature, exceptions); MethodVisitor stub = writer.visitMethod(access & ~Opcodes.ACC_SYNCHRONIZED, name, descriptor, signature, exceptions); 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 index 4c77af30128..5ce8763b7cc 100644 --- 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 @@ -543,11 +543,34 @@ public void aSynchronizedAsyncMethodHoldsItsMonitorWhereTheBodyRuns() throws Exc 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("work$cn1body"); + 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 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(); @@ -1431,6 +1454,29 @@ public void aFactoryBuiltBeanKeepsItsScheduledJobs() throws Exception { } } + @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(); diff --git a/vm/backend/native/cn1_backend_server.c b/vm/backend/native/cn1_backend_server.c index 22e26e7aeee..d41381ea86c 100644 --- a/vm/backend/native/cn1_backend_server.c +++ b/vm/backend/native/cn1_backend_server.c @@ -1076,8 +1076,8 @@ JAVA_INT com_codename1_backend_Reactor_createWakePipeImpl___int_1ARRAY_R_int(COD fcntl(fds[i], F_SETFD, FD_CLOEXEC); } arr = (JAVA_ARRAY)out; - ((JAVA_ARRAY_INT*)arr->data)[0] = fds[0]; - ((JAVA_ARRAY_INT*)arr->data)[1] = fds[1]; + ((JAVA_ARRAY_INT*)CN1_ARRAY_DATA(arr))[0] = fds[0]; + ((JAVA_ARRAY_INT*)CN1_ARRAY_DATA(arr))[1] = fds[1]; return 0; #endif } diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 496aa0ddc51..45d160b498c 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -221,6 +221,20 @@ public Sessions getSessions() { /// 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. diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 3ae63b82dde..600c2ace95d 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -2597,6 +2597,9 @@ static boolean submitVirtualTask(Runnable task, HttpServer server) { int index = nextTaskHost; 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); @@ -2650,10 +2653,37 @@ private static void releaseTaskInbox(VtHost host) { /// 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 @@ -3233,7 +3263,10 @@ private void awaitTaskDrain(int callerFd) { if (hosts == null) { return; } - VtHost callersHost = callerFd >= 0 ? ownerOf(callerFd) : null; + // 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) { diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index 59324d367c5..398472bb9d8 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -282,12 +282,8 @@ public static void platform(Runnable task) { /// it, which is why the native source names it -- that keeps it alive through /// dead-code elimination. static void runVirtual(long token) { - Runnable task = HttpServer.takeVirtualTask(token); - if (task == null) { - return; - } try { - task.run(); + 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); From f930488ec3924b9dc63505c5e6daedde1b138dd6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:07:46 +0300 Subject: [PATCH 40/48] Backend: injective support names, discarded file responses closed, restart cursor, slot released - Woven body names carry the declaring class's own name, not its hash, and support-class names encode `_` and `$` injectively: two classes whose names shared a hash (Aa, BB), or Outer_Inner and Outer$Inner, no longer get one name -- a recursing super call, or one helper replacing the other. - A file response replaced by a 500 because the session could not be stored closes its descriptor; only the writer closed it before. - The virtual-task host cursor is reduced to the current server's host count, so a restart with fewer hosts does not index past its array. - A stopped Backend frees the process slot, so its destroyed beans and sessions are not kept reachable until the next start. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 73 +++++++++++++++++++ .../maven/processors/BackendBeans.java | 6 +- .../maven/processors/BackendWeaver.java | 6 +- .../maven/processors/BackendBeansTest.java | 35 +++++++++ .../src/com/codename1/backend/Backend.java | 29 +++++++- .../src/com/codename1/backend/HttpServer.java | 15 +++- 6 files changed, 157 insertions(+), 7 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 69a72af8635..a44653a30b0 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -877,6 +877,79 @@ void unknownCronZone() { 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 { 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 index 4b48def908d..7c7f0e5d278 100644 --- 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 @@ -2720,9 +2720,13 @@ static String identifier(String name) { /// 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: `_` is doubled before `$` becomes + /// `_`, so Outer_Inner and the nested Outer$Inner, which folded to one name, + /// get two -- one helper silently replaced the other before. static String baseName(String internal) { int slash = internal.lastIndexOf('/'); - return internal.substring(slash + 1).replace('$', '_'); + return internal.substring(slash + 1).replace("_", "__").replace('$', '_'); } static String qualify(String pkg, String simple) { 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 index 0a35c6d5496..eba9d0baa3d 100644 --- 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 @@ -79,7 +79,11 @@ final class BackendWeaver { /// 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) { - return method + BODY_SUFFIX + Integer.toHexString(ownerInternalName.hashCode()); + // 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. 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 index 5ce8763b7cc..8613dd165aa 100644 --- 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 @@ -549,6 +549,41 @@ public void aSynchronizedAsyncMethodHoldsItsMonitorWhereTheBodyRuns() throws Exc + "at once", java.lang.reflect.Modifier.isSynchronized(body.getModifiers())); } + @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")); + 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(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 45d160b498c..8e881b0f3c6 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -328,10 +328,23 @@ private void finishAfterCaller(boolean stopTracer) { } } - private synchronized void markStopped() { - stopping = false; - stopped = true; - notifyAll(); + 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. @@ -982,9 +995,17 @@ public HttpServer.Response handle(HttpServer.Request request) } } } 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. diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 600c2ace95d..42a171d9391 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -970,6 +970,16 @@ 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() { @@ -2594,7 +2604,10 @@ static boolean submitVirtualTask(Runnable task, HttpServer server) { synchronized (VIRTUAL_TASKS) { token = ++nextVirtualTask; VIRTUAL_TASKS.put(Long.valueOf(token), task); - int index = nextTaskHost; + // 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) { From 030a2feef35a6f713f4888730091fb12a7ae6b5f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:46:57 +0300 Subject: [PATCH 41/48] Backend build: injective support names, per-bean stand-ins, lifecycle runs synchronously - Support-class names escape `_` as `__` and `$` as `_S`, so no two class names meet (A$_B and A_$B collided under the previous scheme). - Scoped and lazy stand-ins are named per bean: two @Bean methods of one class, both request-scoped, each get their own instead of one source replacing the other. - A stand-in whose bean's constructor calls an overridable method runs the bean's own code during construction rather than dereferencing a scope that is not assigned yet. - @Async on @PostConstruct or @PreDestroy is not applied, with a warning, as Spring calls lifecycle methods on the bean itself: the server no longer becomes ready before initialisation, and a @PreDestroy body still runs. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../maven/processors/BackendBeans.java | 47 +++++++++-- .../maven/processors/BackendSources.java | 19 +++-- .../maven/processors/BackendBeansTest.java | 82 +++++++++++++++++++ 3 files changed, 137 insertions(+), 11 deletions(-) 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 index 7c7f0e5d278..6f655e67f9d 100644 --- 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 @@ -2065,9 +2065,20 @@ private void checkProxyable(Bean b) { } } String pkg = RestClientAnnotationProcessor.packageOf(b.type.replace('/', '.')); - b.proxyBinary = qualify(pkg, baseName(b.type) + (b.lazy ? "Cn1Lazy" : "Cn1Scoped")); + 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(); @@ -2395,6 +2406,18 @@ private void collectAspects() { 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) { @@ -2721,12 +2744,24 @@ static String identifier(String name) { /// 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: `_` is doubled before `$` becomes - /// `_`, so Outer_Inner and the nested Outer$Inner, which folded to one name, - /// get two -- one helper silently replaced the other before. + /// 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) { - int slash = internal.lastIndexOf('/'); - return internal.substring(slash + 1).replace("_", "__").replace('$', '_'); + 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) { 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 index 77df7ae53b1..96a2be7cbdf 100644 --- 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 @@ -370,12 +370,21 @@ String proxy(BackendBeans.Bean b) { } sb.append(typeName(args[i])).append(" a").append(i); } - sb.append(')').append(throwsClause(m)).append(" {\n "); - if (ret.getSort() != Type.VOID) { - sb.append("return "); + 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("cn1Target().").append(m.getName()).append('(') - .append(plainArguments(args.length)).append(");\n }\n\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(); 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 index 8613dd165aa..368db425598 100644 --- 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 @@ -549,6 +549,87 @@ public void aSynchronizedAsyncMethodHoldsItsMonitorWhereTheBodyRuns() throws Exc + "at once", java.lang.reflect.Modifier.isSynchronized(body.getModifiers())); } + @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. @@ -557,6 +638,7 @@ public void supportNamesStayDistinct() throws Exception { 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" From 4cdef68f348a3e30d1358afe2a144cd55c989afb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:26:33 +0300 Subject: [PATCH 42/48] Backend review fixes; recalibrate the Windows AMD perf row; GcSteadyState fault twin may wedge Review: - A request's scoped beans are destroyed even when serialising the deferred JSON body throws: the array was taken first, and every later pass found nothing to destroy. - @Profile("!") (an empty negated name) is a build error; it activated the bean under every profile. - A @PostConstruct or @PreDestroy returning a Future builds with a warning: the return is ignored, as in Spring, and the server goes on when the method returns. Gates: - perf-baseline.json: the windows-x64 AMD family 25 model 1 rows are recalibrated with calibrate-perf-baseline.py from the six calibration runs of #5903 plus this branch's run, which changes nothing in the VM. Its arrayRandom rounds spanned 1.06-1.49 inside one run (median 1.38 against a 0.97 baseline) -- runner noise the row's 25% tolerance could not absorb; the row is now 1.061 with 45%, the script's own rule for the observed spread. - GcSteadyStateIntegrationTest scenario 4 builds the reserve OUT (-DCN1_PACING_NO_RESERVE) to prove scenario 3 can fail. That deliberately broken build can wedge on the admission margin instead of finishing, and the gate required it to finish, so it failed on a slow runner (footprint 745MB of a 768MB budget, no cycles, no reserve parks). A wedge is now accepted as the demonstration, checked from the per-second probe: headroom under the threshold and no reserve parks. A finishing run is held to the same assertions as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ApplicationRuntimeTest.java | 35 +++++++ .../maven/processors/BackendBeans.java | 22 +++++ .../maven/processors/BackendBeansTest.java | 14 +++ .../src/com/codename1/backend/Backend.java | 39 ++++---- vm/selfhost/perf-baseline.json | 59 ++++++------ .../GcSteadyStateIntegrationTest.java | 93 +++++++++++++++---- 6 files changed, 198 insertions(+), 64 deletions(-) diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index a44653a30b0..400507b8b45 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -2363,6 +2363,41 @@ public void requestEnded(Object[] beans) { } } + @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 { 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 index 6f655e67f9d..cc624f25df6 100644 --- 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 @@ -717,6 +717,15 @@ private boolean lifecycle(AnnotatedClass cls, MethodInfo m, String what) { + " 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; } @@ -776,6 +785,19 @@ private void readModifiers(Bean bean, Map annotations, 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); 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 index 368db425598..0d79286c30e 100644 --- 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 @@ -549,6 +549,20 @@ public void aSynchronizedAsyncMethodHoldsItsMonitorWhereTheBodyRuns() throws Exc + "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(); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 8e881b0f3c6..3954b0a797f 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -901,23 +901,30 @@ private void endRequestBeans(HttpServer.Request request, HttpServer.Response response) { if (app != null) { Object[] beans = request.takeScopedBeans(); - if (beans != null && response != null) { - response.serializeDeferredJson(); - } - // 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; + 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(); } - app.requestEnded(beans); - beans = request.takeScopedBeans(); } } } 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/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" From 3b667ad128f5d0ef59fc363b5a276d2519e1c8e1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:29:11 +0300 Subject: [PATCH 43/48] Backend docs: drop unsupported hand-written mains, promote Future, db session store - Remove every hand-wired server from the guide (ServerSnippets, raw HttpServer.start/serveForever, installByHand, mcpTool builder); the build writes the entry point and that is the only supported shape. - Promote java.util.concurrent Future/ExecutionException/TimeoutException/ CancellationException/TimeUnit (and Properties) to documented backend API: rendered in the backend javadoc, gated by check-backend-jdk-surface.py (which also scans the guide's demos), and exercised on ParparVM by SelfTest.futures(). - Session store value "jdbc" -> "db"; diagrams clarified (no bare "null", fixedRate vs fixedDelay with varied run lengths). - ParparVM virtual threads are not Java 21 ones; say so. - Correct doc claims the audit found false: CN1_ prefixed env names, multi-server passages (one backend per process), @Scheduled scope rules, management "always on in dev", injected DataSource instead of DataSource.open(getenv), PushFeedback on the backend Json and injection. - Document the missing cn1.otel.* keys and CN1_HTTP_MAX_UPLOAD_MB. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/scripts/build_javadocs.sh | 18 +++- .../backend/DatabaseSnippets.java | 5 +- .../developerguide/backend/Notes.java | 15 ++-- .../developerguide/backend/NotesEndpoint.java | 6 +- .../developerguide/backend/PushFeedback.java | 32 ++++--- .../backend/ServerSnippets.java | 52 ------------ .../backend/TracingSnippets.java | 9 -- .../backend/WebSocketSnippets.java | 31 ------- docs/developer-guide/Backend-Beans.asciidoc | 13 +-- docs/developer-guide/Backend-Data.asciidoc | 25 +++--- docs/developer-guide/Backend-MCP.asciidoc | 10 +-- .../Backend-Observability.asciidoc | 27 ++---- .../Backend-Scheduling.asciidoc | 13 ++- .../developer-guide/Backend-Sessions.asciidoc | 19 ++--- docs/developer-guide/Backend-Web.asciidoc | 32 ------- docs/developer-guide/Backend.asciidoc | 33 +++++--- .../img/backend-request-lifecycle.svg | 14 ++-- .../img/backend-schedule-timelines.svg | 40 ++++----- .../img/backend-session-lifecycle.svg | 2 +- .../backend/ApplicationRuntimeTest.java | 30 +++---- .../backend/ServerEngineTransactionsTest.java | 2 +- scripts/check-backend-jdk-surface.py | 79 ++++++++++++++++++ .../resources/skill/references/backend.md | 6 +- .../concurrent/CancellationException.java | 28 +++++++ .../util/concurrent/ExecutionException.java | 37 +++++++++ .../src/java/util/concurrent/Future.java | 57 +++++++++++++ .../src/java/util/concurrent/TimeUnit.java | 79 ++++++++++++++++++ .../util/concurrent/TimeoutException.java | 28 +++++++ .../demo/selftest/com/demo/SelfTest.java | 83 +++++++++++++++++++ .../src/com/codename1/backend/Backend.java | 6 +- .../com/codename1/backend/HttpSession.java | 2 +- .../com/codename1/backend/SessionStore.java | 2 +- .../src/com/codename1/backend/Sessions.java | 28 +++---- 33 files changed, 577 insertions(+), 286 deletions(-) delete mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/ServerSnippets.java create mode 100755 scripts/check-backend-jdk-surface.py 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/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/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/ServerSnippets.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/ServerSnippets.java deleted file mode 100644 index bc5805a603c..00000000000 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/ServerSnippets.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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.Backend; -import com.codename1.backend.HttpServer; - -/** 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[] - } - - /** 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")); - } - } -} 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/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc index d2ef536d2d9..e7ddbe7f11e 100644 --- a/docs/developer-guide/Backend-Beans.asciidoc +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -173,7 +173,7 @@ include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/b ---- The value comes from the layers described under <>, -so `MAIL_HOST` in the environment -- or `CN1_MAIL_HOST` -- overrides the file. A +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 @@ -351,8 +351,12 @@ is checked by the build. include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ExpensiveIndex.java[tag=backend-bean-lazy,indent=0] ---- -A WebSocket endpoint and a bean with `@Scheduled` methods must be singletons, -since each serves the whole life of the server rather than one request. +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 @@ -378,8 +382,7 @@ is what makes the `flush` above safe: . `@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. Each server has its own executors, so stopping one - server leaves another in the same process running. + running are interrupted. . `@PreDestroy` and `destroyMethod` run, in reverse dependency order. . The connection pool closes. . The last metrics and spans are exported. diff --git a/docs/developer-guide/Backend-Data.asciidoc b/docs/developer-guide/Backend-Data.asciidoc index bf41c84a552..763c3cbb2e2 100644 --- a/docs/developer-guide/Backend-Data.asciidoc +++ b/docs/developer-guide/Backend-Data.asciidoc @@ -10,9 +10,9 @@ most likely to be almost right. === 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: +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] ---- @@ -116,18 +116,13 @@ point holds a `new` with the argument written into it, and a controller that nee a database 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: +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] ---- diff --git a/docs/developer-guide/Backend-MCP.asciidoc b/docs/developer-guide/Backend-MCP.asciidoc index 57bf180198e..325ff759ef9 100644 --- a/docs/developer-guide/Backend-MCP.asciidoc +++ b/docs/developer-guide/Backend-MCP.asciidoc @@ -56,11 +56,6 @@ 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. -A program that builds its server itself adds a tool that isn't a bean method by -implementing `com.codename1.backend.mcp.McpTool` -- a name, a description, an -input schema and a `call` method -- and passing it to the builder with -`mcpTool(tool)`, beside `mcp(...)`. - ==== The endpoint The endpoint speaks JSON-RPC 2.0 over MCP's Streamable HTTP transport, and @@ -93,9 +88,8 @@ answered 405. development profile. |=== -Each server has its own tools, even with several in one process, so a server -restarted in a test serves the tools its own beans provide and nothing a previous -one registered. +A server restarted in a test serves the tools its own beans provide and nothing +a previous one registered. === Tools for developing the server diff --git a/docs/developer-guide/Backend-Observability.asciidoc b/docs/developer-guide/Backend-Observability.asciidoc index 84aaaa25d27..c43a6b460a5 100644 --- a/docs/developer-guide/Backend-Observability.asciidoc +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -58,12 +58,17 @@ same way it configures everything else it runs: | `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 @@ -78,14 +83,6 @@ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://abc12345.live.dynatrace.com/api/v2/ot OTEL_EXPORTER_OTLP_HEADERS=Authorization=Api-Token%20dt0c01.XXXX ---- -A server started without the builder -- one that calls `HttpServer.start` or runs -the Lambda loop itself -- installs the tracer in one line: - -[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. @@ -97,7 +94,7 @@ application's own, and `Tracing.inSpan` times a block of work as a child span. 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 +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: ---- @@ -120,10 +117,7 @@ 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 metrics are the process's, so with several servers in one process -their exporters must report as the same service to the same collector; a second -exporter that would report the same numbers under another name is refused at -start-up. The management endpoints described below record and serve the same +traces on. The management endpoints described below record and serve the same metrics whether anything exports them or not. ==== What the server records @@ -175,11 +169,6 @@ The route attribute is the template -- `GET /notes/{id}` is recorded as ask for. A request that fails, whether in a handler or in the session store, is recorded with status 500. -The instruments belong to the process, as an OpenTelemetry meter's do. With more -than one server in a process, each server gauge reports the sum over the servers -still running, and the request histogram counts every server's requests. A -server that stops drops out of the sums. - ==== Managed beans The application's own metrics follow the JMX model -- attributes to watch and @@ -316,4 +305,4 @@ counter, so `http.server.request.duration` is scraped as 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, from `Backend.getManagedBeans()`. +available to code: inject `Backend` and call its `getManagedBeans()`. diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc index 0ad57803e2c..9f764c06b9b 100644 --- a/docs/developer-guide/Backend-Scheduling.asciidoc +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -27,8 +27,12 @@ method is called. 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. The body returns `AsyncResult.of(value)`, and the caller receives, -immediately, a `Future` that completes with that value when the body has run: +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 @@ -114,7 +118,10 @@ queue, and `cn1.task.queue_depth` reports how many are waiting, by executor. choice is logged once at start-up. |=== -The choice matters because of how virtual threads work on this runtime, explained +`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 only on the socket it serves. An outbound read -- a database query, a call to another service -- blocks the host thread running it, and with it every connection that host diff --git a/docs/developer-guide/Backend-Sessions.asciidoc b/docs/developer-guide/Backend-Sessions.asciidoc index dca16b9a74d..d16cac13862 100644 --- a/docs/developer-guide/Backend-Sessions.asciidoc +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -23,10 +23,8 @@ include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/b `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. Sessions need a -server started through `Backend.builder()`, which stores them and sends their -cookie; on a bare `HttpServer`, `getSession` throws rather than hand out a -session that would never be kept. +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 @@ -121,7 +119,7 @@ browser closes; how long the session lasts is the server's decision. sessions never expire, and a negative value stops the server at start-up. | `cn1.session.store` -| `memory`, the default, or `jdbc`. See <>. +| `memory`, the default, or `db`. See <>. | `cn1.session.same-site` | `Lax`, `Strict` or `None`. `None` lets other sites send the cookie, and a @@ -134,11 +132,8 @@ browser closes; how long the session lasts is the server's decision. Anything else stops the server at start-up. |=== -Every server has its own settings and its own sessions, even when two run in one -process. That matters because cookies aren't scoped by port: a browser sends a -cookie for `localhost` to every port on it, and two servers sharing one store -would accept each other's sessions. `Backend.getSessions()` returns a running -server's sessions, whose `getStore()` answers the store in use. +An injected `Backend` returns the server's sessions from `getSessions()`, whose +`getStore()` answers the store in use. === Where sessions are kept @@ -148,7 +143,7 @@ object the next one gets. It also means sessions end with the process, and that 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=jdbc` keeps sessions in the server's own database, in a table +`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 @@ -189,7 +184,7 @@ include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/b 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=jdbc` each instance of +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. diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index 6b7e031db6e..77d22a888f4 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -222,38 +222,6 @@ request, so there's no request or session to find the bean in, and using it ther throws `IllegalStateException`, as in Spring. Keep per-connection state in the session's attachment instead. -==== 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 diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index fff8040f0d0..0d2148d3001 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -193,6 +193,22 @@ 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 -- a descriptor a host polls -- while anything +else that blocks, `Object.wait()` included, blocks the host thread under it and +every virtual thread that host runs. 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 @@ -339,20 +355,13 @@ there is nothing next to the binary. | How long SQLite waits on a locked database file, 5 seconds by default. |=== +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. + 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. 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. diff --git a/docs/developer-guide/img/backend-request-lifecycle.svg b/docs/developer-guide/img/backend-request-lifecycle.svg index 60e211e0f9b..05d2428154d 100644 --- a/docs/developer-guide/img/backend-request-lifecycle.svg +++ b/docs/developer-guide/img/backend-request-lifecycle.svg @@ -21,26 +21,26 @@ 1 MCP endpoint /mcp, when enabled - null: next + not its path: next 2 Trace relay /otel/v1/traces, when enabled - null: next + not its path: next 3 Management /manage/..., when enabled - null: next + not its path: next 4 Your routes - generated routers, handler() ones + your controllers' generated routers - null: next + not its path: next 5 Static files cn1.static.root, always last - null: 404 + 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 @@ -56,7 +56,7 @@ bound and converted; a missing required one is answered 400 before your method runs; the return value becomes text, JSON or - your own Response; null is a 404; + 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 index 8dc1e518f8d..8769b6f950b 100644 --- a/docs/developer-guide/img/backend-schedule-timelines.svg +++ b/docs/developer-guide/img/backend-schedule-timelines.svg @@ -5,18 +5,18 @@ 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 - starts on a fixed grid - - - - - - - - - + 10s from start to start + + + + + + + + + - + 0s @@ -36,15 +36,15 @@ 100s fixedDelay = 10000 - waits after each end - - - - - - - - + 10s from end to next start + + + + + + + + 0s diff --git a/docs/developer-guide/img/backend-session-lifecycle.svg b/docs/developer-guide/img/backend-session-lifecycle.svg index 8100005dcbd..5133e9fdf0c 100644 --- a/docs/developer-guide/img/backend-session-lifecycle.svg +++ b/docs/developer-guide/img/backend-session-lifecycle.svg @@ -35,7 +35,7 @@ A restart or a second instance does not see them, so behind a load balancer this needs sticky sessions. - cn1.session.store=jdbc + 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, diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 400507b8b45..8141c930bae 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -657,13 +657,13 @@ public void run() { @Test @DisplayName("two servers' database session stores never read each other's sessions") - void jdbcSessionNamespaces(@org.junit.jupiter.api.io.TempDir java.io.File dir) + 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.Jdbc orders = new Sessions.Jdbc(pool, "com.example.orders"); - Sessions.Jdbc admin = new Sessions.Jdbc(pool, "com.example.admin"); + 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(); @@ -675,7 +675,7 @@ void jdbcSessionNamespaces(@org.junit.jupiter.api.io.TempDir java.io.File dir) 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.Jdbc(pool, "com.example.orders") + assertEquals("ada", new Sessions.Db(pool, "com.example.orders") .load("shared-cookie").getAttribute("user")); } finally { pool.close(); @@ -684,11 +684,11 @@ void jdbcSessionNamespaces(@org.junit.jupiter.api.io.TempDir java.io.File dir) @Test @DisplayName("the database session store: no resurrection, and no early expiry for a short timeout") - void jdbcSessionStore(@org.junit.jupiter.api.io.TempDir java.io.File dir) throws Exception { + 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.Jdbc store = new Sessions.Jdbc(pool); + Sessions.Db store = new Sessions.Db(pool); long now = System.currentTimeMillis(); HttpSession created = new HttpSession("sid", now, now, 1800); created.markNew(); @@ -744,8 +744,8 @@ void jdbcSessionStore(@org.junit.jupiter.api.io.TempDir java.io.File dir) throws HttpSession back = store.load("short"); assertFalse(back.isExpired(now), "expired before the touch interval allowed"); assertTrue(back.isExpired(now + 2000)); - assertEquals(2500L, Sessions.Jdbc.touchInterval(10)); - assertEquals(60000L, Sessions.Jdbc.touchInterval(1800)); + assertEquals(2500L, Sessions.Db.touchInterval(10)); + assertEquals(60000L, Sessions.Db.touchInterval(1800)); } finally { pool.close(); } @@ -1650,7 +1650,7 @@ void aLostRotationIsNotAnnounced(@org.junit.jupiter.api.io.TempDir java.io.File 2, 5000, 10000); try { Sessions sessions = new Sessions(); - Sessions.Jdbc store = new Sessions.Jdbc(pool); + Sessions.Db store = new Sessions.Db(pool); sessions.setStore(store); long now = System.currentTimeMillis(); HttpSession created = new HttpSession("twice", now, now, 1800); @@ -2091,7 +2091,7 @@ void aRotatingRequestKeepsItsOldIdBusy(@org.junit.jupiter.api.io.TempDir java.io 2, 5000, 10000); try { Sessions sessions = new Sessions(); - sessions.setStore(new Sessions.Jdbc(pool)); + sessions.setStore(new Sessions.Db(pool)); long now = System.currentTimeMillis(); HttpSession stored = new HttpSession("old", now - 3600000, now - 3600000, 60); stored.markNew(); @@ -2595,7 +2595,7 @@ public void sessionEnded(Object[] beans) { ended.add(beans); } }); - sessions.setStore(new Sessions.Jdbc(pool)); + sessions.setStore(new Sessions.Db(pool)); long now = System.currentTimeMillis(); HttpSession stored = new HttpSession("shared", now, now, 1800); stored.markNew(); @@ -2743,7 +2743,7 @@ void anUnannouncedRotationKeepsTheChanges(@org.junit.jupiter.api.io.TempDir java 2, 5000, 10000); try { Sessions sessions = new Sessions(); - sessions.setStore(new Sessions.Jdbc(pool)); + sessions.setStore(new Sessions.Db(pool)); long now = System.currentTimeMillis(); HttpSession stored = new HttpSession("kept", now, now, 1800); stored.markNew(); @@ -2991,7 +2991,7 @@ void aStaleRotationUsesTheRetiredBeans(@org.junit.jupiter.api.io.TempDir java.io 2, 5000, 10000); try { Sessions sessions = new Sessions(new EmptyApplication()); - sessions.setStore(new Sessions.Jdbc(pool)); + sessions.setStore(new Sessions.Db(pool)); long now = System.currentTimeMillis(); HttpSession stored = new HttpSession("shared", now, now, 1800); stored.markNew(); @@ -3031,7 +3031,7 @@ void noLabelValuesIsThePlainSeries() { @Test @DisplayName("a database session's beans get the same expiry grace as its row") - void jdbcBeansGetTheRowsGrace(@org.junit.jupiter.api.io.TempDir java.io.File dir) + 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); @@ -3042,7 +3042,7 @@ public void sessionEnded(Object[] beans) { ended.add(beans); } }); - sessions.setStore(new Sessions.Jdbc(pool)); + 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. diff --git a/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java index ae548510687..c217ddd5025 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java @@ -128,7 +128,7 @@ void theSessionStoreRoundTrips(String engine) throws Exception { DataSource pool = open(engine); try { pool.execute("DROP TABLE IF EXISTS cn1_http_session", null); - Sessions.Jdbc store = new Sessions.Jdbc(pool); + 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 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/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md index 8467d33a14e..a831c1bf1f0 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/backend.md +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -171,7 +171,7 @@ public String login(HttpServer.Request request, @RequestBody Map body) { ``` A session and its cookie exist only once something calls `getSession(true)`. -`cn1.session.store=jdbc` keeps sessions in the database so any replica can serve +`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. @@ -193,8 +193,8 @@ Custom instruments: `Metrics.counter(...)`, `Metrics.histogram(...)`, - `@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 (always on in dev, otherwise `cn1.management.enabled=true` - plus `cn1.management.token`): `/manage/health`, `/manage/metrics`, +- Management endpoints (on by default in dev, otherwise `cn1.management.enabled=true` + plus `cn1.management.token`; `cn1.management.enabled=false` turns them off in dev too): `/manage/health`, `/manage/metrics`, `/manage/prometheus`, `/manage/jobs`, `/manage/managed`, and `POST /manage/managed/{bean}/{operation}`. 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/selftest/com/demo/SelfTest.java b/vm/backend/demo/selftest/com/demo/SelfTest.java index 853d6431a05..d3c762b3e16 100644 --- a/vm/backend/demo/selftest/com/demo/SelfTest.java +++ b/vm/backend/demo/selftest/com/demo/SelfTest.java @@ -224,6 +224,7 @@ public static void main(String[] args) throws Exception { web(); clientTls(); rotatedCaBundlesAreReRead(); + futures(); System.out.println("passed=" + passed + " failed=" + failures.size()); for(int iter = 0 ; iter < failures.size() ; iter++) { @@ -237,6 +238,88 @@ 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. + 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() { diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 3954b0a797f..a8f31e17453 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -353,7 +353,7 @@ private static synchronized void releaseProcessSlot(Backend stopped) { private static boolean processStarting; /// ONE Backend per process. Scaling out is more processes -- sessions in the - /// JDBC store, scheduler locks in the database -- never more servers in one + /// 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 @@ -1717,8 +1717,8 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { 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 diff --git a/vm/backend/src/com/codename1/backend/HttpSession.java b/vm/backend/src/com/codename1/backend/HttpSession.java index 014d14805e4..c2f89837549 100644 --- a/vm/backend/src/com/codename1/backend/HttpSession.java +++ b/vm/backend/src/com/codename1/backend/HttpSession.java @@ -220,7 +220,7 @@ synchronized void touch(long 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.Jdbc. + /// Allows `millis` past the timeout before expiry; see Sessions.Db. synchronized void setExpiryGrace(long millis) { expiryGraceMillis = millis; } diff --git a/vm/backend/src/com/codename1/backend/SessionStore.java b/vm/backend/src/com/codename1/backend/SessionStore.java index c0cc2e51e6b..c7ec285b193 100644 --- a/vm/backend/src/com/codename1/backend/SessionStore.java +++ b/vm/backend/src/com/codename1/backend/SessionStore.java @@ -27,7 +27,7 @@ /// Where [HttpSession]s are kept between requests. /// /// Two are provided, chosen by `cn1.session.store`: `memory` (the -/// default) and `jdbc`, which keeps them in the server's database so any +/// 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 { diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index a4e3cdbdea8..ba29814690f 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -38,7 +38,7 @@ /// ```java /// cn1.session.cookie=CN1SESSION # the cookie's name /// cn1.session.timeout=1800 # seconds of inactivity, 0 for never -/// cn1.session.store=memory # or jdbc +/// 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) /// ``` @@ -85,7 +85,7 @@ public Sessions() { /// /// - `tls`: whether the server terminates TLS, for `secure=auto` /// - /// - `pool`: the database, for `store=jdbc` + /// - `pool`: the database, for `store=db` /// /// - `application`: destroys the session-scoped beans, or null public static Sessions configure(Config config, boolean tls, DataSource pool, @@ -137,15 +137,15 @@ public static Sessions configure(Config config, boolean tls, DataSource pool, + "set cn1.session.secure=true or serve TLS"); } String kind = config.get("cn1.session.store", "memory"); - if ("jdbc".equalsIgnoreCase(kind)) { + if ("db".equalsIgnoreCase(kind)) { if (pool == null) { - throw new IOException("cn1.session.store=jdbc needs a database, and this " + throw new IOException("cn1.session.store=db needs a database, and this " + "server has none"); } - out.setStore(new Jdbc(pool, config.get("cn1.session.namespace", ""))); + 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 jdbc"); + + "\"; it must be memory or db"); } return out; } @@ -280,7 +280,7 @@ void purgeIfDue(SessionStore s, long now) { // 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 Jdbc ? Jdbc.touchInterval(h.maxInactiveSeconds) : 0; + 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())) { @@ -305,8 +305,8 @@ void purgeIfDue(SessionStore s, long now) { // application's own sees the plain call. if (s instanceof Memory) { ((Memory) s).purgeExpired(now, busy); - } else if (s instanceof Jdbc) { - ((Jdbc) s).purgeExpired(now, busy); + } else if (s instanceof Db) { + ((Db) s).purgeExpired(now, busy); } else { s.purgeExpired(now); } @@ -415,8 +415,8 @@ HttpServer.Response finish(HttpServer.Request request, HttpSession session, cookie = cookie(session.getId(), -1); } session.clean(); - } else if (s instanceof Jdbc) { - ((Jdbc) s).touchIfStale(session); + } else if (s instanceof Db) { + ((Db) s).touchIfStale(session); } keep(session, previous); } @@ -902,7 +902,7 @@ public synchronized int size() { /// 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 Jdbc implements SessionStore { + 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; @@ -911,12 +911,12 @@ public static final class Jdbc implements SessionStore { private boolean ready; /// A store in the empty namespace. - public Jdbc(DataSource pool) { + public Db(DataSource pool) { this(pool, ""); } /// A store whose sessions only servers with the same `namespace` see. - public Jdbc(DataSource pool, String namespace) { + public Db(DataSource pool, String namespace) { this.pool = pool; this.namespace = namespace == null ? "" : namespace; } From d307f8258a94d37782ba6f0a217ec0263b4a0612 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:09:13 +0300 Subject: [PATCH 44/48] Backend: management and MCP linked only on request; settings as annotations - Backend no longer names Management or McpServer. Only the builder's management() and mcp() do, and the generated entry point calls them only when the build asked (@EnableManagement / @EnableMcpServer / an @McpTool / a literal cn1.*.enabled=true in any application*.properties, or a dev build). The translator drops the uncalled builder method and the classes with it; BackendOtelTest now asserts from nm that a server that never asked carries neither, against a control that links both. - Typed settings annotations -- @ServerConfig, @SessionConfig, @DataSourceConfig, @StaticFilesConfig, exporter settings on @OpenTelemetry, paths on the Enable* ones -- compiled in as the bottom configuration layer (Config.withCompiledDefaults), under the files and the environment. Values the runtime would refuse, and two classes disagreeing, are build errors naming the class. - OtlpTracerTest.partialSuccessIsCounted waited on a flush that could run before the request's span was queued (the span ends after the response is written); it now waits for the span first. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/beans/AppSettings.java | 36 +++ .../backend/beans/OpsSettings.java | 34 +++ docs/developer-guide/Backend-MCP.asciidoc | 9 + .../Backend-Observability.asciidoc | 17 ++ .../developer-guide/Backend-Sessions.asciidoc | 5 + docs/developer-guide/Backend.asciidoc | 51 ++++ .../backend/ApplicationRuntimeTest.java | 68 ++++- .../backend/otel/OtlpTracerTest.java | 13 + .../maven/processors/BackendSettings.java | 276 ++++++++++++++++++ .../RestControllerAnnotationProcessor.java | 78 ++++- .../maven/processors/BackendBeansTest.java | 109 ++++++- .../resources/skill/references/backend.md | 21 +- .../demo/oteltest/com/demo/OtelServer.java | 6 + .../src/com/codename1/backend/Backend.java | 116 ++++++-- .../src/com/codename1/backend/Config.java | 51 +++- .../backend/annotations/DataSourceConfig.java | 51 ++++ .../backend/annotations/EnableManagement.java | 55 ++++ .../backend/annotations/EnableMcpServer.java | 47 +++ .../backend/annotations/OpenTelemetry.java | 14 + .../backend/annotations/ServerConfig.java | 50 ++++ .../backend/annotations/SessionConfig.java | 63 ++++ .../annotations/StaticFilesConfig.java | 49 ++++ .../com/codename1/backend/mcp/McpServer.java | 2 +- .../tools/translator/BackendOtelTest.java | 20 +- 24 files changed, 1196 insertions(+), 45 deletions(-) create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AppSettings.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OpsSettings.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSettings.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/DataSourceConfig.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/EnableManagement.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/EnableMcpServer.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/ServerConfig.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/SessionConfig.java create mode 100644 vm/backend/src/com/codename1/backend/annotations/StaticFilesConfig.java 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/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/developer-guide/Backend-MCP.asciidoc b/docs/developer-guide/Backend-MCP.asciidoc index 325ff759ef9..e2d9ca355b1 100644 --- a/docs/developer-guide/Backend-MCP.asciidoc +++ b/docs/developer-guide/Backend-MCP.asciidoc @@ -91,6 +91,15 @@ answered 405. 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 diff --git a/docs/developer-guide/Backend-Observability.asciidoc b/docs/developer-guide/Backend-Observability.asciidoc index c43a6b460a5..6132c89b28c 100644 --- a/docs/developer-guide/Backend-Observability.asciidoc +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -293,6 +293,23 @@ still fail to start. | 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. diff --git a/docs/developer-guide/Backend-Sessions.asciidoc b/docs/developer-guide/Backend-Sessions.asciidoc index d16cac13862..ac45cac4290 100644 --- a/docs/developer-guide/Backend-Sessions.asciidoc +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -132,6 +132,11 @@ browser closes; how long the session lasts is the server's decision. 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. diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index 0d2148d3001..89425761e90 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -364,4 +364,55 @@ 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. +==== Settings in source + +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/beans/AppSettings.java[tag=backend-settings-annotations,indent=0] +---- + +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. + +[cols="2,3", options="header"] +|=== +| Annotation | Keys + +| `@ServerConfig` +| `cn1.server.port`, `workers`, `backlog`, `shutdownTimeoutMillis` + +| `@DataSourceConfig` +| `cn1.datasource.url`, `pool.size`, `pool.borrowTimeoutMillis`, + `busyTimeoutMillis` + +| `@SessionConfig` +| `cn1.session.store`, `timeout`, `cookie`, `same-site`, `secure`, `namespace` + +| `@StaticFilesConfig` +| `cn1.static.root`, `prefix`, `index`, `cacheControl` + +| `@OpenTelemetry` +| turns tracing on and names the service; `cn1.otel.endpoint`, `protocol`, + `sampler`, `sampler.arg` + +| `@EnableManagement` +| builds the management endpoints in; `cn1.management.enabled`, `path` + +| `@EnableMcpServer` +| builds the MCP endpoint in; `cn1.mcp.path`, `allowedOrigins` +|=== + +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. + Handlers that need any of this get it from the entry point the build writes. diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 8141c930bae..a73b6ee61ce 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -1224,7 +1224,7 @@ public Object invoke(int index, Map arguments) { 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() + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().management() .application(new EmptyApplication() { public HttpServer.Handler[] create(Backend.Environment environment) { environment.registerManaged(cache); @@ -1249,6 +1249,68 @@ public HttpServer.Response handle(HttpServer.Request request) { } } + @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"); @@ -2192,7 +2254,9 @@ void aDrainedRunIsMeasured() throws Exception { settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); final CountDownLatch running = new CountDownLatch(1); final Scheduler[] scheduler = new Scheduler[1]; - Backend backend = Backend.builder(Config.of(settings, "dev")).quiet() + // 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); 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/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/RestControllerAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java index 5ba7e7deb82..923c8810c62 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 @@ -212,6 +212,10 @@ public void setDevTools(boolean devTools) { 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. */ @@ -1273,6 +1277,7 @@ public void finish(ProcessorContext ctx) throws ProcessingException { return; } resolveTelemetry(ctx); + settings = BackendSettings.resolve(ctx); if (ctx.hasErrors()) { return; } @@ -2546,8 +2551,24 @@ String generateBootstrap(String packageName) { // only for the pool. sb.append(" .requiresDataSource()\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) { + 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 @@ -2665,17 +2686,49 @@ private static boolean propertyEnablesTelemetry(ProcessorContext ctx) { || "on".equalsIgnoreCase(v) || "1".equals(v); } - /// 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) { + /// 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 false; + return files; } - File[] siblings = base.getParentFile() == null ? null : base.getParentFile().listFiles(); - List files = new ArrayList(); files.add(base); + File[] siblings = base.getParentFile() == null ? null : base.getParentFile().listFiles(); if (siblings != null) { for (File f : siblings) { String n = f.getName(); @@ -2684,7 +2737,14 @@ static boolean applicationPropertyKnown(ProcessorContext ctx, String key) { } } } - for (File f : files) { + 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 { 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 index 0d79286c30e..7c6fcea8f9f 100644 --- 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 @@ -186,7 +186,7 @@ public void theGeneratedWiringRunsTheSample() throws Exception { Backend backend = Backend.builder(Config.of(settings, "dev")) .quiet() .requiresDataSource() - .mcp(new com.codename1.backend.mcp.DevTools()) + .mcp(new com.codename1.backend.mcp.DevTools()).management() .application(app) .start(); try { @@ -285,6 +285,113 @@ public void theEntryPointNamesTheWiring() throws Exception { wiring.contains(".cn1$inject$greeter(")); } + @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(); diff --git a/scripts/initializr/common/src/main/resources/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md index a831c1bf1f0..42a2a21e0bd 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/backend.md +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -32,6 +32,12 @@ mvn -pl backend -Dcodename1.platform=backend cn1:backend-package 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.*`. @@ -193,10 +199,13 @@ Custom instruments: `Metrics.counter(...)`, `Metrics.histogram(...)`, - `@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 (on by default in dev, otherwise `cn1.management.enabled=true` - plus `cn1.management.token`; `cn1.management.enabled=false` turns them off in dev too): `/manage/health`, `/manage/metrics`, - `/manage/prometheus`, `/manage/jobs`, `/manage/managed`, and - `POST /manage/managed/{bean}/{operation}`. +- 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 @@ -236,7 +245,9 @@ start without it) and a browser `Origin` other than localhost is refused. On a d 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`. +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 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/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index a8f31e17453..0bbffd3b6fb 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -1208,6 +1208,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(); @@ -1236,8 +1260,9 @@ public static final class Builder { private Tracer tracer; private Application application; private com.codename1.backend.metrics.MetricReader metricReader; - private boolean mcp; - private com.codename1.backend.mcp.McpServer.Extension mcpDevTools; + private OwnRoute managementRoute; + private OwnRoute mcpRoute; + private String[] compiledSettings; private String serviceName; Builder(Config config) { @@ -1433,9 +1458,63 @@ public Builder metrics(com.codename1.backend.metrics.MetricReader reader) { /// 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]. - public Builder mcp(com.codename1.backend.mcp.McpServer.Extension devTools) { - this.mcp = true; - this.mcpDevTools = devTools; + /// + /// 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() { + 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. + void attach(HttpServer.Handler opened, Backend running) { + ((com.codename1.backend.mcp.McpServer) opened).attach(running); + } + + String unguardedBy(HttpServer.Handler opened) { + return ((com.codename1.backend.mcp.McpServer) opened).hasToken() ? null + : com.codename1.backend.mcp.McpServer.TOKEN; + } + + 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() { + HttpServer.Handler open(Config config, String name, List tools) + throws IOException { + return Management.fromConfig(config); + } + + 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; } @@ -1489,6 +1568,7 @@ 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. @@ -1618,7 +1698,8 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { 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. - Management management = Management.fromConfig(config); + 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 @@ -1656,9 +1737,8 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { // After the application: its @McpTool methods are registered while its // beans are built, and whether the endpoint has anything to serve // depends on them. - com.codename1.backend.mcp.McpServer mcpServer = mcp - ? com.codename1.backend.mcp.McpServer.fromConfig(config, mcpDevTools, - serviceName, tools) : null; + HttpServer.Handler mcpServer = mcpRoute == null ? null + : mcpRoute.open(config, serviceName, tools); if (mcpServer != null) { routers.add(0, mcpServer); } @@ -1668,20 +1748,19 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { // interface, so it binds loopback instead; an address chosen // explicitly that is not loopback needs the token. String bindHost = host; - if (mcpServer != null && !mcpServer.hasToken()) { + String unguardedBy = mcpServer == null ? null : mcpRoute.unguardedBy(mcpServer); + if (unguardedBy != null) { if (bindHost == null || bindHost.length() == 0) { bindHost = "127.0.0.1"; if (!quiet) { System.out.println("cn1: the MCP endpoint has no token, so the server " - + "listens on 127.0.0.1 only; set " - + com.codename1.backend.mcp.McpServer.TOKEN + + "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 " - + com.codename1.backend.mcp.McpServer.TOKEN + + "could run its tools; set " + unguardedBy + ", or bind to a loopback address"); } } @@ -1912,16 +1991,15 @@ public void register(HttpServer.WebSocketRegistry direct) boolean running = false; try { if (management != null) { - management.attach(backend); + managementRoute.attach(management, backend); } if (mcpServer != null) { - mcpServer.attach(backend); + mcpRoute.attach(mcpServer, backend); if (!quiet) { // The line an agent's setup instructions point at. - System.out.println("cn1: MCP endpoint at http" + System.out.println(mcpRoute.announce(mcpServer, "http" + (context != null ? "s" : "") + "://" + advertised(bindHost) - + ":" + server.getPort() + mcpServer.getPath() - + (mcpServer.hasDevTools() ? " (with development tools)" : "")); + + ":" + server.getPort())); } } if (application != null) { diff --git a/vm/backend/src/com/codename1/backend/Config.java b/vm/backend/src/com/codename1/backend/Config.java index de6147f7072..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 @@ -162,16 +167,41 @@ public final class Config { 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 { @@ -358,12 +388,17 @@ public String describe() { return out.toString(); } - /// Every key the properties files 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. + /// 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 = baseFile.propertyNames(); + java.util.Enumeration e = compiled.propertyNames(); + while (e.hasMoreElements()) { + names.add(e.nextElement()); + } + e = baseFile.propertyNames(); while (e.hasMoreElements()) { names.add(e.nextElement()); } @@ -392,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/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/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/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/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/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/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/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index add5f20afeb..df9567c280e 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -109,7 +109,7 @@ private McpServer(String path, String token, String[] allowedOrigins, String ser /// /// #### Parameters /// - /// - `devTools`: @param devTools the development tools, which the build passes only in a + /// - `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` /// 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++; } From 0e6549b4b8e0d12f4fe571bfc3eef6b58a81d2f2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:31:45 +0300 Subject: [PATCH 45/48] Backend: controllers return and accept the application's own classes as JSON A route may now return an entity or DTO (and collections or String-keyed maps of them) and take one as @RequestBody, as a Spring controller does through Jackson. The build writes a Cn1Json codec per class -- plain code, no reflection -- in the app's @Mapped JSON form, so a class shared by app and backend round-trips: fields (public directly, others via bean accessors), @JsonProperty/@JsonIgnore (now shared with the backend), Date as epoch millis (read from millis or ISO-8601), byte[] base64, enums by name, subclasses written as themselves, unknown members ignored. - A body the codec refuses is a 400 naming the path ($.lines[0].quantity: expected ...); a response nesting past 64 objects (a cycle) is a 500 pointing at @JsonIgnore. - Generic type-variable fields, body classes without a no-arg constructor, interfaces, arrays other than byte[], and runtime types such as HttpServer.Response inside a list stay build errors, with the reason. - HttpServer renders a deferred JSON body before the write, into the same buffer, so a throw there (a Writable, a codec) is answered 500 and recorded as the handler's failure instead of dropping the connection unanswered. - The guide's order example is compiled from the guide's own files and run end to end by BackendBeansTest. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/codename1/annotations/JsonIgnore.java | 5 + .../codename1/annotations/JsonProperty.java | 4 + .../developerguide/backend/orders/Order.java | 39 + .../backend/orders/OrderLine.java | 31 + .../backend/orders/OrdersApi.java | 59 + docs/developer-guide/Backend-Web.asciidoc | 74 +- .../maven/processors/BackendJsonCodecs.java | 1108 +++++++++++++++++ .../RestControllerAnnotationProcessor.java | 133 +- .../maven/processors/BackendBeansTest.java | 167 +++ ...RestControllerAnnotationProcessorTest.java | 51 +- .../resources/skill/references/backend.md | 18 + .../src/com/codename1/backend/HttpServer.java | 38 +- .../src/com/codename1/backend/JsonCodec.java | 358 ++++++ 13 files changed, 2034 insertions(+), 51 deletions(-) create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/Order.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/OrderLine.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/OrdersApi.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java create mode 100644 vm/backend/src/com/codename1/backend/JsonCodec.java 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/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-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index 77d22a888f4..2acc0045ca8 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -60,7 +60,7 @@ one annotation: | `@RequestBody` | the body -| `String`, a `Map` or a `List` +| `String`, a `Map` or a `List`, or a class of your own and collections of it | none, typed `HttpServer.Request` | the request itself @@ -83,11 +83,10 @@ would hand the method a value nobody sent: `required = false` on a primitive wit 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, which produces maps, lists, strings, numbers and -booleans, so that's what a body binds to. A `List` would arrive as a list -of maps and fail the first time an element is used as an `Order`, so the build -refuses it. For typed bodies, declare the API as a contract, as described under -<>, which generates the codecs on both sides. +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 @@ -105,7 +104,8 @@ couldn't explain. | `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 implementing `Json.Writable` +| 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` @@ -116,10 +116,62 @@ couldn't explain. | 404. |=== -Any other return type is a build error. A class the router doesn't know how to -encode 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. +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] +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/orders/OrderLine.java[tag=backend-dto-json,indent=0] +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 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 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..e71a06e046a --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java @@ -0,0 +1,1108 @@ +/* + * 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; + } + } + + private final ProcessorContext ctx; + private final Map dtos = new LinkedHashMap(); + private int counter; + + 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); + 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) { + if (cls == null) { + return false; + } + for (String itf : cls.getInterfaceInternalNames()) { + if (WRITABLE.equals(itf)) { + return true; + } + if (seen.add(itf) && implementsWritable( + RestControllerAnnotationProcessor.resolveClass(ctx, itf), seen)) { + return true; + } + } + String parent = cls.getSuperInternalName(); + if (parent == null || parent.startsWith("java/") || !seen.add(parent)) { + return false; + } + return implementsWritable(RestControllerAnnotationProcessor.resolveClass(ctx, parent), + seen); + } + + 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: + case ANY: + case RAW_MAP: + case RAW_LIST: + sb.append(ind).append(JSON).append(".writeValue(").append(expr) + .append(", out);\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. + 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)); + } + return out; + } + + /// The binary names of the codecs, for the collision check. + List codecBinaries() { + List out = new ArrayList(); + for (Dto dto : dtos.values()) { + if (dto.problem == null && (dto.write || dto.read)) { + out.add(codecBinary(dto.binary)); + } + } + return out; + } + + 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/RestControllerAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java index 923c8810c62..636c036e140 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 @@ -248,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 { @@ -262,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 @@ -845,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 @@ -925,12 +971,18 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St return null; } 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."); - return null; + // 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 @@ -1323,6 +1375,16 @@ public void finish(ProcessorContext ctx) throws ProcessingException { + "would replace it. Rename that class."); return; } + if (codecs != null) { + for (String codec : codecs.codecBinaries()) { + 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(codecs.sources()); + } daos = hasGeneratedDaos(ctx); sources.put(wiring, generateWiring(entryPackage)); sources.put(bootstrap, generateBootstrap(entryPackage)); @@ -1521,6 +1583,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. @@ -1931,6 +2006,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"; @@ -2142,6 +2241,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"); @@ -2468,6 +2580,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"); 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 index 7c6fcea8f9f..710c2e1322a 100644 --- 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 @@ -285,6 +285,173 @@ public void theEntryPointNamesTheWiring() throws Exception { 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.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" + + "}\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")); + } 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"); + 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[]")); + } + @Test public void aPackagedServerLinksManagementAndMcpOnlyWhenAsked() throws Exception { Map plain = new LinkedHashMap(); 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 72a66297332..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 @@ -1054,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" @@ -1069,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 @@ -1109,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 @@ -1139,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 @@ -1209,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 @@ -1252,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" @@ -1267,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 @@ -1494,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" @@ -1514,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/scripts/initializr/common/src/main/resources/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md index 42a2a21e0bd..1c1dcfcb1e0 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/backend.md +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -101,6 +101,24 @@ 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 diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 42a171d9391..4cb0a7aace0 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -5296,6 +5296,23 @@ private void serveOneRelease(int fd) { 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 { @@ -5677,6 +5694,18 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) 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(); @@ -7591,15 +7620,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(); } 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..3841578dc72 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/JsonCodec.java @@ -0,0 +1,358 @@ +/* + * 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(); + if (d == Math.floor(d) && d >= min && d <= max) { + return (long) d; + } + } + 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; + } +} From f8bb21960d3898f9dbebc8d526b9d5ca3d0a0a14 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:28:12 +0300 Subject: [PATCH 46/48] Backend: outbound I/O parks a virtual thread instead of blocking its host A virtual thread used to park only on the socket it served. An outbound read -- a PostgreSQL or MySQL query, a Web call, a TLS handshake -- did a blocking recv() on the host pthread, and with one host per core that many slow calls stopped the server answering anything. - Native: a per-virtual-thread wait record (descriptors, events, timeout) and cn1BackendVtWait/cn1BackendAwaitFd; resume answers WAITING (3) for it. Outbound Tcp descriptors stay non-blocking for life and every wait (connect, read, write, TLS handshake/read/write) parks on a virtual thread and polls anywhere else, so platform threads behave as before. SO_RCVTIMEO/SO_SNDTIMEO become the wait's deadline. Web drives libcurl through the multi interface on a virtual thread. - Host: WAITING registers the descriptors with the host's own poller for exactly the wait, wakes on readiness or deadline, and the stop drain keeps pumping tasks that wait on outbound I/O. - Docs/javadoc/skill: network databases, Web and TLS are fine on VIRTUAL; SQLite, file access, host-name resolution and Object.wait still block. - Tests: SelfTest (Tcp, Web, TLS handshake, TLS read, read deadline) and DbCheck (pg_sleep / SLEEP against a one-host server) prove another request is served while the call waits; both fail with the old blocking wait. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Backend-Operations.asciidoc | 15 +- .../Backend-Scheduling.asciidoc | 18 +- docs/developer-guide/Backend.asciidoc | 12 +- .../maven/processors/BackendBeans.java | 14 +- .../resources/skill/references/backend.md | 9 +- vm/backend/demo/dbcheck/com/demo/DbCheck.java | 96 +++++++ .../demo/selftest/com/demo/SelfTest.java | 230 +++++++++++++++ .../com/codename1/backend/VirtualThread.java | 18 ++ .../com/codename1/backend/VirtualThread.java | 32 ++- vm/backend/native/cn1_backend_net.c | 160 +++++++++-- vm/backend/native/cn1_backend_server.c | 196 ++++++++++++- vm/backend/native/cn1_backend_tls.c | 35 +-- vm/backend/native/cn1_backend_tlsclient.c | 80 +++++- vm/backend/native/cn1_backend_web.c | 177 +++++++++++- .../src/com/codename1/backend/HttpServer.java | 264 +++++++++++++++++- .../com/codename1/backend/TaskExecutor.java | 2 +- .../src/com/codename1/backend/Tasks.java | 6 +- .../backend/annotations/ThreadKind.java | 10 +- .../translator/BackendRuntimeSelfTest.java | 33 +++ 19 files changed, 1326 insertions(+), 81 deletions(-) diff --git a/docs/developer-guide/Backend-Operations.asciidoc b/docs/developer-guide/Backend-Operations.asciidoc index cdbdc377d50..c3c5256215a 100644 --- a/docs/developer-guide/Backend-Operations.asciidoc +++ b/docs/developer-guide/Backend-Operations.asciidoc @@ -151,14 +151,13 @@ no TLS, and what it does need is exactly what a translated binary is good at. 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`. +* 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 diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc index 9f764c06b9b..c98d5c78545 100644 --- a/docs/developer-guide/Backend-Scheduling.asciidoc +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -122,12 +122,13 @@ queue, and `cn1.task.queue_depth` reports how many are waiting, by executor. 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 only on the -socket it serves. An outbound read -- a database query, a call to another service --- blocks the host thread running it, and with it every connection that host -serves. Work that talks to a database or makes outbound calls belongs on -`PLATFORM`, which is why it's the default. `VIRTUAL` suits work that's cheap to -start and mostly computes, or that there is a great deal of. +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: @@ -280,8 +281,9 @@ completes. === Pitfalls -* **Database work on a virtual thread** blocks the host thread under it. Leave such - work on `PLATFORM`. +* **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 diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index 89425761e90..ff28fc319f3 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -197,10 +197,14 @@ These are ParparVM's own virtual threads, a feature of the translated runtime, a 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 -- a descriptor a host polls -- while anything -else that blocks, `Object.wait()` included, blocks the host thread under it and -every virtual thread that host runs. They're also pinned: a virtual thread stays -on one host for its whole life. +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 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 index cc624f25df6..1543a72d73b 100644 --- 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 @@ -2292,9 +2292,17 @@ private void warnInheritedOutsideClassAspect(AnnotatedClass cls, String what) { /// 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 database method - /// could end up on the virtual hosts. AUTO agrees with either; unnamed - /// executors are already named by their 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()) { diff --git a/scripts/initializr/common/src/main/resources/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md index 1c1dcfcb1e0..0af0fd2a365 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/backend.md +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -173,10 +173,11 @@ public class Reports { 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. **Rule of thumb: anything that talks to a database or makes an - outbound HTTP call belongs on `PLATFORM`** — on this runtime those calls block the - host thread under a virtual thread. `Future.get()` on a virtual thread yields its - host instead of blocking it. + 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)`, 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/selftest/com/demo/SelfTest.java b/vm/backend/demo/selftest/com/demo/SelfTest.java index d3c762b3e16..a46b8ea88f6 100644 --- a/vm/backend/demo/selftest/com/demo/SelfTest.java +++ b/vm/backend/demo/selftest/com/demo/SelfTest.java @@ -4354,6 +4354,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() { @@ -5840,6 +6069,7 @@ private static void json() throws Exception { anOutboundFragmentIsRefused(); caseVariantHeadersSignAsOneField(); aLateBodyIsNotServedAnotherConnectionsBytes(); + anOutboundWaitLeavesTheHostFree(); aFileBackedResponseClosesItsDescriptorWhenTheHeadFails(); anEncodedMountPrefixIsTheSameMount(); aMountDeclaredWithAnEscapeIsStillReachable(); diff --git a/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java b/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java index 1abe5e11c28..0cb59169c77 100644 --- a/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java +++ b/vm/backend/impl/javase/com/codename1/backend/VirtualThread.java @@ -46,11 +46,29 @@ public static long createTask(long token, int stackBytes) { 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) { } diff --git a/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java b/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java index cdb8e305e8f..e27b6f72a3c 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java +++ b/vm/backend/impl/parparvm/com/codename1/backend/VirtualThread.java @@ -73,12 +73,38 @@ public static long createTask(long token, 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); @@ -122,6 +148,10 @@ 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(); 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 d41381ea86c..0227823df11 100644 --- a/vm/backend/native/cn1_backend_server.c +++ b/vm/backend/native/cn1_backend_server.c @@ -1209,11 +1209,31 @@ 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 @@ -1307,6 +1327,9 @@ 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); @@ -1334,6 +1357,7 @@ JAVA_LONG com_codename1_backend_VirtualThread_createTaskImpl___long_int_R_long(C if(a == 0) { return 0; } + memset(a, 0, sizeof(struct cn1BackendVtArg)); a->fd = -1; a->token = token; vt = cn1SpawnVirtualThread(cn1BackendVtTaskBody, a, (size_t)stackBytes); @@ -1367,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) { 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/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 4cb0a7aace0..e18dd065b9c 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -2747,9 +2747,140 @@ private void advanceTask(VtHost me, long 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. @@ -2891,6 +3022,88 @@ boolean ringEmpty() { /// 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(); @@ -3187,7 +3400,13 @@ private void drainTasksAfterStop(VtHost me) { } } if (tasks == 0 && !me.hasQueuedTasks()) { - return; + // 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: @@ -3221,6 +3440,8 @@ private void drainTasksAfterStop(VtHost me) { 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"); @@ -3248,7 +3469,11 @@ private void finishOverrunningTasks(VtHost me) { // A napping task is a running one: back on the ring each pass. wakeNappers(me, Long.MAX_VALUE); if (me.ringEmpty()) { - return; + if (me.taskWaiters() == 0) { + return; + } + pumpTaskWaiters(me, 50); + continue; } int budget = me.ringCount; int tasks = 0; @@ -3262,7 +3487,10 @@ private void finishOverrunningTasks(VtHost me) { } } if (tasks == 0) { - return; + if (me.taskWaiters() == 0) { + return; + } + pumpTaskWaiters(me, 50); } } } @@ -3330,6 +3558,9 @@ private void runVirtualThreadHostLoop(VtHost me, int index) { // 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; @@ -3378,6 +3609,19 @@ private void runVirtualThreadHostLoop(VtHost me, 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)); } } @@ -3468,6 +3712,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 diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index 85ddb474113..fc5f2d13b2f 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -89,7 +89,7 @@ static boolean runningOnThisThread() { /// 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 database I/O it asked a platform thread for. + /// -- 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) { diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index 398472bb9d8..f2c0038e4e4 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -51,9 +51,9 @@ /// ## Which thread /// /// A virtual thread is the cheaper one, and the right one for work that waits -/// on sockets. It is the wrong one for work that talks to a database: on this -/// runtime a database read blocks the HOST thread under the virtual thread, and -/// every other virtual thread on that host with it. See +/// 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; diff --git a/vm/backend/src/com/codename1/backend/annotations/ThreadKind.java b/vm/backend/src/com/codename1/backend/annotations/ThreadKind.java index e765cfc76e1..cf76af0c9ba 100644 --- a/vm/backend/src/com/codename1/backend/annotations/ThreadKind.java +++ b/vm/backend/src/com/codename1/backend/annotations/ThreadKind.java @@ -26,10 +26,12 @@ /// [Scheduled] job. /// /// A virtual thread is cheap to create and to switch, and many can wait on -/// sockets at once. But on this runtime only the SERVED socket parks a virtual -/// thread: a database query or an outbound HTTP call blocks the host thread -/// under it, and with it every other virtual thread that host is running. Work -/// that talks to a database belongs on a platform thread. +/// 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. 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="); From 903a18fb098da1888a0ed040c8defce67fe0a281 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:41:44 +0300 Subject: [PATCH 47/48] Backend: clear the PMD report; native checks for the JSON codec runtime - The seven AvoidUsingHardCodedIP findings are loopback and wildcard literals that recognise or bind the local interface and dial nothing; each carries a //NOPMD with that reason, the convention ServerSocket and Credentials already use. The OwnRoute overrides get @Override. - SelfTest exercises JsonCodec on the translated runtime: range and fraction refusals with their paths, dates from millis and ISO-8601 with offsets and fractions, an impossible date refused, base64, writeDate. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../demo/selftest/com/demo/SelfTest.java | 66 +++++++++++++++++++ .../src/com/codename1/backend/Backend.java | 20 ++++-- .../com/codename1/backend/mcp/McpServer.java | 3 +- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/vm/backend/demo/selftest/com/demo/SelfTest.java b/vm/backend/demo/selftest/com/demo/SelfTest.java index a46b8ea88f6..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; @@ -225,6 +226,7 @@ public static void main(String[] args) throws Exception { clientTls(); rotatedCaBundlesAreReRead(); futures(); + jsonCodec(); System.out.println("passed=" + passed + " failed=" + failures.size()); for(int iter = 0 ; iter < failures.size() ; iter++) { @@ -243,6 +245,70 @@ public static void main(String[] args) throws Exception { /// 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())); diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 0bbffd3b6fb..6289b500359 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -158,7 +158,7 @@ public static Builder builder(Config config) { return new Builder(config); } - private String listenAddress = "127.0.0.1"; + 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 @@ -1070,8 +1070,9 @@ public HttpServer.Response handle(HttpServer.Request request) /// 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) || "::".equals(h) || "[::]".equals(h)) { - return "127.0.0.1"; + 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; } @@ -1084,8 +1085,9 @@ static String advertised(String host) { /// 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) || "[::1]".equals(h) - || "0:0:0:0:0:0:0:1".equals(h)) { + 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. @@ -1465,6 +1467,7 @@ public Builder metrics(com.codename1.backend.metrics.MetricReader reader) { /// 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, @@ -1473,15 +1476,18 @@ HttpServer.Handler open(Config config, String name, List 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; @@ -1498,11 +1504,13 @@ String announce(HttpServer.Handler opened, String base) { /// 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); } @@ -1751,7 +1759,7 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { String unguardedBy = mcpServer == null ? null : mcpRoute.unguardedBy(mcpServer); if (unguardedBy != null) { if (bindHost == null || bindHost.length() == 0) { - bindHost = "127.0.0.1"; + 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 diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index df9567c280e..fbd07e68336 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -439,7 +439,8 @@ private boolean originAllowed(HttpServer.Request request) { // 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) + return "localhost".equalsIgnoreCase(host) + || "127.0.0.1".equals(host) //NOPMD AvoidUsingHardCodedIP - recognises a loopback origin || "[::1]".equals(host); } From 8a415127f0a13383221c52184c7a53c1126a9564 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:52:26 +0300 Subject: [PATCH 48/48] Backend review: codec value dispatch, TreeSet, long bounds, MCP CORS and paths - An Object or raw Map/List field is written by its run-time class through a generated cn1app.JsonValues: a class the build writes goes through its codec, Date as millis, enums by name, JDK shapes through Json; any other class is a 500 instead of its toString(). - A TreeSet body needs a Comparable element; refused at build time otherwise. - readLong bounds a fractional-free double by 2^63 before converting, so 9223372036854775808.0 is refused instead of clamped to Long.MAX_VALUE. - MCP and management match the canonical path (pathFrom), as the routers, static files and relay do: /%6dcp is /mcp. - MCP answers an allowed origin's CORS preflight before authentication and names the origin on every reply to it (new Response.header). - The guide's order example uses one source block per include, which the snippet validator requires. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/developer-guide/Backend-MCP.asciidoc | 3 +- docs/developer-guide/Backend-Web.asciidoc | 12 ++ .../backend/ApplicationRuntimeTest.java | 73 +++++++++ .../com/codename1/backend/JsonCodecTest.java | 83 ++++++++++ .../maven/processors/BackendJsonCodecs.java | 152 ++++++++++++++++-- .../RestControllerAnnotationProcessor.java | 5 +- .../maven/processors/BackendBeansTest.java | 36 +++++ .../src/com/codename1/backend/HttpServer.java | 14 ++ .../src/com/codename1/backend/JsonCodec.java | 11 +- .../src/com/codename1/backend/Management.java | 8 +- .../com/codename1/backend/mcp/McpServer.java | 36 ++++- 11 files changed, 403 insertions(+), 30 deletions(-) create mode 100644 maven/backend/src/test/java/com/codename1/backend/JsonCodecTest.java diff --git a/docs/developer-guide/Backend-MCP.asciidoc b/docs/developer-guide/Backend-MCP.asciidoc index e2d9ca355b1..f2f2efd0b52 100644 --- a/docs/developer-guide/Backend-MCP.asciidoc +++ b/docs/developer-guide/Backend-MCP.asciidoc @@ -29,7 +29,8 @@ 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. +server serves itself has to be listed too. An allowed origin gets the CORS +answers a browser needs, the preflight included. ==== Writing a tool diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index 2acc0045ca8..ed80a3b32a9 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -131,7 +131,15 @@ 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] ---- @@ -155,6 +163,10 @@ app and the server reads the same on both sides: 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. diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index a73b6ee61ce..e8f3406a3b0 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -3227,6 +3227,79 @@ private static String read(HttpURLConnection c) throws IOException { 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 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/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 index e71a06e046a..9e7b9b9dc9d 100644 --- 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 @@ -119,9 +119,15 @@ static final class Dto { } } + /// 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; @@ -159,6 +165,16 @@ private String check(String type, boolean read, Set visiting) { 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: { @@ -363,15 +379,19 @@ private List subclassesOf(String binary) { } 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 (WRITABLE.equals(itf)) { + if (itfName.equals(itf)) { return true; } - if (seen.add(itf) && implementsWritable( - RestControllerAnnotationProcessor.resolveClass(ctx, itf), seen)) { + if (seen.add(itf) && implementsInterface( + RestControllerAnnotationProcessor.resolveClass(ctx, itf), itfName, seen)) { return true; } } @@ -379,8 +399,22 @@ private boolean implementsWritable(AnnotatedClass cls, Set seen) { if (parent == null || parent.startsWith("java/") || !seen.add(parent)) { return false; } - return implementsWritable(RestControllerAnnotationProcessor.resolveClass(ctx, parent), - seen); + 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) { @@ -535,11 +569,18 @@ private void write(StringBuilder sb, String type, String expr, String depth, Str 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: - sb.append(ind).append(JSON).append(".writeValue(").append(expr) - .append(", out);\n"); + // 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) @@ -859,7 +900,8 @@ private String cast(String type, String var) { // Codec classes // ------------------------------------------------------------------ - /// The codec classes the checks above asked for, by binary name. + /// 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()) { @@ -868,18 +910,100 @@ Map sources() { } out.put(codecBinary(dto.binary), codecSource(dto)); } + if (valuesUsed) { + out.put(VALUES, valuesSource()); + } return out; } - /// The binary names of the codecs, for the collision check. - List codecBinaries() { - List out = new ArrayList(); + /// `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 || dto.read)) { - out.add(codecBinary(dto.binary)); + 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"); } - return out; + 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) { 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 636c036e140..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 @@ -1376,14 +1376,15 @@ public void finish(ProcessorContext ctx) throws ProcessingException { return; } if (codecs != null) { - for (String codec : codecs.codecBinaries()) { + 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(codecs.sources()); + sources.putAll(codecSources); } daos = hasGeneratedDaos(ctx); sources.put(wiring, generateWiring(entryPackage)); 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 index 710c2e1322a..66d41064730 100644 --- 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 @@ -315,6 +315,13 @@ private static Map dtoSample() { 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" @@ -339,6 +346,15 @@ private static Map dtoSample() { + " @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; } @@ -383,6 +399,12 @@ public void aControllerReturnsAndAcceptsItsOwnClassesAsJson() throws Exception { 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(); } @@ -445,11 +467,25 @@ public void aClassNoCodecCanBeWrittenForIsABuildError() throws Exception { + "@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 diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index e18dd065b9c..678e0116e97 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -1090,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"); diff --git a/vm/backend/src/com/codename1/backend/JsonCodec.java b/vm/backend/src/com/codename1/backend/JsonCodec.java index 3841578dc72..fc082e5e36c 100644 --- a/vm/backend/src/com/codename1/backend/JsonCodec.java +++ b/vm/backend/src/com/codename1/backend/JsonCodec.java @@ -160,8 +160,15 @@ public static long readLong(Object json, Path at, String name, int index, } } else if (json instanceof Double) { double d = ((Double) json).doubleValue(); - if (d == Math.floor(d) && d >= min && d <= max) { - return (long) d; + // 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); diff --git a/vm/backend/src/com/codename1/backend/Management.java b/vm/backend/src/com/codename1/backend/Management.java index e8d4db2c65a..a9d3f0690d3 100644 --- a/vm/backend/src/com/codename1/backend/Management.java +++ b/vm/backend/src/com/codename1/backend/Management.java @@ -196,12 +196,12 @@ public static Object invoke(List all, String objectName, String operation, @Override public HttpServer.Response handle(HttpServer.Request request) throws Exception { - String target = request.getTarget(); - if (target == null || !target.startsWith(path)) { + // 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; } - int query = target.indexOf('?'); - String rest = (query < 0 ? target : target.substring(0, query)).substring(path.length()); + String rest = canonical.substring(path.length()); if (rest.length() > 0 && rest.charAt(0) != '/') { return null; } diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/backend/mcp/McpServer.java index fbd07e68336..4feeff8873d 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/backend/mcp/McpServer.java @@ -191,15 +191,37 @@ public void attach(Backend running) { @Override public HttpServer.Response handle(HttpServer.Request request) throws Exception { - String target = request.getTarget(); - if (target == null) { - return null; - } - int query = target.indexOf('?'); - String p = query < 0 ? target : target.substring(0, query); - if (!p.equals(path)) { + // 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));