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, " + + "new com.chunkzero.chunk.runtime.SessionMethodBinding<>($type.REF, ${sourceType(implementation)}.class, " + "(session, args) -> (($type) session).${method["function"].asString}(args))" } for ((session, implementation) in implementations) { @@ -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..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 @@ -164,22 +164,43 @@ 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")) } @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", """ 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 +240,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 +316,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 +341,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 +368,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 +388,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 +417,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 +433,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 +451,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 +467,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 +493,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 +507,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 +522,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 +532,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 +641,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 +685,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 +697,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 +708,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 +772,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 77% 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..4dce5aa0 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,16 +1,10 @@ -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; +import java.util.Collections; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.LinkedHashMap; @@ -18,12 +12,16 @@ 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. */ -@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 +51,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 +63,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 +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 SessionScope || value instanceof BackendSession) + 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)) @@ -109,7 +124,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 +175,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 +183,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 +225,14 @@ public void close() { } private static final class Store { - final @Nullable SessionScope scope; + final Map, Object> supplied; + final Set borrowed = Collections.newSetFromMap(new IdentityHashMap<>()); final Map, Entry> values = new LinkedHashMap<>(); boolean closed; - Store(@Nullable SessionScope scope) { - this.scope = scope; + Store(Map, Object> supplied) { + this.supplied = supplied; + borrowed.addAll(supplied.values()); } } 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..fa432091 --- /dev/null +++ b/jvm/runtime/src/test/java/com/chunkzero/chunk/runtime/ComponentRegistryTest.java @@ -0,0 +1,89 @@ +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())))); + } + + @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) {} +} 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):