From 31cef8fadfa3829dce6c994c91afbaf67c86eda7 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:33:19 -0400 Subject: [PATCH 1/2] refactor(jvm)!: move session contracts into the engine-neutral runtime SessionProvider, ConfiguredSessionProvider, SessionCreation, SessionMethodBinding, the component bindings and the session, method and component registries now live in com.chunkzero.chunk.runtime, so generated code and the Gradle plugin target no engine types. Hosts mark the types they hand to session components with @Component.Supplied; multistom supplies SessionScope and requires its sessions to be multistom Sessions. --- .../src/codegen/java/configurations.rs | 2 +- crates/chunk-build/src/codegen/tests.rs | 4 +- .../src/release/session_configurations.rs | 2 +- .../src/release/session_methods.rs | 2 +- crates/chunk-build/src/release/tests.rs | 4 +- crates/chunk-cli/templates/java/Lobby.java | 4 +- crates/chunk-cli/templates/kotlin/Lobby.kt | 4 +- examples/arena/README.md | 4 +- .../src/main/java/example/arena/Arena.java | 4 +- .../src/main/java/example/lobby/Lobby.java | 4 +- .../com/chunkzero/chunk/example/load/Lobby.kt | 4 +- .../chunk/example/arena/ArenaSessions.kt | 5 +- .../chunk/example/lobby/LobbySessions.kt | 5 +- .../chunk/multistom/CoinCommandTest.java | 10 +- jvm/gradle-plugin/README.md | 30 +++-- .../com/chunkzero/chunk/gradle/ClassLookup.kt | 122 +++++++++++++++++ .../chunkzero/chunk/gradle/ComponentIndex.kt | 18 ++- .../chunk/gradle/SessionConfigurationIndex.kt | 2 +- .../chunk/gradle/SessionMethodIndex.kt | 19 ++- .../chunk/gradle/WriteComponentBindings.kt | 26 +--- .../chunk/gradle/WriteSessionRegistry.kt | 126 +----------------- .../chunkzero/chunk/gradle/ChunkPluginTest.kt | 97 ++++++-------- .../chunk/gradle/ComponentIndexTest.kt | 37 ++++- jvm/minestom/README.md | 3 + jvm/multistom/README.md | 11 +- jvm/multistom/build.gradle.kts | 2 +- .../chunk/multistom/ChunkMinestom.java | 19 ++- .../chunk/multistom/SessionManager.java | 69 +++++----- .../chunk/multistom/SessionMethodBinding.java | 33 ----- .../chunk/multistom/SessionProvider.java | 7 - .../chunk/multistom/SessionRegistration.java | 68 ---------- .../chunk/multistom/SessionScope.java | 17 ++- .../chunk/multistom/internal/AppRegistry.java | 40 ------ .../internal/SessionMethodRegistry.java | 39 ------ .../multistom/ComponentRegistryTest.java | 3 +- .../chunk/multistom/SessionCreationTest.java | 87 ++++++------ .../multistom/SessionMethodBindingTest.java | 37 ----- .../chunk/multistom/SessionMethodTest.java | 12 +- .../multistom/SessionMethodDisconnectTest.kt | 10 +- jvm/runtime/README.md | 14 +- .../chunkzero/chunk/runtime/Component.java | 8 ++ .../chunk/runtime}/ComponentBinding.java | 4 +- .../chunk/runtime}/ComponentProvider.java | 2 +- .../chunk/runtime}/ComponentRegistry.java | 60 +++++---- .../runtime}/ConfiguredSessionProvider.java | 8 +- .../chunk/runtime}/SessionCreation.java | 2 +- .../chunk/runtime/SessionMethodBinding.java | 44 ++++++ .../chunk/runtime}/SessionMethodProvider.java | 6 +- .../chunk/runtime/SessionMethodRegistry.java | 64 +++++++++ .../chunk/runtime/SessionProvider.java | 10 ++ .../chunk/runtime/SessionRegistry.java | 108 +++++++++++++++ .../chunk/runtime/ComponentRegistryTest.java | 55 ++++++++ .../runtime/SessionMethodBindingTest.java | 66 +++++++++ .../chunk/runtime/SessionRegistryTest.java} | 93 +++++++++---- scripts/check-consumers.py | 2 +- 55 files changed, 893 insertions(+), 645 deletions(-) delete mode 100644 jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionMethodBinding.java delete mode 100644 jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionProvider.java delete mode 100644 jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionRegistration.java delete mode 100644 jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/AppRegistry.java delete mode 100644 jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/SessionMethodRegistry.java delete mode 100644 jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionMethodBindingTest.java rename jvm/{multistom/src/main/java/com/chunkzero/chunk/multistom => runtime/src/main/java/com/chunkzero/chunk/runtime}/ComponentBinding.java (88%) rename jvm/{multistom/src/main/java/com/chunkzero/chunk/multistom => runtime/src/main/java/com/chunkzero/chunk/runtime}/ComponentProvider.java (87%) rename jvm/{multistom/src/main/java/com/chunkzero/chunk/multistom/internal => runtime/src/main/java/com/chunkzero/chunk/runtime}/ComponentRegistry.java (79%) rename jvm/{multistom/src/main/java/com/chunkzero/chunk/multistom => runtime/src/main/java/com/chunkzero/chunk/runtime}/ConfiguredSessionProvider.java (61%) rename jvm/{multistom/src/main/java/com/chunkzero/chunk/multistom => runtime/src/main/java/com/chunkzero/chunk/runtime}/SessionCreation.java (90%) create mode 100644 jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionMethodBinding.java rename jvm/{multistom/src/main/java/com/chunkzero/chunk/multistom => runtime/src/main/java/com/chunkzero/chunk/runtime}/SessionMethodProvider.java (54%) create mode 100644 jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionMethodRegistry.java create mode 100644 jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionProvider.java create mode 100644 jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionRegistry.java create mode 100644 jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/ComponentRegistryTest.java create mode 100644 jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/SessionMethodBindingTest.java rename jvm/{multistom/src/test/java/com/chunkzero/chunk/multistom/AppRegistryTest.java => runtime/src/test/java/com/chunkzero/chunk/runtime/SessionRegistryTest.java} (62%) diff --git a/crates/chunk-build/src/codegen/java/configurations.rs b/crates/chunk-build/src/codegen/java/configurations.rs index 32dc9c7f..ff7a820b 100644 --- a/crates/chunk-build/src/codegen/java/configurations.rs +++ b/crates/chunk-build/src/codegen/java/configurations.rs @@ -37,7 +37,7 @@ pub(super) fn sources( let provider = names::type_name(session, &provider_scope.path); provider_scope.declare(&provider, session)?; provider_scope.declarations.push(format!( - "public interface {provider} extends com.chunkzero.chunk.multistom.ConfiguredSessionProvider<{}> {{\n@Override default JsonType<{}> configurationType() {{ return {}.TYPE; }}\n}}", + "public interface {provider} extends com.chunkzero.chunk.runtime.ConfiguredSessionProvider<{}, S> {{\n@Override default JsonType<{}> configurationType() {{ return {}.TYPE; }}\n}}", config.ty, config.ty, scope.path.join(".") )); metadata.push(json!({ diff --git a/crates/chunk-build/src/codegen/tests.rs b/crates/chunk-build/src/codegen/tests.rs index 568de613..713ee258 100644 --- a/crates/chunk-build/src/codegen/tests.rs +++ b/crates/chunk-build/src/codegen/tests.rs @@ -222,7 +222,9 @@ fn session_configuration_providers_are_scoped_to_their_app_and_detect_java_colli let output = root.path().join("generated"); generate(&fixture(), &output, GenerationTarget::Java { package: "example" }).unwrap(); let provider = fs::read_to_string(output.join("java-session/duels/example/DuelsSessionProviders.java")).unwrap(); - assert!(provider.contains("ConfiguredSessionProvider")); + assert!(provider.contains( + "public interface Default extends com.chunkzero.chunk.runtime.ConfiguredSessionProvider" + )); assert!(provider.contains("return SessionConfigs.Duels.Default.TYPE;")); let models = fs::read_to_string(output.join("java/example/SessionConfigs.java")).unwrap(); assert!(!models.contains("com.chunkzero.chunk.runtime")); diff --git a/crates/chunk-build/src/release/session_configurations.rs b/crates/chunk-build/src/release/session_configurations.rs index 974be6f5..656a3dde 100644 --- a/crates/chunk-build/src/release/session_configurations.rs +++ b/crates/chunk-build/src/release/session_configurations.rs @@ -64,7 +64,7 @@ pub(super) fn validate( { return Ok(None); } - let registration = read_registration(archive, "com.chunkzero.chunk.multistom.SessionProvider")?; + let registration = read_registration(archive, "com.chunkzero.chunk.runtime.SessionProvider")?; if registration.len() > 65_536 || !registration.lines().any(|line| line.trim() == configuration.provider) { return Err(io::Error::other("unregistered configured session provider")); } diff --git a/crates/chunk-build/src/release/session_methods.rs b/crates/chunk-build/src/release/session_methods.rs index ae990227..cdf45375 100644 --- a/crates/chunk-build/src/release/session_methods.rs +++ b/crates/chunk-build/src/release/session_methods.rs @@ -79,7 +79,7 @@ pub(super) fn validate( if actual.is_empty() { return Ok(()); } - let registration = read_registration(archive, "com.chunkzero.chunk.multistom.SessionMethodProvider")?; + let registration = read_registration(archive, "com.chunkzero.chunk.runtime.SessionMethodProvider")?; let providers: Vec<_> = registration.lines().filter(|line| !line.trim().is_empty()).collect(); if registration.len() > 65_536 || providers.len() != 1 diff --git a/crates/chunk-build/src/release/tests.rs b/crates/chunk-build/src/release/tests.rs index 030d1a16..9d59cd2b 100644 --- a/crates/chunk-build/src/release/tests.rs +++ b/crates/chunk-build/src/release/tests.rs @@ -397,7 +397,7 @@ fn session_methods_publish_with_the_runtime_contract_and_reject_stale_jars() { ("sample/lobby/Provider.class", class(25, 1)), ("sample/Method.class", class(25, 1)), ( - "META-INF/services/com.chunkzero.chunk.multistom.SessionMethodProvider", + "META-INF/services/com.chunkzero.chunk.runtime.SessionMethodProvider", b"sample.lobby.Provider\n".to_vec(), ), ( @@ -460,7 +460,7 @@ fn session_configurations_require_matching_packaged_schemas_and_registered_provi ("META-INF/MANIFEST.MF", b"Manifest-Version: 1.0\r\nMain-Class: sample.lobby.Provider\r\n\r\n".to_vec()), ("sample/lobby/Provider.class", class(25, 1)), ("sample/ConfigProvider.class", class(25, 1)), - ("META-INF/services/com.chunkzero.chunk.multistom.SessionProvider", provider.as_bytes().to_vec()), + ("META-INF/services/com.chunkzero.chunk.runtime.SessionProvider", provider.as_bytes().to_vec()), ( "META-INF/chunk/session-configurations.json", serde_json::to_vec(&json!({"version":1,"app":"lobby","configurations":[configuration]})).unwrap(), diff --git a/crates/chunk-cli/templates/java/Lobby.java b/crates/chunk-cli/templates/java/Lobby.java index d1f4a68e..30e862ed 100644 --- a/crates/chunk-cli/templates/java/Lobby.java +++ b/crates/chunk-cli/templates/java/Lobby.java @@ -5,9 +5,9 @@ import com.chunkzero.chunk.generated.BackendTypes.Shared.Greetings.MessageResult; import com.chunkzero.chunk.multistom.ChunkMinestom; import com.chunkzero.chunk.multistom.Session; -import com.chunkzero.chunk.multistom.SessionProvider; import com.chunkzero.chunk.multistom.SessionScope; import com.chunkzero.chunk.runtime.ChunkProcess; +import com.chunkzero.chunk.runtime.SessionProvider; import com.chunkzero.chunk.runtime.SessionType; import net.kyori.adventure.text.Component; @@ -20,7 +20,7 @@ import java.util.concurrent.CompletionStage; @SessionType("default") -public final class Lobby implements SessionProvider { +public final class Lobby implements SessionProvider { public static void main(String[] args) throws Exception { try (var chunk = ChunkProcess.connect(); var minestom = ChunkMinestom.attach(chunk, ServerProcess.create())) { diff --git a/crates/chunk-cli/templates/kotlin/Lobby.kt b/crates/chunk-cli/templates/kotlin/Lobby.kt index a7224dd4..c829f701 100644 --- a/crates/chunk-cli/templates/kotlin/Lobby.kt +++ b/crates/chunk-cli/templates/kotlin/Lobby.kt @@ -5,10 +5,10 @@ import com.chunkzero.chunk.generated.CoroutineBackendClient import com.chunkzero.chunk.multistom.ChunkMinestom import com.chunkzero.chunk.multistom.CoroutineSession import com.chunkzero.chunk.multistom.Session -import com.chunkzero.chunk.multistom.SessionProvider import com.chunkzero.chunk.multistom.SessionScope import com.chunkzero.chunk.multistom.coroutines import com.chunkzero.chunk.runtime.ChunkProcess +import com.chunkzero.chunk.runtime.SessionProvider import com.chunkzero.chunk.runtime.SessionType import net.kyori.adventure.text.Component import net.minestom.server.ServerProcess @@ -16,7 +16,7 @@ import net.minestom.server.entity.Player import net.minestom.server.instance.block.Block @SessionType("default") -class Lobby : SessionProvider { +class Lobby : SessionProvider { override fun create(): Session = GreetingSession() } diff --git a/examples/arena/README.md b/examples/arena/README.md index ba090c5b..a28e441a 100644 --- a/examples/arena/README.md +++ b/examples/arena/README.md @@ -51,8 +51,8 @@ In the arena, `/match` shows the score and `/lobby` leaves the match. Stats pers - [`Lobby.java`](apps/lobby/src/main/java/example/lobby/Lobby.java) and [`Arena.java`](apps/arena/src/main/java/example/arena/Arena.java) are each app's `main` and session provider. The - arena implements the generated `ArenaSessionProviders.Koth` interface to receive the destination's config, and its - [`ArenaSession`](apps/arena/src/main/java/example/arena/ArenaSession.java) implements the generated + arena implements the generated `ArenaSessionProviders.Koth` interface to receive the destination's + config, and its [`ArenaSession`](apps/arena/src/main/java/example/arena/ArenaSession.java) implements the generated `SessionMethods.Arena.Koth.Status`. - [`LobbyComponents`](apps/lobby/src/main/java/example/lobby/LobbyComponents.java) and [`ArenaComponents`](apps/arena/src/main/java/example/arena/ArenaComponents.java) declare a `SESSION` component for the diff --git a/examples/arena/apps/arena/src/main/java/example/arena/Arena.java b/examples/arena/apps/arena/src/main/java/example/arena/Arena.java index dcde0aaf..331718fe 100644 --- a/examples/arena/apps/arena/src/main/java/example/arena/Arena.java +++ b/examples/arena/apps/arena/src/main/java/example/arena/Arena.java @@ -3,14 +3,14 @@ import com.chunkzero.chunk.generated.ArenaSessionProviders; import com.chunkzero.chunk.generated.SessionConfigs; import com.chunkzero.chunk.multistom.ChunkMinestom; -import com.chunkzero.chunk.multistom.SessionCreation; import com.chunkzero.chunk.runtime.ChunkProcess; +import com.chunkzero.chunk.runtime.SessionCreation; import com.chunkzero.chunk.runtime.SessionType; import net.minestom.server.ServerProcess; @SessionType("koth") -public final class Arena implements ArenaSessionProviders.Koth { +public final class Arena implements ArenaSessionProviders.Koth { public static void main(String[] args) throws Exception { try (var chunk = ChunkProcess.connect(); var minestom = ChunkMinestom.attach(chunk, ServerProcess.create())) { diff --git a/examples/arena/apps/lobby/src/main/java/example/lobby/Lobby.java b/examples/arena/apps/lobby/src/main/java/example/lobby/Lobby.java index 5eb2f312..1c458979 100644 --- a/examples/arena/apps/lobby/src/main/java/example/lobby/Lobby.java +++ b/examples/arena/apps/lobby/src/main/java/example/lobby/Lobby.java @@ -1,14 +1,14 @@ package example.lobby; import com.chunkzero.chunk.multistom.ChunkMinestom; -import com.chunkzero.chunk.multistom.SessionProvider; import com.chunkzero.chunk.runtime.ChunkProcess; +import com.chunkzero.chunk.runtime.SessionProvider; import com.chunkzero.chunk.runtime.SessionType; import net.minestom.server.ServerProcess; @SessionType("default") -public final class Lobby implements SessionProvider { +public final class Lobby implements SessionProvider { public static void main(String[] args) throws Exception { try (var chunk = ChunkProcess.connect(); var minestom = ChunkMinestom.attach(chunk, ServerProcess.create())) { diff --git a/examples/load/apps/lobby/src/main/kotlin/com/chunkzero/chunk/example/load/Lobby.kt b/examples/load/apps/lobby/src/main/kotlin/com/chunkzero/chunk/example/load/Lobby.kt index 1f736fb2..92019215 100644 --- a/examples/load/apps/lobby/src/main/kotlin/com/chunkzero/chunk/example/load/Lobby.kt +++ b/examples/load/apps/lobby/src/main/kotlin/com/chunkzero/chunk/example/load/Lobby.kt @@ -4,11 +4,11 @@ import com.chunkzero.chunk.example.load.generated.BackendTypes import com.chunkzero.chunk.example.load.generated.CoroutineBackendClient import com.chunkzero.chunk.multistom.ChunkMinestom import com.chunkzero.chunk.multistom.CoroutineSession -import com.chunkzero.chunk.multistom.SessionProvider import com.chunkzero.chunk.multistom.SessionScope import com.chunkzero.chunk.multistom.coroutines import com.chunkzero.chunk.multistom.own import com.chunkzero.chunk.runtime.ChunkProcess +import com.chunkzero.chunk.runtime.SessionProvider import com.chunkzero.chunk.runtime.SessionType import kotlinx.coroutines.launch import net.minestom.server.ServerProcess @@ -23,7 +23,7 @@ private const val SAVE_SECONDS = 60L private const val TICKS_PER_LOG = 200 @SessionType("default") -class LobbySessions : SessionProvider { +class LobbySessions : SessionProvider { override fun create() = LobbySession() } diff --git a/examples/local/apps/games/arena/src/main/kotlin/com/chunkzero/chunk/example/arena/ArenaSessions.kt b/examples/local/apps/games/arena/src/main/kotlin/com/chunkzero/chunk/example/arena/ArenaSessions.kt index 9f05a411..d87825c9 100644 --- a/examples/local/apps/games/arena/src/main/kotlin/com/chunkzero/chunk/example/arena/ArenaSessions.kt +++ b/examples/local/apps/games/arena/src/main/kotlin/com/chunkzero/chunk/example/arena/ArenaSessions.kt @@ -4,13 +4,14 @@ import com.chunkzero.chunk.example.ExampleSessions import com.chunkzero.chunk.example.generated.ArenaSessionProviders import com.chunkzero.chunk.example.generated.SessionConfigs import com.chunkzero.chunk.multistom.ChunkMinestom -import com.chunkzero.chunk.multistom.SessionCreation +import com.chunkzero.chunk.multistom.Session import com.chunkzero.chunk.runtime.ChunkProcess +import com.chunkzero.chunk.runtime.SessionCreation import com.chunkzero.chunk.runtime.SessionType import net.minestom.server.ServerProcess @SessionType("default") -class ArenaSessions : ArenaSessionProviders.Default { +class ArenaSessions : ArenaSessionProviders.Default { override fun create(creation: SessionCreation) = ExampleSessions.arena("${creation.config().label()} (${creation.maxPlayers()} slots)") } diff --git a/examples/local/apps/lobby/src/main/kotlin/com/chunkzero/chunk/example/lobby/LobbySessions.kt b/examples/local/apps/lobby/src/main/kotlin/com/chunkzero/chunk/example/lobby/LobbySessions.kt index f9b06232..b1c8a7c2 100644 --- a/examples/local/apps/lobby/src/main/kotlin/com/chunkzero/chunk/example/lobby/LobbySessions.kt +++ b/examples/local/apps/lobby/src/main/kotlin/com/chunkzero/chunk/example/lobby/LobbySessions.kt @@ -1,14 +1,15 @@ package com.chunkzero.chunk.example.lobby import com.chunkzero.chunk.example.ExampleSessions +import com.chunkzero.chunk.example.LobbySession import com.chunkzero.chunk.multistom.ChunkMinestom -import com.chunkzero.chunk.multistom.SessionProvider import com.chunkzero.chunk.runtime.ChunkProcess +import com.chunkzero.chunk.runtime.SessionProvider import com.chunkzero.chunk.runtime.SessionType import net.minestom.server.ServerProcess @SessionType("default") -class LobbySessions : SessionProvider { +class LobbySessions : SessionProvider { override fun create() = ExampleSessions.lobby() } diff --git a/examples/local/shared/src/test/java/com/chunkzero/chunk/multistom/CoinCommandTest.java b/examples/local/shared/src/test/java/com/chunkzero/chunk/multistom/CoinCommandTest.java index dc4bc198..645d1637 100644 --- a/examples/local/shared/src/test/java/com/chunkzero/chunk/multistom/CoinCommandTest.java +++ b/examples/local/shared/src/test/java/com/chunkzero/chunk/multistom/CoinCommandTest.java @@ -106,15 +106,11 @@ public void subscribe( new SessionManager( process, ticks, - Map.of( + Map.>of( "lobby", - new SessionRegistration( - "lobby", ExampleSessions.INSTANCE::lobby), + ExampleSessions.INSTANCE::lobby, "arena", - new SessionRegistration( - "arena", - () -> ExampleSessions.INSTANCE.arena("Arena"))), - Map.of()); + () -> ExampleSessions.INSTANCE.arena("Arena"))); var host = TestHosts.detached( manager, diff --git a/jvm/gradle-plugin/README.md b/jvm/gradle-plugin/README.md index 137e3477..4ff55cc7 100644 --- a/jvm/gradle-plugin/README.md +++ b/jvm/gradle-plugin/README.md @@ -113,26 +113,27 @@ run as. The plugin runs that CLI; it never builds Rust tools or installs Node pa ## Session types The app's compiled classes are scanned for `@SessionType("id")`. Each annotated class must be public, concrete, have a -public no-argument constructor and implement `SessionProvider`; an app has 1 to 128 of them, with distinct IDs, and its -main class needs a `public static void main(String[])`. `generateChunkSessionRegistry` writes -`META-INF/services/com.chunkzero.chunk.multistom.SessionProvider`, from which the runtime loads the providers, and a +public no-argument constructor and implement `com.chunkzero.chunk.runtime.SessionProvider`; an app has 1 to 128 of them, +with distinct IDs, and its main class needs a `public static void main(String[])`. `generateChunkSessionRegistry` writes +`META-INF/services/com.chunkzero.chunk.runtime.SessionProvider`, from which the runtime loads the providers, and a catalog of the session type IDs for release assembly. Packaging fails unless those IDs exactly match the app's `implementations` in `app.ts` (by default just `default`). An implementation with a `config` validator must implement its generated interface, for example -`LobbySessionProviders.Default`, and receives the validated configuration in `create(SessionCreation)`; one -without must implement plain `SessionProvider`. +`LobbySessionProviders.Default`, and receives the validated configuration in +`create(SessionCreation)`; one without must implement plain `SessionProvider`. The type argument +is the session class; the plugin only checks the contracts, while the engine decides which session classes it accepts. ## Session methods For each `sessionMethod` declared in TypeScript (see the [SDK](../../crates/chunk-build/sdk/README.md#session-methods)), the generated `SessionMethods` class has a single-method interface with `Args` and result types. The session class -implements it, and the provider's `create` must declare that concrete class as its return type. From the +implements it, and the provider's `create` must declare that public concrete class as its return type. From the [arena example](../../examples/arena/apps/arena/src/main/java/example/arena/Arena.java): ```java @SessionType("koth") -public final class Arena implements ArenaSessionProviders.Koth { +public final class Arena implements ArenaSessionProviders.Koth { @Override public ArenaSession create(SessionCreation creation) { var config = creation.config(); @@ -154,16 +155,23 @@ public final class ArenaSession extends Session implements SessionMethods.Arena. The signature is synchronous: futures and Kotlin `suspend` functions cannot implement it. Implementing a method that belongs to another app or session type is a build error. `generateChunkSessionRegistry` checks the implementations and -writes `META-INF/chunk/session-methods.json` and a method provider service; `compileChunkSessionMethods` compiles -direct-call adapters into the app, which the runtime loads without scanning the classpath. +writes `META-INF/chunk/session-methods.json` and a `com.chunkzero.chunk.runtime.SessionMethodProvider` service; +`compileChunkSessionMethods` compiles typed direct-call adapters into the app, such as +`new SessionMethodBinding<>(Status.REF, ArenaSession.class, (session, args) -> session.status(args))`, which the runtime +loads without scanning the classpath. ## Components Every module applying a Chunk plugin indexes its public static `@Component` factories after compilation (`generateChunkComponentIndex`). Each app then reads its own index and those in its dependencies' JARs, checks the graph for missing or duplicate providers, cycles and process components that depend on session ones -(`generateChunkComponentBindings`), and compiles one provider that calls the factories directly -(`compileChunkComponents`). See the [Minestom runtime](../multistom/README.md#components) for writing components. +(`generateChunkComponentBindings`), and compiles one `com.chunkzero.chunk.runtime.ComponentProvider` that calls the +factories directly (`compileChunkComponents`). See the [Minestom runtime](../multistom/README.md#components) for writing +components. + +The host supplies some types to session-scoped factories itself: `BackendSession`, and any class on the app's runtime +classpath annotated `@Component.Supplied`, such as an engine's session handle. A factory may take them only if it is +session-scoped, and no factory may provide them. ## Testing diff --git a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ClassLookup.kt b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ClassLookup.kt index aab74477..35cd2256 100644 --- a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ClassLookup.kt +++ b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ClassLookup.kt @@ -1,9 +1,58 @@ package com.chunkzero.chunk.gradle import com.google.gson.JsonObject +import org.objectweb.asm.AnnotationVisitor +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type +import java.io.File +import java.util.jar.JarFile internal typealias ClassLookup = (String) -> CompiledClass? +internal class CompiledClass { + var name = "" + var parents = emptyList() + var publicConcrete = false + var constructor = false + var main = false + var session: String? = null + var supplied = false + var creates: String? = null + var createsConfigured: String? = null + val constructible get() = publicConcrete && constructor +} + +/** Looks up classes in [preloaded], then in the class directories and JARs of [classpath]. */ +internal fun classLookup( + classpath: Collection, + preloaded: Map = emptyMap(), +): ClassLookup { + val cache = preloaded.toMutableMap() + return { name -> + cache[name] ?: classpath + .firstNotNullOfOrNull { readClass(it, name) } + ?.let(::inspectClass) + ?.also { cache[name] = it } + } +} + +internal fun readClass( + file: File, + name: String, +): ByteArray? = + if (file.isDirectory) { + file.resolve("$name.class").takeIf { it.isFile }?.readBytes() + } else if (file.isFile) { + JarFile(file).use { jar -> + jar.getJarEntry("$name.class")?.let { jar.getInputStream(it).use { input -> input.readAllBytes() } } + } + } else { + null + } + internal fun ClassLookup.inherits( name: String, target: String, @@ -21,3 +70,76 @@ internal fun ClassLookup.implementsForeign( (it["app"].asString != app || it["session"].asString != session) && inherits(type, it["binary_interface"].asString.replace('.', '/')) } + +internal fun inspectClass(bytes: ByteArray): CompiledClass { + val type = CompiledClass() + ClassReader(bytes).accept( + object : ClassVisitor(Opcodes.ASM9) { + override fun visit( + version: Int, + access: Int, + name: String, + signature: String?, + superName: String?, + interfaces: Array, + ) { + type.name = name + type.parents = interfaces.toList() + listOfNotNull(superName) + type.publicConcrete = + access and Opcodes.ACC_PUBLIC != 0 && + access and (Opcodes.ACC_ABSTRACT or Opcodes.ACC_INTERFACE) == 0 + } + + override fun visitAnnotation( + descriptor: String, + visible: Boolean, + ): AnnotationVisitor? { + if (descriptor == "Lcom/chunkzero/chunk/runtime/Component\$Supplied;" && visible) type.supplied = true + if (descriptor != "Lcom/chunkzero/chunk/runtime/SessionType;") return null + type.session = "" + return object : AnnotationVisitor(Opcodes.ASM9) { + override fun visit( + name: String, + value: Any, + ) { + if (name == "value") type.session = value as? String ?: "" + } + } + } + + override fun visitMethod( + access: Int, + name: String, + descriptor: String, + signature: String?, + exceptions: Array?, + ): MethodVisitor? { + val arguments = Type.getArgumentTypes(descriptor) + val creation = + arguments.size == 1 && arguments[0].descriptor == "Lcom/chunkzero/chunk/runtime/SessionCreation;" + if (name == "create" && (arguments.isEmpty() || creation) && + access and Opcodes.ACC_PUBLIC != 0 && + access and (Opcodes.ACC_STATIC or Opcodes.ACC_BRIDGE) == 0 && + Type.getReturnType(descriptor).sort == Type.OBJECT + ) { + val result = Type.getReturnType(descriptor).internalName + if (creation) type.createsConfigured = result else type.creates = result + } + if (name == "" && descriptor == "()V" && + access and Opcodes.ACC_PUBLIC != 0 + ) { + type.constructor = true + } + if (name == "main" && descriptor == "([Ljava/lang/String;)V" && + access and (Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC) == (Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC) + ) { + type.main = + true + } + return null + } + }, + ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES, + ) + return type +} diff --git a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ComponentIndex.kt b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ComponentIndex.kt index 29010f9e..dc185518 100644 --- a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ComponentIndex.kt +++ b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ComponentIndex.kt @@ -116,13 +116,18 @@ internal fun componentFactories(methods: List): List): List { +/** Links [factories]; [lookup] resolves `@Component.Supplied` types, which the host supplies per session. */ +internal fun validateComponents( + factories: List, + lookup: ClassLookup, +): List { require(factories.size <= 256) { "App supports at most 256 component factories" } val byType = mutableMapOf() - val builtins = - setOf("com/chunkzero/chunk/multistom/SessionScope", "com/chunkzero/chunk/backend/client/BackendSession") + + fun builtin(type: String) = + type == "com/chunkzero/chunk/backend/client/BackendSession" || lookup(type)?.supplied == true for (factory in factories) { - require(factory.type !in builtins) { "Component cannot replace session builtin: ${factory.type}" } + require(!builtin(factory.type)) { "Component cannot provide host-supplied type: ${factory.type}" } require(byType.put(factory.type, factory) == null) { "Duplicate component identity: ${factory.type}" } require(factory.dependencies.size <= 32) { "Component ${factory.type} supports at most 32 dependencies" } } @@ -135,10 +140,11 @@ internal fun validateComponents(factories: List): List val type = provider.replace('.', '/') - creates(type, lookup.inherits(type, "com/chunkzero/chunk/multistom/ConfiguredSessionProvider")) + creates(type, lookup.inherits(type, "com/chunkzero/chunk/runtime/ConfiguredSessionProvider")) } val bindings = declarations.map { method -> val session = method["session"].asString val name = method["name"].asString val implementation = implementations[session] - require( - implementation != null && lookup(implementation)?.publicConcrete == true && - lookup.inherits(implementation, "com/chunkzero/chunk/multistom/Session"), - ) { - "Session $app/$session method $name requires a provider creation method with a public concrete Session return type" + require(implementation != null && lookup(implementation)?.publicConcrete == true) { + "Session $app/$session method $name requires a provider creation method with a public concrete return type" } val contractInterface = method["binary_interface"].asString.replace('.', '/') require(lookup.inherits(implementation, contractInterface)) { "Session $app/$session must implement ${method["interface"].asString} for method $name" } val type = method["interface"].asString - "new com.chunkzero.chunk.multistom.SessionMethodBinding<>($type.REF, " + - "(session, args) -> (($type) session).${method["function"].asString}(args))" + "new com.chunkzero.chunk.runtime.SessionMethodBinding<>($type.REF, ${sourceType(implementation)}.class, " + + "(session, args) -> session.${method["function"].asString}(args))" } for ((session, implementation) in implementations) { if (implementation == null) continue @@ -62,7 +59,7 @@ internal fun writeSessionMethods( } sources.deleteRecursively() sources.mkdirs() - val service = resources.resolve("META-INF/services/com.chunkzero.chunk.multistom.SessionMethodProvider") + val service = resources.resolve("META-INF/services/com.chunkzero.chunk.runtime.SessionMethodProvider") service.delete() if (bindings.isNotEmpty()) { val type = "com.chunkzero.chunk.generated.sessions.a${app.toByteArray().joinToString( @@ -73,8 +70,8 @@ internal fun writeSessionMethods( file.writeText( """ package ${type.substringBeforeLast('.')}; - public final class ChunkSessionMethods implements com.chunkzero.chunk.multistom.SessionMethodProvider { - public java.util.Collection> methods() { + public final class ChunkSessionMethods implements com.chunkzero.chunk.runtime.SessionMethodProvider { + public java.util.Collection> methods() { return java.util.List.of(${bindings.joinToString(",\n")}); } } diff --git a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/WriteComponentBindings.kt b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/WriteComponentBindings.kt index 395dafe7..2b999320 100644 --- a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/WriteComponentBindings.kt +++ b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/WriteComponentBindings.kt @@ -69,7 +69,7 @@ abstract class WriteComponentBindings : DefaultTask() { } } } - val factories = validateComponents(componentFactories(methods)) + val factories = validateComponents(componentFactories(methods), classLookup(locations)) val sources = sourceDirectory.get().asFile val resources = resourceDirectory.get().asFile sources.deleteRecursively() @@ -84,7 +84,7 @@ abstract class WriteComponentBindings : DefaultTask() { factories.map { factory -> val dependencies = factory.dependencies.joinToString(", ") { "${sourceType(it)}.class" } val arguments = factory.dependencies.mapIndexed { index, type -> "(${sourceType(type)}) args[$index]" } - "new com.chunkzero.chunk.multistom.ComponentBinding<>(${sourceType(factory.type)}.class, " + + "new com.chunkzero.chunk.runtime.ComponentBinding<>(${sourceType(factory.type)}.class, " + "com.chunkzero.chunk.runtime.Component.Scope.${factory.scope}, java.util.List.of($dependencies), " + "args -> ${sourceType(factory.owner)}.${factory.name}(${arguments.joinToString(", ")}))" } @@ -93,20 +93,20 @@ abstract class WriteComponentBindings : DefaultTask() { source.writeText( """ package $namespace; - public final class ChunkComponents implements com.chunkzero.chunk.multistom.ComponentProvider { - public java.util.Collection> components() { + public final class ChunkComponents implements com.chunkzero.chunk.runtime.ComponentProvider { + public java.util.Collection> components() { return java.util.List.of(${bindings.joinToString(",\n")}); } } """.trimIndent() + "\n", ) - val service = resources.resolve("META-INF/services/com.chunkzero.chunk.multistom.ComponentProvider") + val service = resources.resolve("META-INF/services/com.chunkzero.chunk.runtime.ComponentProvider") service.parentFile.mkdirs() service.writeText("$namespace.ChunkComponents\n") } } -private fun sourceType(binary: String) = binary.replace('/', '.').replace('$', '.') +internal fun sourceType(binary: String) = binary.replace('/', '.').replace('$', '.') private fun componentIndexes(file: File): List { val prefix = "META-INF/chunk/components/" @@ -137,17 +137,3 @@ private fun componentIndexes(file: File): List { emptyList() } } - -private fun readClass( - file: File, - name: String, -): ByteArray? = - if (file.isDirectory) { - file.resolve("$name.class").takeIf { it.isFile }?.readBytes() - } else if (file.isFile) { - JarFile(file).use { jar -> - jar.getJarEntry("$name.class")?.let { jar.getInputStream(it).use { input -> input.readAllBytes() } } - } - } else { - null - } diff --git a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/WriteSessionRegistry.kt b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/WriteSessionRegistry.kt index 7ac402cb..5729aff9 100644 --- a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/WriteSessionRegistry.kt +++ b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/WriteSessionRegistry.kt @@ -15,13 +15,6 @@ import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction -import org.objectweb.asm.AnnotationVisitor -import org.objectweb.asm.ClassReader -import org.objectweb.asm.ClassVisitor -import org.objectweb.asm.MethodVisitor -import org.objectweb.asm.Opcodes -import org.objectweb.asm.Type -import java.util.jar.JarFile @CacheableTask abstract class WriteSessionRegistry : DefaultTask() { @@ -54,42 +47,19 @@ abstract class WriteSessionRegistry : DefaultTask() { val found = sortedMapOf() classes.files.filter { it.isDirectory }.forEach { directory -> directory.walkTopDown().filter { it.isFile && it.extension == "class" }.forEach { - val type = inspect(it.readBytes()) + val type = inspectClass(it.readBytes()) found[type.name] = type } } val main = found[mainClass.get().replace('.', '/')] require(main?.main == true) { "App requires a compiled public static main(String[]): ${mainClass.get()}" } - val cache = found.toMutableMap() - - fun lookup(name: String): CompiledClass? = - cache[name] ?: dependencies.files - .firstNotNullOfOrNull { file -> - if (file.isDirectory) { - file - .resolve("$name.class") - .takeIf { it.isFile } - ?.readBytes() - ?.let(::inspect) - } else if (file.isFile) { - JarFile(file).use { jar -> - jar.getJarEntry("$name.class")?.let { - jar.getInputStream(it).use { input -> - inspect(input.readBytes()) - } - } - } - } else { - null - } - }?.also { cache[name] = it } - + val lookup = classLookup(dependencies.files, found) val sessions = sortedMapOf() for (type in found.values.filter { it.session != null }) { val id = requireNotNull(type.session) require(id.matches(Regex("[A-Za-z_][A-Za-z0-9_]{0,127}"))) { "Invalid session type ID: $id" } require( - type.constructible && ::lookup.inherits(type.name, "com/chunkzero/chunk/multistom/SessionProvider"), + type.constructible && lookup.inherits(type.name, "com/chunkzero/chunk/runtime/SessionProvider"), ) { "Session $id requires a public concrete SessionProvider with a public no-argument constructor" } @@ -100,21 +70,21 @@ abstract class WriteSessionRegistry : DefaultTask() { app.get(), sessions, configurationContracts.singleFile, - ::lookup, + lookup, outputDirectory.get().asFile, ) writeSessionMethods( app.get(), sessions, methodContracts.singleFile, - ::lookup, + lookup, outputDirectory.get().asFile, bindingSourceDirectory.get().asFile, ) val output = outputDirectory .file( - "META-INF/services/com.chunkzero.chunk.multistom.SessionProvider", + "META-INF/services/com.chunkzero.chunk.runtime.SessionProvider", ).get() .asFile output.parentFile.mkdirs() @@ -124,87 +94,3 @@ abstract class WriteSessionRegistry : DefaultTask() { catalog.writeText(Gson().toJson(sessions.keys) + "\n") } } - -internal class CompiledClass { - var name = "" - var parents = emptyList() - var publicConcrete = false - var constructor = false - var main = false - var session: String? = null - var creates: String? = null - var createsConfigured: String? = null - val constructible get() = publicConcrete && constructor -} - -private fun inspect(bytes: ByteArray): CompiledClass { - val type = CompiledClass() - ClassReader(bytes).accept( - object : ClassVisitor(Opcodes.ASM9) { - override fun visit( - version: Int, - access: Int, - name: String, - signature: String?, - superName: String?, - interfaces: Array, - ) { - type.name = name - type.parents = interfaces.toList() + listOfNotNull(superName) - type.publicConcrete = - access and Opcodes.ACC_PUBLIC != 0 && - access and (Opcodes.ACC_ABSTRACT or Opcodes.ACC_INTERFACE) == 0 - } - - override fun visitAnnotation( - descriptor: String, - visible: Boolean, - ): AnnotationVisitor? { - if (descriptor != "Lcom/chunkzero/chunk/runtime/SessionType;") return null - type.session = "" - return object : AnnotationVisitor(Opcodes.ASM9) { - override fun visit( - name: String, - value: Any, - ) { - if (name == "value") type.session = value as? String ?: "" - } - } - } - - override fun visitMethod( - access: Int, - name: String, - descriptor: String, - signature: String?, - exceptions: Array?, - ): MethodVisitor? { - val arguments = Type.getArgumentTypes(descriptor) - val creation = - arguments.size == 1 && arguments[0].descriptor == "Lcom/chunkzero/chunk/multistom/SessionCreation;" - if (name == "create" && (arguments.isEmpty() || creation) && - access and Opcodes.ACC_PUBLIC != 0 && - access and (Opcodes.ACC_STATIC or Opcodes.ACC_BRIDGE) == 0 && - Type.getReturnType(descriptor).sort == Type.OBJECT - ) { - val result = Type.getReturnType(descriptor).internalName - if (creation) type.createsConfigured = result else type.creates = result - } - if (name == "" && descriptor == "()V" && - access and Opcodes.ACC_PUBLIC != 0 - ) { - type.constructor = true - } - if (name == "main" && descriptor == "([Ljava/lang/String;)V" && - access and (Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC) == (Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC) - ) { - type.main = - true - } - return null - } - }, - ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES, - ) - return type -} diff --git a/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt b/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt index 52532171..8c11a641 100644 --- a/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt +++ b/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt @@ -164,7 +164,7 @@ class ChunkPluginTest { """ package fixture.lobby; @com.chunkzero.chunk.runtime.SessionType("default") - public final class Duplicate implements com.chunkzero.chunk.multistom.SessionProvider {} + public final class Duplicate implements com.chunkzero.chunk.runtime.SessionProvider {} """, ) assertTrue(runFailure(":apps:lobby:generateChunkSessionRegistry").output.contains("Duplicate session type")) @@ -179,7 +179,7 @@ class ChunkPluginTest { "apps/lobby/src/main/java/Game.java", """ package fixture.lobby; - public final class Game extends com.chunkzero.chunk.multistom.Session {} + public final class Game {} """, ) assertTrue(runFailure("chunkArtifacts").output.contains("must implement fixture.generated.Ping")) @@ -219,8 +219,8 @@ class ChunkPluginTest { source .readText() .replace( - "fixture.generated.LobbySessionProviders.Default", - "com.chunkzero.chunk.multistom.ConfiguredSessionProvider", + "fixture.generated.LobbySessionProviders.Default", + "com.chunkzero.chunk.runtime.ConfiguredSessionProvider", ).replace( "public Game create", "public com.chunkzero.chunk.backend.api.JsonType configurationType() { return null; } public Game create", @@ -295,15 +295,10 @@ class ChunkPluginTest { fixture(kotlin) app("lobby", kotlin) val repository = File(System.getProperty("chunk.test.repository")) - for ((module, name) in listOf( - "runtime" to "Component", - "multistom" to "ComponentBinding", - "multistom" to "ComponentProvider", - )) { - val packagePath = "com/chunkzero/chunk/" + if (module == "runtime") "runtime" else "multistom" + for (name in listOf("Component", "ComponentBinding", "ComponentProvider")) { write( - "src/main/java/$packagePath/$name.java", - repository.resolve("jvm/$module/src/main/java/$packagePath/$name.java").readText(), + "src/main/java/com/chunkzero/chunk/runtime/$name.java", + repository.resolve("jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/$name.java").readText(), ) } write( @@ -325,10 +320,10 @@ class ChunkPluginTest { """ package fixture.lobby; public final class Verify { - private static java.util.Map, com.chunkzero.chunk.multistom.ComponentBinding> bindings; + private static java.util.Map, com.chunkzero.chunk.runtime.ComponentBinding> bindings; public static void run() throws Exception { bindings = new java.util.HashMap<>(); - var providers = java.util.ServiceLoader.load(com.chunkzero.chunk.multistom.ComponentProvider.class).stream().toList(); + var providers = java.util.ServiceLoader.load(com.chunkzero.chunk.runtime.ComponentProvider.class).stream().toList(); if (providers.size() != 1) throw new AssertionError("one generated app registry"); for (var binding : providers.getFirst().get().components()) bindings.put(binding.type(),binding); if (bindings.size() != 3) throw new AssertionError("expected shared and app factories"); @@ -352,7 +347,7 @@ class ChunkPluginTest { package fixture.lobby import com.chunkzero.chunk.runtime.Component @com.chunkzero.chunk.runtime.SessionType("default") - class Factory : com.chunkzero.chunk.multistom.SessionProvider + class Factory : com.chunkzero.chunk.runtime.SessionProvider class Services { companion object { @JvmStatic @@ -372,7 +367,7 @@ class ChunkPluginTest { package fixture.lobby; import com.chunkzero.chunk.runtime.Component; @com.chunkzero.chunk.runtime.SessionType("default") - public final class App implements com.chunkzero.chunk.multistom.SessionProvider { + public final class App implements com.chunkzero.chunk.runtime.SessionProvider { @Component(Component.Scope.PROCESS) public static Extra extra() { return new Extra("app"); } @Component(Component.Scope.SESSION) @@ -401,10 +396,10 @@ class ChunkPluginTest { val repository = File(System.getProperty("chunk.test.repository")) for (name in listOf("SessionCreation", "ConfiguredSessionProvider", "SessionProvider")) { write( - "apps/lobby/src/main/java/com/chunkzero/chunk/multistom/$name.java", + "apps/lobby/src/main/java/com/chunkzero/chunk/runtime/$name.java", repository .resolve( - "jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/$name.java", + "jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/$name.java", ).readText(), ) } @@ -417,14 +412,13 @@ class ChunkPluginTest { "apps/lobby/src/main/kotlin/App.kt", """ package fixture.lobby - import com.chunkzero.chunk.multistom.SessionCreation + import com.chunkzero.chunk.runtime.SessionCreation import fixture.generated.Bindings @com.chunkzero.chunk.runtime.SessionType("default") - class Factory : fixture.generated.LobbySessionProviders.Default { + class Factory : fixture.generated.LobbySessionProviders.Default { override fun create(creation: SessionCreation) = Game() - override fun create(): com.chunkzero.chunk.multistom.Session = Game() } - class Game : com.chunkzero.chunk.multistom.Session(), fixture.generated.Ping { + class Game : fixture.generated.Ping { override fun ping(args: Bindings): String = "echo:" + args.value() } fun main() { Verify.run(Factory().create(SessionCreation(32, Bindings("ok")))) } @@ -436,11 +430,10 @@ class ChunkPluginTest { """ package fixture.lobby; @com.chunkzero.chunk.runtime.SessionType("default") - public final class App implements fixture.generated.LobbySessionProviders.Default { - public Game create(com.chunkzero.chunk.multistom.SessionCreation creation) { return new Game(); } - public com.chunkzero.chunk.multistom.Session create() { return new Game(); } + public final class App implements fixture.generated.LobbySessionProviders.Default { + public Game create(com.chunkzero.chunk.runtime.SessionCreation creation) { return new Game(); } public static void main(String[] args) { - Verify.run(new App().create(new com.chunkzero.chunk.multistom.SessionCreation<>(32, new fixture.generated.Bindings("ok")))); + Verify.run(new App().create(new com.chunkzero.chunk.runtime.SessionCreation<>(32, new fixture.generated.Bindings("ok")))); } } """, @@ -453,29 +446,23 @@ class ChunkPluginTest { app("lobby", kotlin) write("method-fixture", "") write( - "apps/lobby/src/main/java/com/chunkzero/chunk/multistom/Session.java", + "apps/lobby/src/main/java/com/chunkzero/chunk/runtime/SessionMethodBinding.java", """ - package com.chunkzero.chunk.multistom; - public abstract class Session {} - """, - ) - write( - "apps/lobby/src/main/java/com/chunkzero/chunk/multistom/SessionMethodBinding.java", - """ - package com.chunkzero.chunk.multistom; - public final class SessionMethodBinding { - private final java.util.function.BiFunction body; - public SessionMethodBinding(fixture.generated.Ping.Ref ref, java.util.function.BiFunction body) { this.body=body; } - public R invoke(Session session, A args) { return body.apply(session,args); } + package com.chunkzero.chunk.runtime; + public final class SessionMethodBinding { + private final Class session; + private final java.util.function.BiFunction body; + public SessionMethodBinding(fixture.generated.Ping.Ref ref, Class session, java.util.function.BiFunction body) { this.session=session; this.body=body; } + public R invoke(Object target, A args) { return body.apply(session.cast(target),args); } } """, ) write( - "apps/lobby/src/main/java/com/chunkzero/chunk/multistom/SessionMethodProvider.java", + "apps/lobby/src/main/java/com/chunkzero/chunk/runtime/SessionMethodProvider.java", """ - package com.chunkzero.chunk.multistom; + package com.chunkzero.chunk.runtime; public interface SessionMethodProvider { - java.util.Collection> methods(); + java.util.Collection> methods(); } """, ) @@ -485,9 +472,9 @@ class ChunkPluginTest { package fixture.lobby; public final class Verify { @SuppressWarnings("unchecked") - public static void run(com.chunkzero.chunk.multistom.Session session) { - var provider=java.util.ServiceLoader.load(com.chunkzero.chunk.multistom.SessionMethodProvider.class).findFirst().orElseThrow(); - var method=(com.chunkzero.chunk.multistom.SessionMethodBinding) provider.methods().iterator().next(); + public static void run(Object session) { + var provider=java.util.ServiceLoader.load(com.chunkzero.chunk.runtime.SessionMethodProvider.class).findFirst().orElseThrow(); + var method=(com.chunkzero.chunk.runtime.SessionMethodBinding) provider.methods().iterator().next(); System.out.println(method.invoke(session,new fixture.generated.Bindings("ok"))); } } @@ -499,10 +486,10 @@ class ChunkPluginTest { """ package fixture.lobby @com.chunkzero.chunk.runtime.SessionType("default") - class Factory : com.chunkzero.chunk.multistom.SessionProvider { + class Factory : com.chunkzero.chunk.runtime.SessionProvider { fun create() = Game() } - class Game : com.chunkzero.chunk.multistom.Session(), fixture.generated.Ping { + class Game : fixture.generated.Ping { override fun ping(args: fixture.generated.Bindings): String = "echo:" + args.value() } fun main() { Verify.run(Factory().create()) } @@ -514,7 +501,7 @@ class ChunkPluginTest { """ package fixture.lobby; @com.chunkzero.chunk.runtime.SessionType("default") - public final class App implements com.chunkzero.chunk.multistom.SessionProvider { + public final class App implements com.chunkzero.chunk.runtime.SessionProvider { public Game create() { return new Game(); } public static void main(String[] args) { Verify.run(new App().create()); } } @@ -524,7 +511,7 @@ class ChunkPluginTest { "apps/lobby/src/main/java/Game.java", """ package fixture.lobby; - public final class Game extends com.chunkzero.chunk.multistom.Session implements fixture.generated.Ping { + public final class Game implements fixture.generated.Ping { public String ping(fixture.generated.Bindings args) { return "echo:" + args.value(); } } """, @@ -633,7 +620,7 @@ class ChunkPluginTest { files['java/fixture/generated/Ping.java'] = 'package fixture.generated; public interface Ping { String ping(Bindings args); record Ref() {} Ref REF = new Ref<>(); }' methods = [{'app':'lobby','session':'default','name':'ping','interface':'fixture.generated.Ping','binary_interface':'fixture.generated.Ping','function':'ping','arguments':{'type':'object','fields':{}},'result':{'type':'string'}}] if (root / 'configuration-fixture').is_file(): - files['java-session/lobby/fixture/generated/LobbySessionProviders.java'] = 'package fixture.generated; public final class LobbySessionProviders { public interface Default extends com.chunkzero.chunk.multistom.ConfiguredSessionProvider { default com.chunkzero.chunk.backend.api.JsonType configurationType() { return null; } } }' + files['java-session/lobby/fixture/generated/LobbySessionProviders.java'] = 'package fixture.generated; public final class LobbySessionProviders { public interface Default extends com.chunkzero.chunk.runtime.ConfiguredSessionProvider { default com.chunkzero.chunk.backend.api.JsonType configurationType() { return null; } } }' configurations = [{'app':'lobby','session':'default','interface':'fixture.generated.LobbySessionProviders.Default','binary_interface':'fixture.generated.LobbySessionProviders${'$'}Default','configuration':{'type':'object','fields':{'value':{'schema':{'type':'string'}}}}}] for name, content in files.items(): path = output / name @@ -677,9 +664,9 @@ class ChunkPluginTest { """, ) write( - "apps/$id/src/main/java/com/chunkzero/chunk/multistom/SessionProvider.java", + "apps/$id/src/main/java/com/chunkzero/chunk/runtime/SessionProvider.java", """ - package com.chunkzero.chunk.multistom; + package com.chunkzero.chunk.runtime; public interface SessionProvider {} """, ) @@ -689,7 +676,7 @@ class ChunkPluginTest { """ package fixture.$id @com.chunkzero.chunk.runtime.SessionType("default") - class Factory : com.chunkzero.chunk.multistom.SessionProvider + class Factory : com.chunkzero.chunk.runtime.SessionProvider fun main(args: Array) {} suspend fun result(): fixture.generated.Bindings = fixture.generated.value() """, @@ -700,7 +687,7 @@ class ChunkPluginTest { """ package fixture.$id; @com.chunkzero.chunk.runtime.SessionType("default") - public final class App implements com.chunkzero.chunk.multistom.SessionProvider { + public final class App implements com.chunkzero.chunk.runtime.SessionProvider { public static void main(String[] args) {} public fixture.generated.Bindings value() { return fixture.generated.Client.value(); } } @@ -764,7 +751,7 @@ class ChunkPluginTest { JarFile(app.asJsonObject["jar"].asString).use { jar -> assertTrue(jar.getJarEntry("META-INF/chunk/app.json") == null) val entry = - requireNotNull(jar.getJarEntry("META-INF/services/com.chunkzero.chunk.multistom.SessionProvider")) + requireNotNull(jar.getJarEntry("META-INF/services/com.chunkzero.chunk.runtime.SessionProvider")) val providers = jar.getInputStream(entry).bufferedReader().use { it.readLines() } assertEquals(1, providers.size) assertTrue(jar.getJarEntry(providers.single().replace('.', '/') + ".class") != null) diff --git a/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ComponentIndexTest.kt b/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ComponentIndexTest.kt index 4664d99a..de0ac3c9 100644 --- a/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ComponentIndexTest.kt +++ b/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ComponentIndexTest.kt @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.objectweb.asm.ClassWriter import org.objectweb.asm.Opcodes class ComponentIndexTest { @@ -16,14 +17,14 @@ class ComponentIndexTest { listOf(factory("A", "SESSION", "B"), factory("B", "SESSION", "A")) to "Component dependency cycle", listOf(factory("A", "PROCESS", "B"), factory("B", "PROCESS", "C"), factory("C")) to "captures session dependency", - listOf(factory("A", "PROCESS", "com/chunkzero/chunk/multistom/SessionScope")) to + listOf(factory("A", "PROCESS", "com/chunkzero/chunk/backend/client/BackendSession")) to "captures session dependency", listOf( factory("com/chunkzero/chunk/backend/client/BackendSession"), - ) to "cannot replace session builtin", + ) to "cannot provide host-supplied type", ) for ((graph, message) in cases) { - val error = assertThrows(IllegalArgumentException::class.java) { validateComponents(graph) } + val error = assertThrows(IllegalArgumentException::class.java) { validateComponents(graph, { null }) } assertTrue(error.message.orEmpty().contains(message), error.toString()) } } @@ -32,11 +33,37 @@ class ComponentIndexTest { fun `sessions can use process providers and session builtins without duplicate shared construction`() { val graph = listOf( - factory("View", "SESSION", "Clock", "Backend", "com/chunkzero/chunk/multistom/SessionScope"), + factory("View", "SESSION", "Clock", "Backend"), factory("Clock", "PROCESS"), factory("Backend", "SESSION", "com/chunkzero/chunk/backend/client/BackendSession"), ) - assertEquals(listOf("Backend", "Clock", "View"), validateComponents(graph).map { it.type }) + assertEquals(listOf("Backend", "Clock", "View"), validateComponents(graph, { null }).map { it.type }) + } + + @Test + fun `supplied types are session dependencies that no factory provides`() { + val bytes = + ClassWriter(0) + .apply { + visit(Opcodes.V21, Opcodes.ACC_PUBLIC, "host/Scope", null, "java/lang/Object", null) + visitAnnotation("Lcom/chunkzero/chunk/runtime/Component\$Supplied;", true).visitEnd() + visitEnd() + }.toByteArray() + val supplied = inspectClass(bytes) + val lookup: ClassLookup = { if (it == "host/Scope") supplied else null } + assertEquals( + listOf("View"), + validateComponents(listOf(factory("View", "SESSION", "host/Scope")), lookup).map { + it.type + }, + ) + for ((graph, message) in listOf( + listOf(factory("View", "PROCESS", "host/Scope")) to "captures session dependency", + listOf(factory("host/Scope", "SESSION")) to "cannot provide host-supplied type", + )) { + val error = assertThrows(IllegalArgumentException::class.java) { validateComponents(graph, lookup) } + assertTrue(error.message.orEmpty().contains(message), error.toString()) + } } @Test diff --git a/jvm/minestom/README.md b/jvm/minestom/README.md index 535782bc..b38a7ca4 100644 --- a/jvm/minestom/README.md +++ b/jvm/minestom/README.md @@ -41,5 +41,8 @@ try (var chunk = ChunkProcess.connect()) { their delivery, and the delivery is released once it completes successfully; a failed leave is logged and leaves the delivery unreleased. A session ends only after its players are released. - `login.delivery(player)` also gives `move(destination)` and `operationId(action)` for that player. +- The handler can create sessions from the app's providers, methods and components with the runtime's + [`SessionRegistry`, `SessionMethodRegistry` and `ComponentRegistry`](../runtime/README.md#running-your-own-sessions), + as multistom does. - Isolation is the app's choice: several sessions can share instances, own several each, or the app can run one session per JVM through its machine profile's `max_sessions = 1`. diff --git a/jvm/multistom/README.md b/jvm/multistom/README.md index 8504d037..a910aae3 100644 --- a/jvm/multistom/README.md +++ b/jvm/multistom/README.md @@ -11,13 +11,14 @@ Java Edition 26.2. ## Sessions -A session is one gameplay instance of a session type, and one JVM runs several. Each session type is a `SessionProvider` -annotated with its ID, which must match an implementation in the app's `app.ts` (`default` unless it declares others). -The provider creates fresh state for every session. From the Java project template: +A session is one gameplay instance of a session type, and one JVM runs several. Each session type is a runtime +`SessionProvider` annotated with its ID, which must match an implementation in the app's `app.ts` (`default` unless +it declares others). The provider creates fresh state for every session, and on multistom `S` must be a `Session`. From +the Java project template: ```java @SessionType("default") -public final class Lobby implements SessionProvider { +public final class Lobby implements SessionProvider { @Override public Session create() { return new GreetingSession(); @@ -58,7 +59,7 @@ does: ```kotlin @SessionType("default") -class ArenaSessions : ArenaSessionProviders.Default { +class ArenaSessions : ArenaSessionProviders.Default { override fun create(creation: SessionCreation) = ExampleSessions.arena("${creation.config().label()} (${creation.maxPlayers()} slots)") } diff --git a/jvm/multistom/build.gradle.kts b/jvm/multistom/build.gradle.kts index b0d9bbf7..2fd34a39 100644 --- a/jvm/multistom/build.gradle.kts +++ b/jvm/multistom/build.gradle.kts @@ -27,7 +27,7 @@ dokka { dokkaSourceSets.configureEach { suppressedFiles.from( fileTree("src/main/java/com/chunkzero/chunk/multistom") { - include("ManagedPlayer.java", "SessionManager.java", "SessionRegistration.java", "TickExecutor.java") + include("ManagedPlayer.java", "SessionManager.java", "TickExecutor.java") }, ) } diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ChunkMinestom.java b/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ChunkMinestom.java index bb197f89..cf241fba 100644 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ChunkMinestom.java +++ b/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ChunkMinestom.java @@ -1,11 +1,11 @@ package com.chunkzero.chunk.multistom; -import com.chunkzero.chunk.multistom.internal.AppRegistry; -import com.chunkzero.chunk.multistom.internal.ComponentRegistry; import com.chunkzero.chunk.multistom.internal.GameplayService; -import com.chunkzero.chunk.multistom.internal.SessionMethodRegistry; import com.chunkzero.chunk.runtime.ChunkProcess; import com.chunkzero.chunk.runtime.ChunkSessions; +import com.chunkzero.chunk.runtime.ComponentRegistry; +import com.chunkzero.chunk.runtime.SessionMethodRegistry; +import com.chunkzero.chunk.runtime.SessionRegistry; import net.minestom.server.MinecraftConstants; import net.minestom.server.ServerProcess; @@ -34,19 +34,16 @@ public final class ChunkMinestom implements AutoCloseable { private ChunkMinestom(ChunkProcess chunk, ServerProcess server) { this.chunk = chunk; this.server = server; - var factories = - AppRegistry.load(chunk.app(), Thread.currentThread().getContextClassLoader()); - components = ComponentRegistry.load(Thread.currentThread().getContextClassLoader()); + var loader = Thread.currentThread().getContextClassLoader(); + var types = SessionRegistry.load(chunk.app(), loader); + components = ComponentRegistry.load(loader); var manager = new SessionManager( server, ticks, - factories, + types, components, - SessionMethodRegistry.load( - chunk.app(), - factories.keySet(), - Thread.currentThread().getContextClassLoader())); + SessionMethodRegistry.load(chunk.app(), types.types(), loader)); server.setCompressionThreshold(0); server.connectionManager().setPlayerProvider(ManagedPlayer::new); sessions = chunk.host(manager); diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionManager.java b/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionManager.java index ca7016df..240250ce 100644 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionManager.java +++ b/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionManager.java @@ -3,11 +3,14 @@ import com.chunkzero.chunk.multistom.event.SessionCreateEvent; import com.chunkzero.chunk.multistom.event.SessionJoinEvent; import com.chunkzero.chunk.multistom.event.SessionLeaveEvent; -import com.chunkzero.chunk.multistom.internal.ComponentRegistry; +import com.chunkzero.chunk.runtime.ComponentRegistry; import com.chunkzero.chunk.runtime.Delivery; import com.chunkzero.chunk.runtime.SessionControl; import com.chunkzero.chunk.runtime.SessionHandler; import com.chunkzero.chunk.runtime.SessionMethod; +import com.chunkzero.chunk.runtime.SessionMethodRegistry; +import com.chunkzero.chunk.runtime.SessionProvider; +import com.chunkzero.chunk.runtime.SessionRegistry; import net.minestom.server.ServerProcess; import net.minestom.server.entity.Player; @@ -37,9 +40,9 @@ public final class SessionManager implements SessionHandler { private static final System.Logger LOG = System.getLogger(SessionManager.class.getName()); private final ServerProcess process; private final TickExecutor ticks; - private final Map factories; + private final SessionRegistry types; private final ComponentRegistry components; - private final Map> methods; + private final SessionMethodRegistry methods; private final Map sessions = new ConcurrentHashMap<>(); SessionManager( @@ -47,35 +50,38 @@ public final class SessionManager implements SessionHandler { this( process, ticks, - factories.entrySet().stream() - .collect( - Collectors.toMap( - Map.Entry::getKey, - entry -> - new SessionRegistration( - entry.getKey(), entry.getValue()))), - Map.of()); + new SessionRegistry( + factories.entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + entry -> provider(entry.getValue())))), + new SessionMethodRegistry(List.of(), List.of())); } SessionManager( ServerProcess process, TickExecutor ticks, - Map factories, - Map> methods) { - this(process, ticks, factories, new ComponentRegistry(List.of()), methods); + SessionRegistry types, + SessionMethodRegistry methods) { + this(process, ticks, types, new ComponentRegistry(List.of()), methods); } SessionManager( ServerProcess process, TickExecutor ticks, - Map factories, + SessionRegistry types, ComponentRegistry components, - Map> methods) { + SessionMethodRegistry methods) { this.process = process; this.ticks = ticks; - this.factories = Map.copyOf(factories); + this.types = types; this.components = components; - this.methods = Map.copyOf(methods); + this.methods = methods; + } + + private static SessionProvider provider(Supplier factory) { + return factory::get; } public TickExecutor getTicks() { @@ -90,15 +96,18 @@ public ServerProcess getProcess() { public void create(SessionControl control) { ticks.submit( () -> { - var registration = factories.get(control.type()); - if (registration == null) - throw new IllegalArgumentException("Unknown session type"); - var managed = - new ManagedSession( - control, - registration.create( - control.capacity(), - control.configurationJson())); + var created = + types.create( + control.type(), + control.capacity(), + control.configurationJson()); + if (!(created instanceof Session session)) + throw new IllegalArgumentException( + "Session type " + + control.type() + + " must create a " + + Session.class.getName()); + var managed = new ManagedSession(control, session); sessions.put(control.id(), managed); managed.start(); return null; @@ -128,8 +137,7 @@ public void finish(SessionControl control) { @Override public CompletionStage method(SessionControl control, SessionMethod call) { - var binding = methods.get(control.type() + "/" + call.name()); - if (binding == null) + if (!methods.declares(control.type(), call.name())) return CompletableFuture.failedFuture( new IllegalArgumentException("Undeclared session method")); return ticks.submit( @@ -140,7 +148,8 @@ public CompletionStage method(SessionControl control, SessionMethod call if (!call.start()) return null; if (managed == null || !control.isReady()) throw new IllegalStateException("Session unavailable"); - return binding.invoke(managed.behavior, call.argumentsJson()); + return methods.invoke( + control.type(), call.name(), managed.behavior, call.argumentsJson()); }); } diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionMethodBinding.java b/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionMethodBinding.java deleted file mode 100644 index 911ff8e9..00000000 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionMethodBinding.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.chunkzero.chunk.multistom; - -import com.chunkzero.chunk.backend.api.SessionMethodRef; - -import java.util.Objects; -import java.util.function.BiFunction; - -/** - * A generated direct call. The dispatcher must check authority and lifetime and enter the tick - * thread. - */ -public final class SessionMethodBinding { - private final SessionMethodRef reference; - private final BiFunction implementation; - - public SessionMethodBinding( - SessionMethodRef reference, BiFunction implementation) { - this.reference = Objects.requireNonNull(reference); - this.implementation = Objects.requireNonNull(implementation); - } - - public SessionMethodRef reference() { - return reference; - } - - /** Validates JSON on both sides of the synchronous gameplay call. */ - public String invoke(Session session, String arguments) { - var args = reference.arguments().read(arguments); - return reference - .result() - .write(implementation.apply(Objects.requireNonNull(session), args)); - } -} diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionProvider.java b/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionProvider.java deleted file mode 100644 index 65dbf763..00000000 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionProvider.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.chunkzero.chunk.multistom; - -/** One app-owned service provider creates fresh session state for each instance of that app. */ -@FunctionalInterface -public interface SessionProvider { - Session create(); -} diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionRegistration.java b/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionRegistration.java deleted file mode 100644 index c7441ce4..00000000 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionRegistration.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.chunkzero.chunk.multistom; - -import com.chunkzero.chunk.backend.api.BackendJson; - -import org.jetbrains.annotations.ApiStatus; - -import java.util.Objects; -import java.util.function.BiFunction; -import java.util.function.Supplier; - -/** App identity belongs to registration, independently of the session instance and routing key. */ -@ApiStatus.Internal -public final class SessionRegistration { - private final String appId; - private final BiFunction factory; - - public SessionRegistration(String appId, Supplier factory) { - this( - appId, - (capacity, json) -> { - requireEmptyConfiguration(json); - return factory.get(); - }); - Objects.requireNonNull(factory); - } - - private SessionRegistration(String appId, BiFunction factory) { - this.appId = Objects.requireNonNull(appId); - this.factory = Objects.requireNonNull(factory); - if (!appId.matches("[A-Za-z_][A-Za-z0-9_]{0,127}")) - throw new IllegalArgumentException("Invalid app ID: " + appId); - } - - public static SessionRegistration provider(String appId, Supplier provider) { - return new SessionRegistration( - appId, - (capacity, json) -> { - var instance = provider.get(); - if (instance instanceof ConfiguredSessionProvider configured) - return configured(configured, capacity, json); - requireEmptyConfiguration(json); - return instance.create(); - }); - } - - Session create() { - return create(1, "{}"); - } - - Session create(int capacity, String configuration) { - return Objects.requireNonNull( - factory.apply(capacity, configuration), - "Session provider returned null for " + appId); - } - - private static Session configured( - ConfiguredSessionProvider provider, int capacity, String json) { - var config = provider.configurationType().read(json); - return provider.create(new SessionCreation<>(capacity, config)); - } - - private static void requireEmptyConfiguration(String json) { - var config = BackendJson.mapper().readTree(json); - if (!config.isObject() || !config.isEmpty()) - throw new IllegalArgumentException( - "Session provider does not declare creation configuration"); - } -} diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionScope.java b/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionScope.java index cfe21b37..d7922387 100644 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionScope.java +++ b/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionScope.java @@ -5,7 +5,8 @@ import com.chunkzero.chunk.backend.client.OperationId; import com.chunkzero.chunk.multistom.event.SessionDestroyEvent; import com.chunkzero.chunk.multistom.event.SessionEvent; -import com.chunkzero.chunk.multistom.internal.ComponentRegistry; +import com.chunkzero.chunk.runtime.Component; +import com.chunkzero.chunk.runtime.ComponentRegistry; import com.chunkzero.chunk.runtime.MoveResult; import net.minestom.server.ServerProcess; @@ -48,8 +49,9 @@ /** * The gameplay isolate of one session within the shared server process. It owns the session's * instances with their entities and schedulers, its event node, scheduler, and closeable resources - * until disposal. It is not a memory or failure boundary. + * until disposal. It is not a memory or failure boundary. Session components may depend on it. */ +@Component.Supplied public final class SessionScope { private final ServerProcess process; @@ -227,10 +229,19 @@ public T resource(Class type, Supplier factory) * Closeable results are owned automatically, including rollback of a failed factory graph. */ public T component(Class type) { - return resource(ComponentRegistry.SessionComponents.class, () -> components.session(this)) + return resource( + ComponentRegistry.SessionComponents.class, + () -> components.session(supplied())) .get(type); } + private Map, Object> supplied() { + var supplied = new HashMap, Object>(); + supplied.put(SessionScope.class, this); + if (backend != null) supplied.put(BackendSession.class, backend); + return supplied; + } + void releasePlayer(Player player) throws Exception { checkThread(); var owned = playerResources.remove(player); diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/AppRegistry.java b/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/AppRegistry.java deleted file mode 100644 index de1d9564..00000000 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/AppRegistry.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.chunkzero.chunk.multistom.internal; - -import com.chunkzero.chunk.multistom.SessionProvider; -import com.chunkzero.chunk.multistom.SessionRegistration; -import com.chunkzero.chunk.runtime.SessionType; - -import org.jetbrains.annotations.ApiStatus; - -import java.util.Map; -import java.util.ServiceConfigurationError; -import java.util.ServiceLoader; -import java.util.TreeMap; - -/** Resolves local session factories and binds their backend caller identity. */ -@ApiStatus.Internal -public final class AppRegistry { - private AppRegistry() {} - - public static Map load(String app, ClassLoader loader) { - var factories = new TreeMap(); - try { - for (var provider : - ServiceLoader.load(SessionProvider.class, loader).stream().toList()) { - var declaration = provider.type().getAnnotation(SessionType.class); - if (declaration == null - || !declaration.value().matches("[A-Za-z_][A-Za-z0-9_]{0,127}")) - throw new IllegalArgumentException( - "Session provider requires a valid @SessionType"); - var key = app + "/" + declaration.value(); - if (factories.put(key, SessionRegistration.provider(app, provider::get)) != null) - throw new IllegalArgumentException("Duplicate session type: " + key); - } - } catch (ServiceConfigurationError error) { - throw new IllegalArgumentException("Invalid session provider", error); - } - if (factories.isEmpty() || factories.size() > 128) - throw new IllegalArgumentException("App requires 1–128 session factories"); - return Map.copyOf(factories); - } -} diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/SessionMethodRegistry.java b/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/SessionMethodRegistry.java deleted file mode 100644 index 2ce6e7bb..00000000 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/SessionMethodRegistry.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.chunkzero.chunk.multistom.internal; - -import com.chunkzero.chunk.multistom.SessionMethodBinding; -import com.chunkzero.chunk.multistom.SessionMethodProvider; - -import org.jetbrains.annotations.ApiStatus; - -import java.util.Collection; -import java.util.Map; -import java.util.ServiceLoader; -import java.util.TreeMap; - -/** Loads only the build-generated, app-owned method provider registrations. */ -@ApiStatus.Internal -public final class SessionMethodRegistry { - private SessionMethodRegistry() {} - - public static Map> load( - String app, Collection sessions, ClassLoader loader) { - var methods = new TreeMap>(); - for (var provider : ServiceLoader.load(SessionMethodProvider.class, loader)) { - for (var binding : provider.methods()) { - var ref = binding.reference(); - var session = app + "/" + ref.session(); - if (!ref.app().equals(app) - || !sessions.contains(session) - || !ref.name().matches("[A-Za-z_][A-Za-z0-9_]{0,127}")) { - throw new IllegalArgumentException("Foreign or invalid session method binding"); - } - if (methods.put(session + "/" + ref.name(), binding) != null - || methods.size() > 256) { - throw new IllegalArgumentException( - "Duplicate or excessive session method bindings"); - } - } - } - return Map.copyOf(methods); - } -} diff --git a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/ComponentRegistryTest.java b/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/ComponentRegistryTest.java index 0fcbe79d..8a50686e 100644 --- a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/ComponentRegistryTest.java +++ b/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/ComponentRegistryTest.java @@ -6,8 +6,9 @@ import static org.junit.jupiter.api.Assertions.*; import com.chunkzero.chunk.backend.client.BackendSession; -import com.chunkzero.chunk.multistom.internal.ComponentRegistry; import com.chunkzero.chunk.runtime.Component; +import com.chunkzero.chunk.runtime.ComponentBinding; +import com.chunkzero.chunk.runtime.ComponentRegistry; import net.minestom.server.ServerProcess; diff --git a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionCreationTest.java b/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionCreationTest.java index d5c040e7..14be70df 100644 --- a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionCreationTest.java +++ b/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionCreationTest.java @@ -10,6 +10,11 @@ import com.chunkzero.chunk.multistom.bootstrap.FlatSession; import com.chunkzero.chunk.multistom.event.SessionCreateEvent; import com.chunkzero.chunk.runtime.ChunkSessions; +import com.chunkzero.chunk.runtime.ConfiguredSessionProvider; +import com.chunkzero.chunk.runtime.SessionCreation; +import com.chunkzero.chunk.runtime.SessionMethodRegistry; +import com.chunkzero.chunk.runtime.SessionProvider; +import com.chunkzero.chunk.runtime.SessionRegistry; import com.chunkzero.chunk.runtime.TestHosts; import com.google.protobuf.ByteString; @@ -47,7 +52,7 @@ void oneProviderReceivesIndependentSettingsAndRejectsChangesBeforeConstruction() var ticks = new TickExecutor(); var settings = new ArrayList>(); var provider = - new ConfiguredSessionProvider() { + new ConfiguredSessionProvider() { @Override public JsonType configurationType() { return JsonType.of(new TypeReference() {}, Objects::requireNonNull); @@ -63,12 +68,13 @@ public Session create(SessionCreation creation) { new SessionManager( process, ticks, - Map.of( - "arena/default", - SessionRegistration.provider("arena", () -> provider), - "arena/legacy", - new SessionRegistration("arena", FlatSession::new)), - Map.of()); + new SessionRegistry( + Map.of( + "arena/default", + provider, + "arena/legacy", + (SessionProvider) FlatSession::new)), + new SessionMethodRegistry(List.of(), List.of())); var host = TestHosts.detached(manager); try { var first = session(16, "{\"map\":\"forest\",\"mode\":\"solo\"}"); @@ -159,24 +165,37 @@ void failedCreationAndUnknownFinishAreReported() { new SessionManager( process, ticks, - Map.of( - "arena/default", - new SessionRegistration( - "arena", - () -> { - throw new IllegalStateException("provider failed"); - })), - Map.of()); + new SessionRegistry( + Map.of( + "arena/default", + (SessionProvider) + () -> { + throw new IllegalStateException( + "provider failed"); + }, + "arena/foreign", + (SessionProvider) () -> "not a session")), + new SessionMethodRegistry(List.of(), List.of())); var host = TestHosts.detached(manager); try { var failed = TestHosts.create(host, "failed", session(16, "{}")); + var foreign = + TestHosts.create( + host, + "foreign", + session(16, "{}").toBuilder().setSessionType("arena/foreign").build()); var unknown = TestHosts.finish(host, "unknown", session(16, "{}")); flush(ticks); assertTrue(failed.isCompletedExceptionally()); + var error = assertThrows(CompletionException.class, foreign::join); + assertEquals( + "Session type arena/foreign must create a " + Session.class.getName(), + error.getCause().getMessage()); assertEquals(JvmSessionPhase.JVM_SESSION_PHASE_ENDED, unknown.join().getPhase()); assertEquals( Map.of( "failed", JvmSessionPhase.JVM_SESSION_PHASE_FAILED, + "foreign", JvmSessionPhase.JVM_SESSION_PHASE_FAILED, "unknown", JvmSessionPhase.JVM_SESSION_PHASE_ENDED), phases(host)); } finally { @@ -221,14 +240,7 @@ public CompletionStage onCreate(SessionScope scope) { }; var manager = new SessionManager( - process, - ticks, - Map.of( - "arena/default", - new SessionRegistration("arena", failing), - "arena/leaky", - new SessionRegistration("arena", leaking)), - Map.of()); + process, ticks, Map.of("arena/default", failing, "arena/leaky", leaking)); var host = TestHosts.detached(manager); try { var leaky = session(16, "{}").toBuilder().setSessionType("arena/leaky").build(); @@ -260,20 +272,17 @@ void sessionWhoseCreationOutlastsTheDeadlineFailsAndIgnoresLaterCompletion() { new SessionManager( process, ticks, - Map.of( + Map.>of( "arena/default", - new SessionRegistration( - "arena", - () -> - new Session() { - @Override - public CompletionStage onCreate( - SessionScope scope) { - instances.add(scope.createInstance()); - return creation; - } - })), - Map.of()); + () -> + new Session() { + @Override + public CompletionStage onCreate( + SessionScope scope) { + instances.add(scope.createInstance()); + return creation; + } + })); var clock = new AtomicLong(); var host = TestHosts.withDeadline(manager, Duration.ofMillis(50), clock::get); try { @@ -306,8 +315,7 @@ void creationCompletingAfterTheDeadlinePublishesNoCreateEvent() { new SessionManager( process, ticks, - Map.of("arena/default", new SessionRegistration("arena", FlatSession::new)), - Map.of()); + Map.>of("arena/default", FlatSession::new)); var clock = new AtomicLong(); var host = TestHosts.withDeadline(manager, Duration.ofMillis(50), clock::get); var events = new ArrayList(); @@ -336,8 +344,7 @@ void forgottenSessionsLeaveRoomForNewOnes() { new SessionManager( process, ticks, - Map.of("arena/default", new SessionRegistration("arena", FlatSession::new)), - Map.of()); + Map.>of("arena/default", FlatSession::new)); var host = TestHosts.detached(manager); try { for (var index = 0; index < 300; index++) { diff --git a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionMethodBindingTest.java b/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionMethodBindingTest.java deleted file mode 100644 index 46c6e112..00000000 --- a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionMethodBindingTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.chunkzero.chunk.multistom; - -import static org.junit.jupiter.api.Assertions.*; - -import com.chunkzero.chunk.backend.api.BackendValues; -import com.chunkzero.chunk.backend.api.JsonType; -import com.chunkzero.chunk.backend.api.SessionMethodRef; - -import org.junit.jupiter.api.Test; - -import tools.jackson.core.type.TypeReference; - -import java.util.concurrent.atomic.AtomicInteger; - -class SessionMethodBindingTest { - @Test - void validatesArgumentsBeforeCallingGameplayAndResultsBeforeReturning() { - var text = JsonType.of(new TypeReference() {}, BackendValues::checkString); - var number = JsonType.of(new TypeReference() {}, BackendValues::checkInteger); - var ref = new SessionMethodRef<>("lobby", "default", "length", text, number); - var calls = new AtomicInteger(); - var session = new Session() {}; - var binding = - new SessionMethodBinding<>( - ref, - (target, args) -> { - assertSame(session, target); - calls.incrementAndGet(); - return (long) args.length(); - }); - assertEquals("5", binding.invoke(session, "\"hello\"")); - assertThrows(RuntimeException.class, () -> binding.invoke(session, "42")); - assertEquals(1, calls.get()); - var invalid = new SessionMethodBinding<>(ref, (target, args) -> Long.MAX_VALUE); - assertThrows(RuntimeException.class, () -> invalid.invoke(session, "\"hello\"")); - } -} diff --git a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionMethodTest.java b/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionMethodTest.java index a622022d..d82af9c4 100644 --- a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionMethodTest.java +++ b/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/SessionMethodTest.java @@ -10,6 +10,10 @@ import com.chunkzero.chunk.runtime.SessionControl; import com.chunkzero.chunk.runtime.SessionHandler; import com.chunkzero.chunk.runtime.SessionMethod; +import com.chunkzero.chunk.runtime.SessionMethodBinding; +import com.chunkzero.chunk.runtime.SessionMethodRegistry; +import com.chunkzero.chunk.runtime.SessionProvider; +import com.chunkzero.chunk.runtime.SessionRegistry; import com.chunkzero.chunk.runtime.TestHosts; import net.minestom.server.ServerProcess; @@ -35,13 +39,15 @@ void declaredMethodsRunOnTheTickThreadWhileTheSessionIsReady() { var binding = new SessionMethodBinding<>( new SessionMethodRef<>("lobby", "default", "record", args, number), - (session, input) -> ((Game) session).record(input)); + Game.class, + (session, input) -> session.record(input)); var manager = new SessionManager( process, ticks, - Map.of("lobby/default", new SessionRegistration("lobby", () -> game)), - Map.of("lobby/default/record", binding)); + new SessionRegistry( + Map.of("lobby/default", (SessionProvider) () -> game)), + new SessionMethodRegistry(List.of("lobby/default"), List.of(binding))); var controls = new ArrayList(); var host = TestHosts.detached( diff --git a/jvm/multistom/src/test/kotlin/com/chunkzero/chunk/multistom/SessionMethodDisconnectTest.kt b/jvm/multistom/src/test/kotlin/com/chunkzero/chunk/multistom/SessionMethodDisconnectTest.kt index 6db19da7..ea66b5db 100644 --- a/jvm/multistom/src/test/kotlin/com/chunkzero/chunk/multistom/SessionMethodDisconnectTest.kt +++ b/jvm/multistom/src/test/kotlin/com/chunkzero/chunk/multistom/SessionMethodDisconnectTest.kt @@ -14,6 +14,10 @@ import com.chunkzero.chunk.backend.api.JsonType import com.chunkzero.chunk.backend.api.SessionMethodRef import com.chunkzero.chunk.multistom.bootstrap.FlatSession import com.chunkzero.chunk.multistom.internal.GameplayService +import com.chunkzero.chunk.runtime.SessionMethodBinding +import com.chunkzero.chunk.runtime.SessionMethodRegistry +import com.chunkzero.chunk.runtime.SessionProvider +import com.chunkzero.chunk.runtime.SessionRegistry import com.chunkzero.chunk.runtime.TestHosts import com.google.protobuf.ByteString import net.minestom.server.ServerProcess @@ -29,7 +33,6 @@ import java.util.UUID import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger -import java.util.function.Supplier class SessionMethodDisconnectTest { @Test @@ -43,13 +46,14 @@ class SessionMethodDisconnectTest { val binding = SessionMethodBinding( SessionMethodRef("lobby", "default", "record", number, number), + FlatSession::class.java, ) { _, input -> runs.incrementAndGet().toLong() + input } val manager = SessionManager( minecraft, ticks, - mapOf("lobby/default" to SessionRegistration("lobby", Supplier { FlatSession() })), - mapOf("lobby/default/record" to binding), + SessionRegistry(mapOf("lobby/default" to SessionProvider { FlatSession() })), + SessionMethodRegistry(listOf("lobby/default"), listOf(binding)), ) val core = FakeCore() val host = TestHosts.linked(manager, core, System::nanoTime) diff --git a/jvm/runtime/README.md b/jvm/runtime/README.md index f5aa5240..2ac08ecc 100644 --- a/jvm/runtime/README.md +++ b/jvm/runtime/README.md @@ -9,7 +9,7 @@ Apps use it together with an engine adapter: | Module | Artifact | Contents | | ----------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------- | -| `jvm/runtime` | `com.chunkzero.chunk:runtime` | `ChunkProcess`, `@SessionType`, `@Component` | +| `jvm/runtime` | `com.chunkzero.chunk:runtime` | `ChunkProcess`, the session, method and component contracts | | [`jvm/multistom`](../multistom/README.md) | `com.chunkzero.chunk:multistom` | The session API and its runtime on the multistom Minestom fork | | `jvm/multistom-kotlin` | `com.chunkzero.chunk:multistom-kotlin` | Coroutine adapters, covered in the multistom README | | [`jvm/minestom`](../minestom/README.md) | `com.chunkzero.chunk:minestom` | Login handling for apps running their own sessions on upstream Minestom | @@ -99,6 +99,18 @@ try (var chunk = ChunkProcess.connect()) { `SessionHandler.method`, which no session declares by default; a handler calls `SessionMethod.start()` right before running the method's effects and skips them if it returns false. +The app build's contracts are engine-neutral, so a handler can use them too: + +- `SessionRegistry.load(app, loader)` loads the app's `SessionProvider` services by qualified type (`app/type`). + `create(type, capacity, configurationJson)` decodes the creation config for a `ConfiguredSessionProvider` and requires + an empty one otherwise. `S` is whatever the handler accepts; check it before using the session. +- `SessionMethodRegistry.load(app, registry.types(), loader)` loads the generated `SessionMethodBinding`s. + `invoke(type, method, session, argumentsJson)` validates JSON both ways and calls the session's implementation; call + it after `SessionMethod.start()`. +- `ComponentRegistry.load(loader)` loads the generated `@Component` factories. `session(supplied)` opens one session's + components, given `BackendSession` and any types marked `@Component.Supplied`; close it when the session ends, and + close the registry when the process stops. + [`minestom`](../minestom/README.md) handles the login side for upstream Minestom. ## Launch configuration diff --git a/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/Component.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/Component.java index ac45c9f4..bb6e44cd 100644 --- a/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/Component.java +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/Component.java @@ -18,4 +18,12 @@ enum Scope { PROCESS, SESSION } + + /** + * Marks a type the host supplies to session-scoped component factories, such as a handle to the + * session. Factories may take it as a dependency; no factory may provide it. + */ + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @interface Supplied {} } diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ComponentBinding.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentBinding.java similarity index 88% rename from jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ComponentBinding.java rename to jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentBinding.java index d1b7f6b0..ab8f2c84 100644 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ComponentBinding.java +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentBinding.java @@ -1,6 +1,4 @@ -package com.chunkzero.chunk.multistom; - -import com.chunkzero.chunk.runtime.Component; +package com.chunkzero.chunk.runtime; import org.jetbrains.annotations.ApiStatus; diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ComponentProvider.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentProvider.java similarity index 87% rename from jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ComponentProvider.java rename to jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentProvider.java index 8f73cf8d..8dd41ed6 100644 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ComponentProvider.java +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentProvider.java @@ -1,4 +1,4 @@ -package com.chunkzero.chunk.multistom; +package com.chunkzero.chunk.runtime; import org.jetbrains.annotations.ApiStatus; diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/ComponentRegistry.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentRegistry.java similarity index 79% rename from jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/ComponentRegistry.java rename to jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentRegistry.java index 61749758..8d54d977 100644 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/ComponentRegistry.java +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentRegistry.java @@ -1,13 +1,6 @@ -package com.chunkzero.chunk.multistom.internal; +package com.chunkzero.chunk.runtime; import com.chunkzero.chunk.backend.client.BackendSession; -import com.chunkzero.chunk.multistom.ComponentBinding; -import com.chunkzero.chunk.multistom.ComponentProvider; -import com.chunkzero.chunk.multistom.SessionScope; -import com.chunkzero.chunk.runtime.Component; - -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.Collection; @@ -19,11 +12,14 @@ import java.util.Objects; import java.util.ServiceLoader; -/** Owns generated factory instances for one app process and its independent sessions. */ -@ApiStatus.Internal +/** + * Owns generated factory instances for one app process and its independent sessions. Session + * factories may depend on {@link BackendSession} and {@link Component.Supplied} types, which the + * host supplies when it opens a session's components. + */ public final class ComponentRegistry implements AutoCloseable { private final Map, ComponentBinding> bindings = new HashMap<>(); - private final Store process = new Store(null); + private final Store process = new Store(Map.of()); private final List sessions = new ArrayList<>(); private final Map owned = new IdentityHashMap<>(); private boolean closed; @@ -53,6 +49,7 @@ public ComponentRegistry(Collection> declarations) { } } + /** Loads the build-generated {@link ComponentProvider} services. */ public static ComponentRegistry load(ClassLoader loader) { var declarations = new ArrayList>(); for (var provider : ServiceLoader.load(ComponentProvider.class, loader)) { @@ -64,20 +61,34 @@ public static ComponentRegistry load(ClassLoader loader) { return new ComponentRegistry(declarations); } - public synchronized SessionComponents session(SessionScope scope) { + /** + * Opens one session's components, given the values the host supplies by type. + * + * @throws IllegalArgumentException if a key is neither {@link BackendSession} nor a {@link + * Component.Supplied} type, or its value is not an instance of it + */ + public synchronized SessionComponents session(Map, Object> supplied) { + for (var entry : supplied.entrySet()) { + if (!builtin(entry.getKey()) || !entry.getKey().isInstance(entry.getValue())) + throw new IllegalArgumentException( + "Invalid supplied component: " + entry.getKey().getName()); + } checkActive(); checkIdle(); - var components = new SessionComponents(new Store(scope)); + var components = new SessionComponents(new Store(Map.copyOf(supplied))); sessions.add(components); return components; } private Object resolve(Store session, Class type, List created) throws Exception { - if (type == SessionScope.class) return Objects.requireNonNull(session.scope); - if (type == BackendSession.class) - return Objects.requireNonNull( - Objects.requireNonNull(session.scope).getBackend(), - "Session backend unavailable"); + if (builtin(type)) { + var supplied = session.supplied.get(type); + if (supplied != null) return supplied; + throw new IllegalStateException( + type == BackendSession.class + ? "Session backend unavailable" + : "Supplied component unavailable: " + type.getName()); + } var binding = bindings.get(type); if (binding == null) throw new IllegalArgumentException("Missing component: " + type.getName()); @@ -90,7 +101,7 @@ private Object resolve(Store session, Class type, List created) throws var value = Objects.requireNonNull( binding.factory().create(dependencies), "Component factory returned null"); - if (value instanceof SessionScope || value instanceof BackendSession) + if (value instanceof BackendSession || builtin(value.getClass())) throw new IllegalStateException( "Component factory returned a borrowed session capability"); if (owned.containsKey(value)) @@ -109,7 +120,7 @@ private Object resolve(Store session, Class type, List created) throws } private static boolean builtin(Class type) { - return type == SessionScope.class || type == BackendSession.class; + return type == BackendSession.class || type.isAnnotationPresent(Component.Supplied.class); } private void checkActive() { @@ -160,7 +171,7 @@ private static void closeValue(Object value, Throwable failure) { } } - /** SessionScope owns this cache; only the session tick thread may access its components. */ + /** One session's components; only the session's thread may access them. */ public final class SessionComponents implements AutoCloseable { private final Store store; @@ -168,6 +179,7 @@ private SessionComponents(Store store) { this.store = store; } + /** Resolves an exact declared component type, creating it and its dependencies once. */ public T get(Class type) { synchronized (ComponentRegistry.this) { checkActive(); @@ -209,12 +221,12 @@ public void close() { } private static final class Store { - final @Nullable SessionScope scope; + final Map, Object> supplied; final Map, Entry> values = new LinkedHashMap<>(); boolean closed; - Store(@Nullable SessionScope scope) { - this.scope = scope; + Store(Map, Object> supplied) { + this.supplied = supplied; } } diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ConfiguredSessionProvider.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ConfiguredSessionProvider.java similarity index 61% rename from jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ConfiguredSessionProvider.java rename to jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ConfiguredSessionProvider.java index 1264b218..0361195c 100644 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/ConfiguredSessionProvider.java +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ConfiguredSessionProvider.java @@ -1,15 +1,15 @@ -package com.chunkzero.chunk.multistom; +package com.chunkzero.chunk.runtime; import com.chunkzero.chunk.backend.api.JsonType; /** Implement an app's generated provider interface to receive its validated creation config. */ -public interface ConfiguredSessionProvider extends SessionProvider { - Session create(SessionCreation creation); +public interface ConfiguredSessionProvider extends SessionProvider { + S create(SessionCreation creation); JsonType configurationType(); @Override - default Session create() { + default S create() { throw new IllegalStateException("Configured session requires creation settings"); } } diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionCreation.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionCreation.java similarity index 90% rename from jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionCreation.java rename to jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionCreation.java index dcac0066..fe5424b2 100644 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionCreation.java +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionCreation.java @@ -1,4 +1,4 @@ -package com.chunkzero.chunk.multistom; +package com.chunkzero.chunk.runtime; import java.util.Objects; diff --git a/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionMethodBinding.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionMethodBinding.java new file mode 100644 index 00000000..395dbac9 --- /dev/null +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionMethodBinding.java @@ -0,0 +1,44 @@ +package com.chunkzero.chunk.runtime; + +import com.chunkzero.chunk.backend.api.SessionMethodRef; + +import java.util.Objects; +import java.util.function.BiFunction; + +/** + * A generated direct call on sessions of type {@code S}. The dispatcher must check authority and + * lifetime and enter the session's thread. + */ +public final class SessionMethodBinding { + private final SessionMethodRef reference; + private final Class session; + private final BiFunction implementation; + + public SessionMethodBinding( + SessionMethodRef reference, + Class session, + BiFunction implementation) { + this.reference = Objects.requireNonNull(reference); + this.session = Objects.requireNonNull(session); + this.implementation = Objects.requireNonNull(implementation); + } + + public SessionMethodRef reference() { + return reference; + } + + /** The session class the method is declared on. */ + public Class session() { + return session; + } + + /** + * Validates JSON on both sides of the synchronous gameplay call. Throws {@link + * ClassCastException} if {@code target} is not an {@code S}. + */ + public String invoke(Object target, String argumentsJson) { + var typed = session.cast(Objects.requireNonNull(target)); + var args = reference.arguments().read(argumentsJson); + return reference.result().write(implementation.apply(typed, args)); + } +} diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionMethodProvider.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionMethodProvider.java similarity index 54% rename from jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionMethodProvider.java rename to jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionMethodProvider.java index afcc72f2..3c27b83c 100644 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/SessionMethodProvider.java +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionMethodProvider.java @@ -1,8 +1,8 @@ -package com.chunkzero.chunk.multistom; +package com.chunkzero.chunk.runtime; import java.util.Collection; -/** Generated app-local method bindings, loaded alongside its session factories. */ +/** Generated app-local method bindings, loaded alongside its session providers. */ public interface SessionMethodProvider { - Collection> methods(); + Collection> methods(); } diff --git a/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionMethodRegistry.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionMethodRegistry.java new file mode 100644 index 00000000..5fe116a1 --- /dev/null +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionMethodRegistry.java @@ -0,0 +1,64 @@ +package com.chunkzero.chunk.runtime; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.TreeMap; + +/** An app's session methods by qualified session type ({@code app/type}) and method name. */ +public final class SessionMethodRegistry { + private final Map> methods; + + /** + * Registers {@code bindings}, each of which must belong to one of the qualified session {@code + * types}. + */ + public SessionMethodRegistry( + Collection types, + Collection> bindings) { + var methods = new TreeMap>(); + for (var binding : bindings) { + var ref = binding.reference(); + var type = ref.app() + "/" + ref.session(); + if (!types.contains(type) || !ref.name().matches("[A-Za-z_][A-Za-z0-9_]{0,127}")) + throw new IllegalArgumentException("Foreign or invalid session method binding"); + if (methods.put(type + "/" + ref.name(), binding) != null || methods.size() > 256) + throw new IllegalArgumentException( + "Duplicate or excessive session method bindings"); + } + this.methods = Map.copyOf(methods); + } + + /** Loads the build-generated {@link SessionMethodProvider} services of {@code app}. */ + public static SessionMethodRegistry load( + String app, Collection types, ClassLoader loader) { + var bindings = new ArrayList>(); + for (var provider : ServiceLoader.load(SessionMethodProvider.class, loader)) { + for (var binding : provider.methods()) { + if (!binding.reference().app().equals(app)) + throw new IllegalArgumentException("Foreign or invalid session method binding"); + bindings.add(binding); + } + } + return new SessionMethodRegistry(types, bindings); + } + + /** Whether sessions of {@code type} declare {@code method}. */ + public boolean declares(String type, String method) { + return methods.containsKey(type + "/" + method); + } + + /** + * Calls {@code method} on {@code session}, a session of {@code type}, and returns its result as + * JSON. + * + * @throws IllegalArgumentException if the method is undeclared + * @throws ClassCastException if {@code session} is not of the method's session class + */ + public String invoke(String type, String method, Object session, String argumentsJson) { + var binding = methods.get(type + "/" + method); + if (binding == null) throw new IllegalArgumentException("Undeclared session method"); + return binding.invoke(session, argumentsJson); + } +} diff --git a/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionProvider.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionProvider.java new file mode 100644 index 00000000..8096725f --- /dev/null +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionProvider.java @@ -0,0 +1,10 @@ +package com.chunkzero.chunk.runtime; + +/** + * Creates a fresh session of one {@link SessionType}, registered as an app-owned service. The host + * decides what type of session {@code S} it accepts. + */ +@FunctionalInterface +public interface SessionProvider { + S create(); +} diff --git a/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionRegistry.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionRegistry.java new file mode 100644 index 00000000..d4b5a9be --- /dev/null +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/SessionRegistry.java @@ -0,0 +1,108 @@ +package com.chunkzero.chunk.runtime; + +import com.chunkzero.chunk.backend.api.BackendJson; + +import java.util.Map; +import java.util.Objects; +import java.util.ServiceConfigurationError; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Supplier; + +/** + * An app's session providers by qualified session type ({@code app/type}). Creating a session + * decodes its creation config for {@link ConfiguredSessionProvider}s and requires an empty one for + * plain providers. + */ +public final class SessionRegistry { + private static final String NAME = "[A-Za-z_][A-Za-z0-9_]{0,127}"; + + @SuppressWarnings("unchecked") + private static final Class> SERVICE = + (Class>) (Class) SessionProvider.class; + + private final Map>> providers; + + /** Registers {@code providers} by qualified session type. */ + public SessionRegistry(Map> providers) { + this(copy(providers)); + } + + private SessionRegistry(TreeMap>> providers) { + if (providers.isEmpty() || providers.size() > 128) + throw new IllegalArgumentException("App requires 1–128 session types"); + this.providers = Map.copyOf(providers); + } + + /** + * Loads the {@link SessionProvider} services of {@code app}, each declaring its {@link + * SessionType}. Every created session gets a fresh provider instance. + */ + public static SessionRegistry load(String app, ClassLoader loader) { + if (!app.matches(NAME)) throw new IllegalArgumentException("Invalid app ID: " + app); + var providers = new TreeMap>>(); + try { + for (var provider : ServiceLoader.load(SERVICE, loader).stream().toList()) { + var declaration = provider.type().getAnnotation(SessionType.class); + if (declaration == null || !declaration.value().matches(NAME)) + throw new IllegalArgumentException( + "Session provider requires a valid @SessionType"); + var key = app + "/" + declaration.value(); + if (providers.put(key, provider::get) != null) + throw new IllegalArgumentException("Duplicate session type: " + key); + } + } catch (ServiceConfigurationError error) { + throw new IllegalArgumentException("Invalid session provider", error); + } + return new SessionRegistry(providers); + } + + /** The qualified session types. */ + public Set types() { + return providers.keySet(); + } + + /** + * Creates a session of {@code type} for {@code capacity} players from its creation config. + * + * @throws IllegalArgumentException if the type is unknown or the config is invalid for it + */ + public Object create(String type, int capacity, String configurationJson) { + var provider = providers.get(type); + if (provider == null) throw new IllegalArgumentException("Unknown session type: " + type); + var instance = provider.get(); + Object session; + if (instance instanceof ConfiguredSessionProvider configured) { + session = configured(configured, capacity, configurationJson); + } else { + requireEmptyConfiguration(configurationJson); + session = instance.create(); + } + return Objects.requireNonNull(session, "Session provider returned null for " + type); + } + + private static Object configured( + ConfiguredSessionProvider provider, int capacity, String json) { + var config = provider.configurationType().read(json); + return provider.create(new SessionCreation<>(capacity, config)); + } + + private static void requireEmptyConfiguration(String json) { + var config = BackendJson.mapper().readTree(json); + if (!config.isObject() || !config.isEmpty()) + throw new IllegalArgumentException( + "Session provider does not declare creation configuration"); + } + + private static TreeMap>> copy( + Map> providers) { + var copy = new TreeMap>>(); + providers.forEach( + (type, provider) -> { + Objects.requireNonNull(provider); + copy.put(type, () -> provider); + }); + return copy; + } +} diff --git a/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/ComponentRegistryTest.java b/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/ComponentRegistryTest.java new file mode 100644 index 00000000..026cb9c7 --- /dev/null +++ b/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/ComponentRegistryTest.java @@ -0,0 +1,55 @@ +package com.chunkzero.chunk.runtime; + +import static org.junit.jupiter.api.Assertions.*; + +import com.chunkzero.chunk.backend.client.BackendSession; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class ComponentRegistryTest { + @Test + void sessionComponentsReceiveOnlyMarkedSuppliedTypes() { + var binding = + new ComponentBinding<>( + Greeting.class, + Component.Scope.SESSION, + List.of(Handle.class), + deps -> new Greeting((Handle) deps[0])); + try (var registry = new ComponentRegistry(List.of(binding))) { + var handle = new Handle(); + var components = registry.session(Map.of(Handle.class, handle)); + assertSame(handle, components.get(Greeting.class).handle()); + assertThrows( + IllegalArgumentException.class, + () -> registry.session(Map.of(String.class, "unmarked"))); + assertThrows( + IllegalArgumentException.class, + () -> registry.session(Map.of(Handle.class, "not a handle"))); + var missingBackend = new HashMap, Object>(); + missingBackend.put(BackendSession.class, null); + assertThrows(IllegalArgumentException.class, () -> registry.session(missingBackend)); + assertThrows( + IllegalStateException.class, + () -> registry.session(Map.of()).get(Greeting.class)); + } + assertThrows( + IllegalArgumentException.class, + () -> + new ComponentRegistry( + List.of( + new ComponentBinding<>( + Handle.class, + Component.Scope.SESSION, + List.of(), + deps -> new Handle())))); + } + + @Component.Supplied + private static final class Handle {} + + private record Greeting(Handle handle) {} +} diff --git a/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/SessionMethodBindingTest.java b/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/SessionMethodBindingTest.java new file mode 100644 index 00000000..54cd0d8a --- /dev/null +++ b/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/SessionMethodBindingTest.java @@ -0,0 +1,66 @@ +package com.chunkzero.chunk.runtime; + +import static org.junit.jupiter.api.Assertions.*; + +import com.chunkzero.chunk.backend.api.BackendValues; +import com.chunkzero.chunk.backend.api.JsonType; +import com.chunkzero.chunk.backend.api.SessionMethodRef; + +import org.junit.jupiter.api.Test; + +import tools.jackson.core.type.TypeReference; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +class SessionMethodBindingTest { + @Test + void validatesArgumentsBeforeCallingGameplayAndResultsBeforeReturning() { + var text = JsonType.of(new TypeReference() {}, BackendValues::checkString); + var number = JsonType.of(new TypeReference() {}, BackendValues::checkInteger); + var ref = new SessionMethodRef<>("lobby", "default", "length", text, number); + var calls = new AtomicInteger(); + var session = new Game(); + var binding = + new SessionMethodBinding<>( + ref, + Game.class, + (target, args) -> { + assertSame(session, target); + calls.incrementAndGet(); + return (long) args.length(); + }); + assertEquals("5", binding.invoke(session, "\"hello\"")); + assertThrows(RuntimeException.class, () -> binding.invoke(session, "42")); + assertEquals(1, calls.get()); + var invalid = new SessionMethodBinding<>(ref, Game.class, (target, args) -> Long.MAX_VALUE); + assertThrows(RuntimeException.class, () -> invalid.invoke(session, "\"hello\"")); + } + + @Test + void registryRejectsForeignBindingsAndSessionsOfAnotherClass() { + var text = JsonType.of(new TypeReference() {}, BackendValues::checkString); + var binding = + new SessionMethodBinding<>( + new SessionMethodRef<>("lobby", "default", "echo", text, text), + Game.class, + (target, args) -> args); + for (var types : List.of(Set.of("arena/default"), Set.of("default"), Set.of())) + assertThrows( + IllegalArgumentException.class, + () -> new SessionMethodRegistry(types, List.of(binding))); + var registry = new SessionMethodRegistry(Set.of("lobby/default"), List.of(binding)); + assertTrue(registry.declares("lobby/default", "echo")); + assertFalse(registry.declares("lobby/default", "missing")); + assertEquals("\"hi\"", registry.invoke("lobby/default", "echo", new Game(), "\"hi\"")); + assertThrows( + ClassCastException.class, + () -> registry.invoke("lobby/default", "echo", "not a game", "\"hi\"")); + assertThrows( + IllegalArgumentException.class, + () -> registry.invoke("lobby/default", "missing", new Game(), "\"hi\"")); + } + + private static final class Game {} +} diff --git a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/AppRegistryTest.java b/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/SessionRegistryTest.java similarity index 62% rename from jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/AppRegistryTest.java rename to jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/SessionRegistryTest.java index 533fcad0..ea03484c 100644 --- a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/AppRegistryTest.java +++ b/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/SessionRegistryTest.java @@ -1,13 +1,14 @@ -package com.chunkzero.chunk.multistom; +package com.chunkzero.chunk.runtime; import static org.junit.jupiter.api.Assertions.*; -import com.chunkzero.chunk.multistom.internal.AppRegistry; -import com.chunkzero.chunk.runtime.SessionType; +import com.chunkzero.chunk.backend.api.JsonType; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import tools.jackson.core.type.TypeReference; + import java.io.File; import java.io.IOException; import java.io.StringWriter; @@ -19,13 +20,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.TreeMap; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; import javax.tools.ToolProvider; -class AppRegistryTest { +class SessionRegistryTest { @TempDir Path directory; @Test @@ -33,13 +35,12 @@ void serviceRegistryCreatesIndependentSessions() throws Exception { var lobby = compile("lobby"); var jar = jar("lobby.jar", lobby.classes(), lobby.provider()); try (var loader = loader(jar)) { - var apps = AppRegistry.load("lobby", loader); - assertEquals(List.of("lobby/default"), new ArrayList<>(apps.keySet())); - var factory = apps.get("lobby/default"); - var first = factory.create(); + var registry = SessionRegistry.load("lobby", loader); + assertEquals(List.of("lobby/default"), new ArrayList<>(registry.types())); + var first = registry.create("lobby/default", 1, "{}"); assertEquals("lobby", first.toString()); - assertNotSame(first, factory.create()); - assertTrue(AppRegistry.load("renamed", loader).containsKey("renamed/default")); + assertNotSame(first, registry.create("lobby/default", 1, "{}")); + assertTrue(SessionRegistry.load("renamed", loader).types().contains("renamed/default")); } } @@ -48,11 +49,13 @@ void missingAndInvalidProvidersFailBeforeCreation() throws Exception { for (var provider : List.of("missing.Provider", "java.lang.String")) { try (var loader = loader(jar(provider + ".jar", Map.of(), provider))) { assertThrows( - IllegalArgumentException.class, () -> AppRegistry.load("lobby", loader)); + IllegalArgumentException.class, + () -> SessionRegistry.load("lobby", loader)); } } try (var loader = loader(jar("empty.jar", Map.of(), ""))) { - assertThrows(IllegalArgumentException.class, () -> AppRegistry.load("lobby", loader)); + assertThrows( + IllegalArgumentException.class, () -> SessionRegistry.load("lobby", loader)); } var first = compile("first"); var second = compile("second"); @@ -67,11 +70,54 @@ void missingAndInvalidProvidersFailBeforeCreation() throws Exception { var error = assertThrows( IllegalArgumentException.class, - () -> AppRegistry.load("lobby", loader)); + () -> SessionRegistry.load("lobby", loader)); assertTrue(error.getMessage().contains("Duplicate session type"), error.toString()); } } + @Test + void configuredProvidersDecodeTheirConfigAndPlainOnesRequireAnEmptyOne() { + var provider = + new ConfiguredSessionProvider>() { + @Override + public JsonType configurationType() { + return JsonType.of(new TypeReference() {}, Objects::requireNonNull); + } + + @Override + public SessionCreation create(SessionCreation creation) { + return creation; + } + }; + var registry = + new SessionRegistry( + Map.of( + "arena/koth", + provider, + "arena/plain", + (SessionProvider) () -> "plain")); + assertEquals( + new SessionCreation<>(16, new Config("forest")), + registry.create("arena/koth", 16, "{\"map\":\"forest\"}")); + for (var json : List.of("{}", "null", "{\"map\":1}", "{\"map\":\"a\",\"x\":1}")) + assertThrows(RuntimeException.class, () -> registry.create("arena/koth", 16, json)); + assertThrows( + IllegalArgumentException.class, + () -> registry.create("arena/koth", 0, "{\"map\":\"forest\"}")); + assertEquals("plain", registry.create("arena/plain", 16, "{}")); + for (var json : List.of("{\"map\":\"forest\"}", "[]", "null")) + assertThrows( + IllegalArgumentException.class, () -> registry.create("arena/plain", 16, json)); + assertThrows( + IllegalArgumentException.class, () -> registry.create("arena/missing", 16, "{}")); + } + + public record Config(String map) { + public Config { + Objects.requireNonNull(map); + } + } + private static URLClassLoader loader(Path... jars) throws IOException { var urls = new URL[jars.length]; for (var index = 0; index < jars.length; index++) urls[index] = jars[index].toUri().toURL(); @@ -85,12 +131,11 @@ private Fixture compile(String app) throws Exception { source, """ package fixtures.%s; - import com.chunkzero.chunk.multistom.Session; - import com.chunkzero.chunk.multistom.SessionProvider; + import com.chunkzero.chunk.runtime.SessionProvider; @com.chunkzero.chunk.runtime.SessionType("default") - public final class Provider implements SessionProvider { - public Session create() { - return new Session() { public String toString() { return "%s"; } }; + public final class Provider implements SessionProvider { + public Object create() { + return new Object() { public String toString() { return "%s"; } }; } } """ @@ -99,20 +144,13 @@ public Session create() { var compiler = ToolProvider.getSystemJavaCompiler(); assertNotNull(compiler, "App registry fixtures require the configured JDK"); var runtime = - Path.of(Session.class.getProtectionDomain().getCodeSource().getLocation().toURI()); - var core = Path.of( SessionType.class .getProtectionDomain() .getCodeSource() .getLocation() .toURI()); - var classpath = - System.getProperty("java.class.path") - + File.pathSeparator - + runtime - + File.pathSeparator - + core; + var classpath = System.getProperty("java.class.path") + File.pathSeparator + runtime; var diagnostics = new StringWriter(); try (var files = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) { var success = @@ -147,8 +185,7 @@ private Path jar(String name, Map classes, String providers) var path = directory.resolve(name); try (var output = new JarOutputStream(Files.newOutputStream(path))) { output.putNextEntry( - new JarEntry( - "META-INF/services/com.chunkzero.chunk.multistom.SessionProvider")); + new JarEntry("META-INF/services/com.chunkzero.chunk.runtime.SessionProvider")); output.write(providers.getBytes(StandardCharsets.UTF_8)); output.closeEntry(); for (var entry : classes.entrySet()) { diff --git a/scripts/check-consumers.py b/scripts/check-consumers.py index 688ea767..c2d88af9 100755 --- a/scripts/check-consumers.py +++ b/scripts/check-consumers.py @@ -17,7 +17,7 @@ "gradlew", "gradlew.bat", "gradle", "gradle.properties", "settings.gradle.kts", "build.gradle.kts", "buildSrc", "jvm", "proto", "examples/arena", "examples/local", ) -PROVIDER = "META-INF/services/com.chunkzero.chunk.multistom.SessionProvider" +PROVIDER = "META-INF/services/com.chunkzero.chunk.runtime.SessionProvider" def require(condition, message): From 6d0c256cf589f7622a5f1b438c46e9d261561e0d Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:39:36 -0400 Subject: [PATCH 2/2] fix(jvm): type method adapters through their interface and never own supplied components --- .../chunk/gradle/SessionMethodIndex.kt | 2 +- .../chunkzero/chunk/gradle/ChunkPluginTest.kt | 23 ++++++++++++- .../chunk/runtime/ComponentRegistry.java | 8 ++++- .../chunk/runtime/ComponentRegistryTest.java | 34 +++++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/SessionMethodIndex.kt b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/SessionMethodIndex.kt index d3558bda..9667382c 100644 --- a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/SessionMethodIndex.kt +++ b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/SessionMethodIndex.kt @@ -49,7 +49,7 @@ internal fun writeSessionMethods( } val type = method["interface"].asString "new com.chunkzero.chunk.runtime.SessionMethodBinding<>($type.REF, ${sourceType(implementation)}.class, " + - "(session, args) -> session.${method["function"].asString}(args))" + "(session, args) -> (($type) session).${method["function"].asString}(args))" } for ((session, implementation) in implementations) { if (implementation == null) continue diff --git a/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt b/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt index 8c11a641..5652356b 100644 --- a/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt +++ b/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt @@ -171,10 +171,31 @@ class ChunkPluginTest { } @Test - fun `Java methods compile after clean prerequisites and reject mismatched session contracts`() { + fun `Java methods compile after clean prerequisites, support generic sessions and reject mismatched contracts`() { methodFixture(kotlin = false) run("chunkArtifacts") assertMethodExecution() + write( + "apps/lobby/src/main/java/Base.java", + """ + package fixture.lobby; + public abstract class Base { + protected abstract V result(fixture.generated.Bindings args); + public V ping(fixture.generated.Bindings args) { return result(args); } + } + """, + ) + write( + "apps/lobby/src/main/java/Game.java", + """ + package fixture.lobby; + public final class Game extends Base implements fixture.generated.Ping { + protected String result(fixture.generated.Bindings args) { return "echo:" + args.value(); } + } + """, + ) + run("chunkArtifacts") + assertMethodExecution() write( "apps/lobby/src/main/java/Game.java", """ diff --git a/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentRegistry.java b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentRegistry.java index 8d54d977..4dce5aa0 100644 --- a/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentRegistry.java +++ b/jvm/runtime/src/main/java/com/chunkzero/chunk/runtime/ComponentRegistry.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.LinkedHashMap; @@ -11,6 +12,7 @@ import java.util.Map; import java.util.Objects; import java.util.ServiceLoader; +import java.util.Set; /** * Owns generated factory instances for one app process and its independent sessions. Session @@ -101,7 +103,9 @@ private Object resolve(Store session, Class type, List created) throws var value = Objects.requireNonNull( binding.factory().create(dependencies), "Component factory returned null"); - if (value instanceof BackendSession || builtin(value.getClass())) + if (value instanceof BackendSession + || builtin(value.getClass()) + || session.borrowed.contains(value)) throw new IllegalStateException( "Component factory returned a borrowed session capability"); if (owned.containsKey(value)) @@ -222,11 +226,13 @@ public void close() { private static final class Store { final Map, Object> supplied; + final Set borrowed = Collections.newSetFromMap(new IdentityHashMap<>()); final Map, Entry> values = new LinkedHashMap<>(); boolean closed; Store(Map, Object> supplied) { this.supplied = supplied; + borrowed.addAll(supplied.values()); } } diff --git a/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/ComponentRegistryTest.java b/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/ComponentRegistryTest.java index 026cb9c7..fa432091 100644 --- a/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/ComponentRegistryTest.java +++ b/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/ComponentRegistryTest.java @@ -48,8 +48,42 @@ void sessionComponentsReceiveOnlyMarkedSuppliedTypes() { deps -> new Handle())))); } + @Test + void factoryReturningSuppliedInstanceIsRejectedWithoutClosingIt() { + var binding = + new ComponentBinding<>( + Resource.class, + Component.Scope.SESSION, + List.of(Channel.class), + deps -> (Resource) deps[0]); + var socket = new Socket(); + try (var registry = new ComponentRegistry(List.of(binding))) { + try (var components = registry.session(Map.of(Channel.class, socket))) { + assertThrows(IllegalStateException.class, () -> components.get(Resource.class)); + } + } + assertFalse(socket.closed); + } + @Component.Supplied private static final class Handle {} + @Component.Supplied + private interface Channel {} + + private interface Resource extends AutoCloseable { + @Override + void close(); + } + + private static final class Socket implements Channel, Resource { + boolean closed; + + @Override + public void close() { + closed = true; + } + } + private record Greeting(Handle handle) {} }