From df413be013f91e4e64d5be9b78915cdc85ee0e48 Mon Sep 17 00:00:00 2001 From: "Mr. Denman Mills" Date: Mon, 5 Oct 2026 23:12:47 -0500 Subject: [PATCH] test: exact-tree validate Oreslang PR #252 (506ed0b29329de2bda7747f4d6bcc5f6d4c46d34) --- .github/workflows/ci.yml | 119 +- .github/workflows/fast-ci.yml | 38 + .github/workflows/project-manifest-ci.yml | 30 + .gitignore | 13 +- AGENTS.md | 26 - Cargo.toml | 14 - README.md | 103 +- contracts/oreslangc-config.schema.json | 43 + contracts/oreslangc-config.tsp | 23 + docs/LANGUAGE.md | 1264 ++++++ docs/MIXED_JAVA_ORES.md | 200 + docs/NATIVE_RUNTIME_ABI.md | 89 + docs/PATTERN_MATCHING.md | 122 + docs/PROJECT_MANIFEST.md | 78 + docs/PURE.md | 752 ++++ docs/RUNTIME.md | 246 ++ docs/TRAP.md | 891 ++++ docs/TREE_SHAKING.md | 126 + .../channels-select-and-actor-cancellation.md | 451 ++ docs/garbage-collection.md | 33 + docs/grammar.ebnf | 439 ++ docs/security/untrusted-actor-graalwasm.md | 65 + examples/actor-isolation-support.ores | 3 + examples/async-await.ores | 15 + examples/callables.ores | 31 + examples/circular_imports/README.md | 24 + examples/circular_imports/a.ores | 19 + examples/circular_imports/b.ores | 12 + examples/java-interop.ores | 11 + examples/modules-namespaces-callables.ores | 35 + examples/private-actor-sharing-invalid.ores | 52 + examples/process-gc.ores | 7 + examples/proper-tail-call.ores | 32 + examples/shared-private-actor-isolation.ores | 93 + examples/showcase.ores | 53 + examples/together.ores | 15 + pom.xml | 224 + rust-toolchain.toml | 4 - scripts/build-native-thread.sh | 56 + src/lib.rs | 221 - src/main.rs | 477 --- src/main/c/oresthread.c | 389 ++ src/main/java/dev/oreslang/OresLanguage.java | 81 + .../dev/oreslang/ast/AnnotationExpander.java | 248 ++ src/main/java/dev/oreslang/ast/Ast.java | 547 +++ .../dev/oreslang/compiler/BuildOptions.java | 115 + .../dev/oreslang/compiler/ImportGraph.java | 289 ++ .../compiler/IncrementalCompiler.java | 380 ++ .../dev/oreslang/compiler/OresCompiler.java | 36 + .../dev/oreslang/compiler/TreeShaker.java | 1353 ++++++ .../oreslang/config/OresProjectConfig.java | 266 ++ .../dev/oreslang/imports/ImportRules.java | 109 + .../oreslang/interop/MixedInteropBridge.java | 48 + .../oreslang/interop/MixedJavaCompiler.java | 250 ++ .../dev/oreslang/interop/MixedSourceUnit.java | 667 +++ .../java/dev/oreslang/launcher/OresMain.java | 149 + .../dev/oreslang/nodes/OresEvalRootNode.java | 3265 ++++++++++++++ .../oreslang/nodes/OresInteropRootNode.java | 37 + src/main/java/dev/oreslang/parser/Lexer.java | 147 + src/main/java/dev/oreslang/parser/Parser.java | 2048 +++++++++ src/main/java/dev/oreslang/parser/Token.java | 25 + .../dev/oreslang/runtime/ActorRuntime.java | 3341 +++++++++++++++ .../dev/oreslang/runtime/AsyncRuntime.java | 195 + .../java/dev/oreslang/runtime/Awaitable.java | 14 + .../oreslang/runtime/CapabilityChecker.java | 421 ++ .../dev/oreslang/runtime/ChannelRuntime.java | 820 ++++ .../oreslang/runtime/ExecutionProfile.java | 38 + .../oreslang/runtime/HotReloadManager.java | 172 + .../dev/oreslang/runtime/HungryActor.java | 286 ++ .../dev/oreslang/runtime/IsolatePolicy.java | 333 ++ .../oreslang/runtime/LinkedProgramRunner.java | 326 ++ .../runtime/NativeCarrierExecutor.java | 382 ++ .../dev/oreslang/runtime/OresContext.java | 245 ++ .../java/dev/oreslang/runtime/OresFuture.java | 667 +++ .../dev/oreslang/runtime/OresFutures.java | 191 + .../java/dev/oreslang/runtime/OresMutex.java | 953 +++++ .../java/dev/oreslang/runtime/OresNull.java | 24 + .../dev/oreslang/runtime/OresScheduler.java | 655 +++ .../runtime/RuntimeGarbageCollector.java | 418 ++ .../dev/oreslang/types/OwnershipChecker.java | 2125 +++++++++ .../java/dev/oreslang/types/TypeChecker.java | 3806 +++++++++++++++++ src/main/java/dev/oreslang/types/Types.java | 148 + src/main/java/module-info.java | 14 + .../oreslang/native-image.properties | 1 + .../oreslang/reachability-metadata.json | 24 + .../oreslang/ActorCallableKeywordTest.java | 185 + .../dev/oreslang/ActorFncKeywordTest.java | 18 + .../oreslang/ActorInvocationRuntimeTest.java | 83 + .../java/dev/oreslang/ActorRuntimeTest.java | 1506 +++++++ .../oreslang/ActorTransportHardeningTest.java | 142 + .../dev/oreslang/AsyncAwaitLanguageTest.java | 182 + .../oreslang/BlockLoopControlFlowTest.java | 344 ++ .../CallableKeywordHardeningTest.java | 18 + .../dev/oreslang/CallableSemanticsTest.java | 342 ++ .../dev/oreslang/ChannelSelectSyntaxTest.java | 373 ++ .../CircularImportInitializationTest.java | 105 + .../GarbageCollectionLanguageTest.java | 64 + .../GenericsAndOperatorsHardeningTest.java | 562 +++ .../java/dev/oreslang/HostImportTest.java | 175 + .../oreslang/ImportSelectorSyntaxTest.java | 165 + .../IncrementalFunctorStaticTest.java | 372 ++ .../oreslang/InitializationBarrierTest.java | 218 + .../dev/oreslang/InterfaceFncKeywordTest.java | 18 + .../dev/oreslang/IsolationHotReloadTest.java | 232 + .../dev/oreslang/LanguageHardeningTest.java | 731 ++++ .../dev/oreslang/MixedSourceInteropTest.java | 417 ++ ...odulesNamespacesCallableSemanticsTest.java | 114 + .../java/dev/oreslang/MutexLanguageTest.java | 987 +++++ .../java/dev/oreslang/MutexRuntimeTest.java | 1219 ++++++ .../oreslang/OptionResultSemanticsTest.java | 273 ++ .../OreslangActorIsolationExampleTest.java | 44 + .../dev/oreslang/OwnershipAndClosureTest.java | 348 ++ src/test/java/dev/oreslang/ParserTest.java | 562 +++ .../PatternMatchingHardeningTest.java | 327 ++ .../dev/oreslang/PolyglotFeatureTest.java | 89 + .../java/dev/oreslang/PolyglotSmokeTest.java | 38 + .../oreslang/PrivateActorIsolationTest.java | 486 +++ .../dev/oreslang/PrivateVisibilityTest.java | 359 ++ .../oreslang/ProjectManifestImportTest.java | 378 ++ .../java/dev/oreslang/ProperTailCallTest.java | 211 + .../ReservedKeywordsDynamicStructTest.java | 157 + .../oreslang/ReturnedDestructuringTest.java | 380 ++ .../java/dev/oreslang/RoutineAndLoopTest.java | 317 ++ .../oreslang/SerializationAnnotationTest.java | 184 + .../SharedPrivateActorIsolationProofTest.java | 99 + .../dev/oreslang/StaticFncKeywordTest.java | 18 + .../java/dev/oreslang/TreeShakerTest.java | 222 + .../dev/oreslang/TypeofFncKeywordTest.java | 18 + .../config/OresProjectConfigTest.java | 119 + .../launcher/CompilerCheckModeTest.java | 50 + .../runtime/ActorCapabilityIsolationTest.java | 324 ++ .../ActorRuntimeNativeCarrierTest.java | 71 + .../ActorStructuredCancellationTest.java | 372 ++ .../oreslang/runtime/AsyncRuntimeTest.java | 76 + .../oreslang/runtime/ChannelRuntimeTest.java | 279 ++ .../dev/oreslang/runtime/HungryActorTest.java | 136 + .../runtime/NativeCarrierExecutorTest.java | 49 + .../runtime/OresFutureCallbackTest.java | 179 + .../dev/oreslang/runtime/OresFuturesTest.java | 253 ++ .../oreslang/runtime/OresSchedulerTest.java | 340 ++ .../runtime/RuntimeGarbageCollectorTest.java | 188 + .../runtime/SharedMutexPublicationTest.java | 57 + .../.github/workflows/ci.yml | 26 - vendor/litegraph-gpu-host/.gitignore | 7 - vendor/litegraph-gpu-host/.ores-otel.toml | 2 - vendor/litegraph-gpu-host/AGENTS.md | 22 - vendor/litegraph-gpu-host/Cargo.toml | 18 - vendor/litegraph-gpu-host/README.md | 76 - vendor/litegraph-gpu-host/rust-toolchain.toml | 4 - vendor/litegraph-gpu-host/src/lib.rs | 613 --- .../litegraph-modeld/.github/workflows/ci.yml | 26 - vendor/litegraph-modeld/.gitignore | 7 - vendor/litegraph-modeld/.ores-otel.toml | 2 - vendor/litegraph-modeld/AGENTS.md | 26 - vendor/litegraph-modeld/Cargo.toml | 11 - vendor/litegraph-modeld/README.md | 73 - vendor/litegraph-modeld/rust-toolchain.toml | 4 - vendor/litegraph-modeld/src/lib.rs | 509 --- .../.github/workflows/ci.yml | 26 - vendor/litegraph-runtime/.gitignore | 7 - vendor/litegraph-runtime/.ores-otel.toml | 2 - vendor/litegraph-runtime/AGENTS.md | 26 - vendor/litegraph-runtime/Cargo.toml | 15 - vendor/litegraph-runtime/README.md | 73 - vendor/litegraph-runtime/rust-toolchain.toml | 4 - vendor/litegraph-runtime/src/lib.rs | 453 -- 166 files changed, 47336 insertions(+), 2868 deletions(-) create mode 100644 .github/workflows/fast-ci.yml create mode 100644 .github/workflows/project-manifest-ci.yml delete mode 100644 AGENTS.md delete mode 100644 Cargo.toml create mode 100644 contracts/oreslangc-config.schema.json create mode 100644 contracts/oreslangc-config.tsp create mode 100644 docs/LANGUAGE.md create mode 100644 docs/MIXED_JAVA_ORES.md create mode 100644 docs/NATIVE_RUNTIME_ABI.md create mode 100644 docs/PATTERN_MATCHING.md create mode 100644 docs/PROJECT_MANIFEST.md create mode 100644 docs/PURE.md create mode 100644 docs/RUNTIME.md create mode 100644 docs/TRAP.md create mode 100644 docs/TREE_SHAKING.md create mode 100644 docs/channels-select-and-actor-cancellation.md create mode 100644 docs/garbage-collection.md create mode 100644 docs/grammar.ebnf create mode 100644 docs/security/untrusted-actor-graalwasm.md create mode 100644 examples/actor-isolation-support.ores create mode 100644 examples/async-await.ores create mode 100644 examples/callables.ores create mode 100644 examples/circular_imports/README.md create mode 100644 examples/circular_imports/a.ores create mode 100644 examples/circular_imports/b.ores create mode 100644 examples/java-interop.ores create mode 100644 examples/modules-namespaces-callables.ores create mode 100644 examples/private-actor-sharing-invalid.ores create mode 100644 examples/process-gc.ores create mode 100644 examples/proper-tail-call.ores create mode 100644 examples/shared-private-actor-isolation.ores create mode 100644 examples/showcase.ores create mode 100644 examples/together.ores create mode 100644 pom.xml delete mode 100644 rust-toolchain.toml create mode 100755 scripts/build-native-thread.sh delete mode 100644 src/lib.rs delete mode 100644 src/main.rs create mode 100644 src/main/c/oresthread.c create mode 100644 src/main/java/dev/oreslang/OresLanguage.java create mode 100644 src/main/java/dev/oreslang/ast/AnnotationExpander.java create mode 100644 src/main/java/dev/oreslang/ast/Ast.java create mode 100644 src/main/java/dev/oreslang/compiler/BuildOptions.java create mode 100644 src/main/java/dev/oreslang/compiler/ImportGraph.java create mode 100644 src/main/java/dev/oreslang/compiler/IncrementalCompiler.java create mode 100644 src/main/java/dev/oreslang/compiler/OresCompiler.java create mode 100644 src/main/java/dev/oreslang/compiler/TreeShaker.java create mode 100644 src/main/java/dev/oreslang/config/OresProjectConfig.java create mode 100644 src/main/java/dev/oreslang/imports/ImportRules.java create mode 100644 src/main/java/dev/oreslang/interop/MixedInteropBridge.java create mode 100644 src/main/java/dev/oreslang/interop/MixedJavaCompiler.java create mode 100644 src/main/java/dev/oreslang/interop/MixedSourceUnit.java create mode 100644 src/main/java/dev/oreslang/launcher/OresMain.java create mode 100644 src/main/java/dev/oreslang/nodes/OresEvalRootNode.java create mode 100644 src/main/java/dev/oreslang/nodes/OresInteropRootNode.java create mode 100644 src/main/java/dev/oreslang/parser/Lexer.java create mode 100644 src/main/java/dev/oreslang/parser/Parser.java create mode 100644 src/main/java/dev/oreslang/parser/Token.java create mode 100644 src/main/java/dev/oreslang/runtime/ActorRuntime.java create mode 100644 src/main/java/dev/oreslang/runtime/AsyncRuntime.java create mode 100644 src/main/java/dev/oreslang/runtime/Awaitable.java create mode 100644 src/main/java/dev/oreslang/runtime/CapabilityChecker.java create mode 100644 src/main/java/dev/oreslang/runtime/ChannelRuntime.java create mode 100644 src/main/java/dev/oreslang/runtime/ExecutionProfile.java create mode 100644 src/main/java/dev/oreslang/runtime/HotReloadManager.java create mode 100644 src/main/java/dev/oreslang/runtime/HungryActor.java create mode 100644 src/main/java/dev/oreslang/runtime/IsolatePolicy.java create mode 100644 src/main/java/dev/oreslang/runtime/LinkedProgramRunner.java create mode 100644 src/main/java/dev/oreslang/runtime/NativeCarrierExecutor.java create mode 100644 src/main/java/dev/oreslang/runtime/OresContext.java create mode 100644 src/main/java/dev/oreslang/runtime/OresFuture.java create mode 100644 src/main/java/dev/oreslang/runtime/OresFutures.java create mode 100644 src/main/java/dev/oreslang/runtime/OresMutex.java create mode 100644 src/main/java/dev/oreslang/runtime/OresNull.java create mode 100644 src/main/java/dev/oreslang/runtime/OresScheduler.java create mode 100644 src/main/java/dev/oreslang/runtime/RuntimeGarbageCollector.java create mode 100644 src/main/java/dev/oreslang/types/OwnershipChecker.java create mode 100644 src/main/java/dev/oreslang/types/TypeChecker.java create mode 100644 src/main/java/dev/oreslang/types/Types.java create mode 100644 src/main/java/module-info.java create mode 100644 src/main/resources/META-INF/native-image/dev.oreslang/oreslang/native-image.properties create mode 100644 src/main/resources/META-INF/native-image/dev.oreslang/oreslang/reachability-metadata.json create mode 100644 src/test/java/dev/oreslang/ActorCallableKeywordTest.java create mode 100644 src/test/java/dev/oreslang/ActorFncKeywordTest.java create mode 100644 src/test/java/dev/oreslang/ActorInvocationRuntimeTest.java create mode 100644 src/test/java/dev/oreslang/ActorRuntimeTest.java create mode 100644 src/test/java/dev/oreslang/ActorTransportHardeningTest.java create mode 100644 src/test/java/dev/oreslang/AsyncAwaitLanguageTest.java create mode 100644 src/test/java/dev/oreslang/BlockLoopControlFlowTest.java create mode 100644 src/test/java/dev/oreslang/CallableKeywordHardeningTest.java create mode 100644 src/test/java/dev/oreslang/CallableSemanticsTest.java create mode 100644 src/test/java/dev/oreslang/ChannelSelectSyntaxTest.java create mode 100644 src/test/java/dev/oreslang/CircularImportInitializationTest.java create mode 100644 src/test/java/dev/oreslang/GarbageCollectionLanguageTest.java create mode 100644 src/test/java/dev/oreslang/GenericsAndOperatorsHardeningTest.java create mode 100644 src/test/java/dev/oreslang/HostImportTest.java create mode 100644 src/test/java/dev/oreslang/ImportSelectorSyntaxTest.java create mode 100644 src/test/java/dev/oreslang/IncrementalFunctorStaticTest.java create mode 100644 src/test/java/dev/oreslang/InitializationBarrierTest.java create mode 100644 src/test/java/dev/oreslang/InterfaceFncKeywordTest.java create mode 100644 src/test/java/dev/oreslang/IsolationHotReloadTest.java create mode 100644 src/test/java/dev/oreslang/LanguageHardeningTest.java create mode 100644 src/test/java/dev/oreslang/MixedSourceInteropTest.java create mode 100644 src/test/java/dev/oreslang/ModulesNamespacesCallableSemanticsTest.java create mode 100644 src/test/java/dev/oreslang/MutexLanguageTest.java create mode 100644 src/test/java/dev/oreslang/MutexRuntimeTest.java create mode 100644 src/test/java/dev/oreslang/OptionResultSemanticsTest.java create mode 100644 src/test/java/dev/oreslang/OreslangActorIsolationExampleTest.java create mode 100644 src/test/java/dev/oreslang/OwnershipAndClosureTest.java create mode 100644 src/test/java/dev/oreslang/ParserTest.java create mode 100644 src/test/java/dev/oreslang/PatternMatchingHardeningTest.java create mode 100644 src/test/java/dev/oreslang/PolyglotFeatureTest.java create mode 100644 src/test/java/dev/oreslang/PolyglotSmokeTest.java create mode 100644 src/test/java/dev/oreslang/PrivateActorIsolationTest.java create mode 100644 src/test/java/dev/oreslang/PrivateVisibilityTest.java create mode 100644 src/test/java/dev/oreslang/ProjectManifestImportTest.java create mode 100644 src/test/java/dev/oreslang/ProperTailCallTest.java create mode 100644 src/test/java/dev/oreslang/ReservedKeywordsDynamicStructTest.java create mode 100644 src/test/java/dev/oreslang/ReturnedDestructuringTest.java create mode 100644 src/test/java/dev/oreslang/RoutineAndLoopTest.java create mode 100644 src/test/java/dev/oreslang/SerializationAnnotationTest.java create mode 100644 src/test/java/dev/oreslang/SharedPrivateActorIsolationProofTest.java create mode 100644 src/test/java/dev/oreslang/StaticFncKeywordTest.java create mode 100644 src/test/java/dev/oreslang/TreeShakerTest.java create mode 100644 src/test/java/dev/oreslang/TypeofFncKeywordTest.java create mode 100644 src/test/java/dev/oreslang/config/OresProjectConfigTest.java create mode 100644 src/test/java/dev/oreslang/launcher/CompilerCheckModeTest.java create mode 100644 src/test/java/dev/oreslang/runtime/ActorCapabilityIsolationTest.java create mode 100644 src/test/java/dev/oreslang/runtime/ActorRuntimeNativeCarrierTest.java create mode 100644 src/test/java/dev/oreslang/runtime/ActorStructuredCancellationTest.java create mode 100644 src/test/java/dev/oreslang/runtime/AsyncRuntimeTest.java create mode 100644 src/test/java/dev/oreslang/runtime/ChannelRuntimeTest.java create mode 100644 src/test/java/dev/oreslang/runtime/HungryActorTest.java create mode 100644 src/test/java/dev/oreslang/runtime/NativeCarrierExecutorTest.java create mode 100644 src/test/java/dev/oreslang/runtime/OresFutureCallbackTest.java create mode 100644 src/test/java/dev/oreslang/runtime/OresFuturesTest.java create mode 100644 src/test/java/dev/oreslang/runtime/OresSchedulerTest.java create mode 100644 src/test/java/dev/oreslang/runtime/RuntimeGarbageCollectorTest.java create mode 100644 src/test/java/dev/oreslang/runtime/SharedMutexPublicationTest.java delete mode 100644 vendor/litegraph-gpu-host/.github/workflows/ci.yml delete mode 100644 vendor/litegraph-gpu-host/.gitignore delete mode 100644 vendor/litegraph-gpu-host/.ores-otel.toml delete mode 100644 vendor/litegraph-gpu-host/AGENTS.md delete mode 100644 vendor/litegraph-gpu-host/Cargo.toml delete mode 100644 vendor/litegraph-gpu-host/README.md delete mode 100644 vendor/litegraph-gpu-host/rust-toolchain.toml delete mode 100644 vendor/litegraph-gpu-host/src/lib.rs delete mode 100644 vendor/litegraph-modeld/.github/workflows/ci.yml delete mode 100644 vendor/litegraph-modeld/.gitignore delete mode 100644 vendor/litegraph-modeld/.ores-otel.toml delete mode 100644 vendor/litegraph-modeld/AGENTS.md delete mode 100644 vendor/litegraph-modeld/Cargo.toml delete mode 100644 vendor/litegraph-modeld/README.md delete mode 100644 vendor/litegraph-modeld/rust-toolchain.toml delete mode 100644 vendor/litegraph-modeld/src/lib.rs delete mode 100644 vendor/litegraph-runtime/.github/workflows/ci.yml delete mode 100644 vendor/litegraph-runtime/.gitignore delete mode 100644 vendor/litegraph-runtime/.ores-otel.toml delete mode 100644 vendor/litegraph-runtime/AGENTS.md delete mode 100644 vendor/litegraph-runtime/Cargo.toml delete mode 100644 vendor/litegraph-runtime/README.md delete mode 100644 vendor/litegraph-runtime/rust-toolchain.toml delete mode 100644 vendor/litegraph-runtime/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc70f6e..ad3a65be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,115 @@ name: ci + on: - pull_request: push: - branches: [main] + branches: [main, "feat/**"] + pull_request: + permissions: contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: - rust: + test: runs-on: ubuntu-latest - timeout-minutes: 20 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v4 + - name: Set up GraalVM + uses: graalvm/setup-graalvm@v1 with: - persist-credentials: false - - name: Verify exact vendored dependency trees + version: '25.3' + java-version: '25' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} + cache: maven + - name: GraalVM diagnostics + run: | + java --version + native-image --version + - name: Verify JIT/JVM profile + run: mvn -B -ntp -Dores.runtime.carriers=native verify + - name: Run showcase + run: mvn -B -ntp -DskipTests exec:java -Dexec.args="examples/showcase.ores" + - name: Compile and run shared Oreslang program + run: mvn -B -ntp -DskipTests exec:java -Dexec.args="examples/together.ores" + + native-aot: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up GraalVM + uses: graalvm/setup-graalvm@v1 + with: + version: '25.3' + java-version: '25' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} + cache: maven + - name: GraalVM diagnostics + run: | + java --version + native-image --version + - name: Build interpreter-only AOT image + run: mvn -B -ntp -Pnative-aot -DskipTests package + - name: Run AOT image + run: ./target/ores-aot --mode=aot --platform=linux examples/together.ores + - name: Run AOT proper tail-call stress + run: | + ./target/ores-aot --mode=aot --platform=linux examples/proper-tail-call.ores | tee /tmp/ores-aot-tail.out + grep -F "0|0|0" /tmp/ores-aot-tail.out + + native-hybrid: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up GraalVM + uses: graalvm/setup-graalvm@v1 + with: + version: '25.3' + java-version: '25' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} + cache: maven + - name: GraalVM diagnostics + run: | + java --version + native-image --version + - name: Build AOT host with guest JIT + run: mvn -B -ntp -Pnative-hybrid -DskipTests package + - name: Run hybrid image + run: ./target/ores-hybrid --mode=hybrid --platform=linux examples/together.ores + - name: Run hybrid proper tail-call stress + run: | + ./target/ores-hybrid --mode=hybrid --platform=linux examples/proper-tail-call.ores | tee /tmp/ores-hybrid-tail.out + grep -F "0|0|0" /tmp/ores-hybrid-tail.out + + polyglot-isolate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up GraalVM + uses: graalvm/setup-graalvm@v1 + with: + version: '25.3' + java-version: '25' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} + cache: maven + - name: GraalVM diagnostics + run: | + java --version + native-image --version + - name: Build Oreslang polyglot isolate library + run: mvn -B -ntp -Pnative-isolate -DskipTests package + - name: Locate isolate library + id: isolate + shell: bash run: | - test "$(git rev-parse HEAD:vendor/litegraph-gpu-host)" = "a99b312757206f463d75756bbe703968440c97b2" - test "$(git rev-parse HEAD:vendor/litegraph-modeld)" = "04714b79813beba45942f52929f61d32279652b1" - test "$(git rev-parse HEAD:vendor/litegraph-runtime)" = "b03d1c71faa2df734a3d0bf06349248111939304" - - run: rustc --version && cargo --version - - run: cargo fmt --all -- --check - - run: cargo clippy --all-targets --all-features -- -D warnings - - run: cargo test --all-features + lib="$(find target -maxdepth 1 -type f \( -name 'oresvm.so' -o -name 'liboresvm.so' -o -name 'oresvm.dylib' -o -name 'liboresvm.dylib' \) | head -n1)" + test -n "$lib" + echo "path=$PWD/$lib" >> "$GITHUB_OUTPUT" + - name: Run strict program in Oreslang polyglot isolate + run: mvn -B -ntp -Dpolyglot.engine.IsolateLibrary="${{ steps.isolate.outputs.path }}" -DskipTests exec:java -Dexec.args="--strict-isolate --mode=jit --platform=linux examples/together.ores" diff --git a/.github/workflows/fast-ci.yml b/.github/workflows/fast-ci.yml new file mode 100644 index 00000000..ebaf2e23 --- /dev/null +++ b/.github/workflows/fast-ci.yml @@ -0,0 +1,38 @@ +name: fast-ci + +on: + push: + branches: [main, "feat/**"] + pull_request: + +permissions: + contents: read + +concurrency: + group: fast-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up GraalVM + uses: graalvm/setup-graalvm@v1 + with: + version: '25.3' + java-version: '25' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} + cache: maven + - name: Verify compiler/runtime tests + run: mvn -B -ntp -Dores.runtime.carriers=native verify + - name: Run examples + run: | + mvn -B -ntp -DskipTests exec:java -Dexec.args="examples/showcase.ores" + mvn -B -ntp -DskipTests exec:java -Dexec.args="examples/together.ores" + mvn -B -ntp -DskipTests exec:java -Dexec.args="examples/modules-namespaces-callables.ores" + mvn -B -ntp -DskipTests exec:java -Dexec.args="examples/async-await.ores" | tee /tmp/oreslang-async-await.out + grep -F "144" /tmp/oreslang-async-await.out + mvn -B -ntp -DskipTests exec:java -Dexec.args="examples/callables.ores" | tee /tmp/oreslang-callables.out + grep -F "120:9:13" /tmp/oreslang-callables.out diff --git a/.github/workflows/project-manifest-ci.yml b/.github/workflows/project-manifest-ci.yml new file mode 100644 index 00000000..df5284b7 --- /dev/null +++ b/.github/workflows/project-manifest-ci.yml @@ -0,0 +1,30 @@ +name: Project manifest CI + +on: + push: + branches: + - feat/project-manifest-oreslang-path-20261003 + pull_request: + paths: + - '.oreslangc.cfg.toml' + - 'contracts/oreslangc-config.*' + - 'src/**' + - 'pom.xml' + - '.github/workflows/project-manifest-ci.yml' + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: graalvm/setup-graalvm@v1 + with: + java-version: '25' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} + cache: maven + - name: Verify + run: mvn -B -ntp verify diff --git a/.gitignore b/.gitignore index 3e2da82c..bae9431a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ -/target -Cargo.lock - -# Fleet-local scratch/worktrees and agent-policy link -.ores/ -tmp/ -temp/ +target/ +.idea/ +.vscode/ +*.iml +.DS_Store +*.log diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index a347a361..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,26 +0,0 @@ -# Repository agent instructions - -This repository follows the shared ORESoftware fleet policy. - - - -## Canonical agent instructions - -Before doing anything else in this repository, also read: - - .ores/agents/AGENTS.md - -That path is a symlink to `~/codes/oresoftware/my-ai/AGENTS.md`, whose canonical copy is -. - -The symlink is deliberately not committed. If it is missing locally, run -`~/codes/oresoftware/my-ai/scripts/link-repo-agents.sh` or fetch the canonical policy above. -A missing local symlink is a setup gap, never permission to skip the policy. - - - -## Repository-specific rule - -Preserve the ownership boundaries documented in this repository's README. Cross-repository -LiteGraph semantics belong in the canonical interfaces/contracts repositories rather than -being independently redefined here. diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 0ded4129..00000000 --- a/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "litegraph-node" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" - -[dependencies] -async-trait = "0.1" -axum = "0.8" -litegraph-gpu-host = { path = "vendor/litegraph-gpu-host" } -litegraph-modeld = { path = "vendor/litegraph-modeld" } -litegraph-runtime = { path = "vendor/litegraph-runtime" } -serde = { version = "1", features = ["derive"] } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } diff --git a/README.md b/README.md index 4e825892..028b1f2f 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,30 @@ -# litegraph-node - -Per-machine daemon and local authority for allocatable compute resources. - -LiteGraph is a heterogeneous compute actor platform: CPU code owns control, networking, actor supervision and ordinary OS capabilities; suitable numerical work may be dispatched to one or more GPUs. A machine is therefore not classified as simply "CPU" or "GPU"—CPU, RAM, accelerator devices and VRAM are independently schedulable resources. - -## Responsibilities - -- CPU/RAM and accelerator discovery. -- device/lane health and allocatable capacity. -- local invocation supervision. -- health/snapshot APIs and standalone workstation mode. - -## Explicit non-responsibilities - -- cluster-wide scheduling. -- control-plane tenant CRUD. -- compiler/toolchain responsibilities. - -Keeping these boundaries explicit is important: moving policy into a lower-level component makes local execution harder to reason about and creates competing authorities. - -## Place in the system - -```text -scheduler/router → node → runtime/modeld/gpu-host; node → scheduler telemetry -``` - -Shared invariants across the platform: - -- invocation actors are ephemeral; -- resident artifacts and compiled variants are immutable and revisioned; -- guest/customer code receives capabilities, never raw accelerator pointers; -- mutable accelerator state belongs to trusted lane/device actors; -- CPU and GPU resources are accounted independently; -- `cpu`, `gpu`, and `auto` describe execution requirements/preferences without changing logical function identity; -- backpressure and cancellation must propagate rather than creating unbounded queues. - -## Contracts and compatibility - -Wire-visible names use `snake_case`. Cross-language contracts belong in `litegraph-contracts`: authored TypeSpec and JSON Schema Draft 2020-12 are peer authorities, and generated files are evidence rather than a third authored schema. Contract mismatches must fail closed before promotion. - -Public/shared semantic types belong in `litegraph-interfaces` or `litegraph-pub-lib-core`; this repository should not create a subtly different copy of an existing concept. - -## Security and isolation - -Treat all tenant input and artifacts as untrusted. Validate sizes, identifiers and capability requests before allocating expensive resources. Never expose native accelerator pointers/driver handles across the tenant boundary, never place credentials in manifests or examples, and keep secrets in approved runtime secret channels. - -Isolation policy uses the platform classes `shared`, `sandbox`, `partitioned`, and `dedicated` where applicable. Resource release on cancellation, timeout and failure is part of correctness. - -## Development expectations - -Follow the fleet policy in `ORESoftware/my-ai` (`AGENTS.md` plus `SHARED.md`) when changing this repository. Durable systems tooling, validators, code generation and CI helpers should be Rust-first. Do not add Python for repository scripts, validators, codegen or CI gates. - -When this repository exposes an executable with command-line configuration, its public option contract belongs in root `.cli-flags.toml` and the argv boundary should use the canonical `flags-2-env` integration rather than maintaining a second independent flag schema. - -Tests should cover both success and fail-closed behavior. Hardware-independent logic should run with deterministic fakes/simulators; hardware-specific certification belongs on real accelerator runners. A hosted workflow that starts zero test steps is not evidence of a passing build. - -## Integration map - -- `litegraph-contracts` — wire schemas. -- `litegraph-interfaces` — canonical shared semantics. -- `litegraph-scheduler` — cluster placement. -- `litegraph-node` — machine inventory and local supervision. -- `litegraph-runtime` — invocation lifecycle. -- `litegraph-gpu-host` — trusted accelerator execution. -- `litegraph-modeld` — resident model actors. -- `litegraph-compiler` — deterministic multi-target build artifacts. -- registries — immutable function/model artifact storage. -- `litegraph-router.rs` — invocation forwarding and backpressure. - -## Documentation rule - -Keep this README specific to this repository. Architectural decisions that affect multiple repositories should be recorded in the canonical interface/contracts layer and linked here rather than copied into divergent local specifications. +# Oreslang + +Oreslang is a statically typed GraalVM/Truffle language with nominal typing by default, explicit structural-call opt-ins, actor-oriented concurrency, hot-loadable code generations, and deny-by-default isolate capabilities. + +This repository contains the Java/Truffle reference implementation. + +The language is intentionally opinionated: + +- static nominal typing by default, with explicit structural compatibility at selected call boundaries; +- private functions by default (`fnc`), with `pub` for exported functions; +- class methods omit `fnc` and have an implicit `self` receiver; +- one return value only (tuples/arrays/records are ordinary single values); +- `val`, `const`, and `let` are the only variable declarations; +- actor heaps are isolated: mutable values are never shared between actors; +- immutable/sendable values may be message-passed, and explicitly frozen regions may be shared read-only; +- isolates are stricter security boundaries for FaaS/mobile workloads, with host access denied and Oreslang APIs capability-gated by default; +- JIT, AOT/interpreter, and AOT-host + guest-JIT hybrid execution profiles; +- file-granular incremental compilation with stable code-unit/package identities and reverse-dependency invalidation; +- flat optional file namespaces and flat modules (neither may nest); +- class-level `static fnc` functions separated from receiver methods; +- first-class function aliases/types and block-only `|args| -> { ... }` lambdas; +- lexical closures with persistent captured environments; +- affine ownership, move checking, borrows, immutable-by-default parameters, and `Type mut name` owned-mutation syntax - see [ft borrow, ft copy, ft take, ft share] +- hot reload creates a fresh versioned guest context/generation without requiring FFI or dynamic native libraries; +- direct method calls reuse shared class method definitions; instance/actor methods are direct-call-only, and callbacks use explicit lambdas instead of implicit bound-method values; +- multiple named modules may appear in one source file; +- explicit `return` statements; +- generics, tuples, arrays, complex numbers, futures/`await`, lambdas, `defer`, and `try/catch/finally` are language-level features. + +The first implementation is developed on a feature branch and will land with an executable Truffle skeleton, grammar/specification, examples, tests, and CI. diff --git a/contracts/oreslangc-config.schema.json b/contracts/oreslangc-config.schema.json new file mode 100644 index 00000000..aa7e674e --- /dev/null +++ b/contracts/oreslangc-config.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oreslang.dev/schema/oreslangc-config-v1.schema.json", + "title": "Oreslang compiler project manifest v1", + "type": "object", + "required": ["schema_version"], + "properties": { + "schema_version": { "const": "1" }, + "project": { + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "root": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "source": { + "type": "object", + "properties": { + "roots": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "import_paths": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "entrypoints": { + "type": "object", + "properties": { + "main": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/contracts/oreslangc-config.tsp b/contracts/oreslangc-config.tsp new file mode 100644 index 00000000..dd7d0fdb --- /dev/null +++ b/contracts/oreslangc-config.tsp @@ -0,0 +1,23 @@ +namespace Oreslang.Compiler; + +model ProjectConfig { + schema_version: "1"; + project?: ProjectMetadata; + source?: SourceConfig; + entrypoints?: Entrypoints; +} + +model ProjectMetadata { + name?: string; + version?: string; + root?: string; +} + +model SourceConfig { + roots?: string[]; + import_paths?: string[]; +} + +model Entrypoints { + main?: string; +} diff --git a/docs/LANGUAGE.md b/docs/LANGUAGE.md new file mode 100644 index 00000000..150646de --- /dev/null +++ b/docs/LANGUAGE.md @@ -0,0 +1,1264 @@ +# Oreslang language design (v0.6) + +Oreslang is a statically typed guest language for GraalVM/Truffle. Named types are nominal by default; structural compatibility is explicit at selected boundaries. Its core invariants are explicit mutation, actor-owned mutable heaps, message-only actor communication, read-only sharing, and a stricter isolate profile for untrusted FaaS execution. + +## Files, modules, and imports + +A source file may contain multiple named modules. A module is a namespace: exported members are accessed through the module name, such as `math.add(1, 2)`. + +```ores +define module math + pub fnc add(int a, int b): int { + return a + b; + } +end + +define module app + pub fnc main(): void { + val answer = math.add(40, 2); + stdio.println(answer); + return; + } +end +``` + +Imports are explicit about what kind of symbol is entering the compilation unit: + +```ores +import * as package from "./xyz"; +import module foo from "../xyz"; +import module foo as apiFoo from "../xyz"; +import actor Worker from "../xyz"; +import class Widget as ApiWidget from "../xyz"; +import fnc add as apiAdd from "../xyz"; +import interface ServiceApi from "../xyz"; +import type UserId from "../xyz"; +import types ServiceApi, UserId from "../xyz"; +import types (ServiceApi, UserId) from "../xyz"; +import trait Retryable from "../xyz"; +import struct Point from "../xyz"; +``` + +Comma-separated and parenthesized named selections are equivalent, so +`import types X, Y, Z from "../foo";` and +`import types (X, Y, Z) from "../foo";` produce the same import selection. +The existing brace form remains accepted for compatibility. `types` is the +union selector for type-like declarations: `trait`, `struct`, `interface`, +and `type`. On the current v0.6 AST, interface and type-alias declarations are +available; trait/struct selectors are reserved and fail closed at link +validation until those declaration kinds land on the current compiler branch. + +Wildcard imports always require a namespace alias. A single named import may use +`as` to choose its local binding; the original source name still controls export +resolution. `import fnc` specifically imports a **reifiable non-generic, +non-actor `fnc` value**. Generic `fnc` declarations require direct-call +specialization and therefore are not valid `import fnc` targets until Oreslang +gains polymorphic function values. `class` and `actor` are deliberately +distinct selectors: an actor class does not satisfy an `import class`, and an +ordinary class does not satisfy an `import actor`. Import paths are part of the +AST/compiler contract; filesystem/package resolution is a host build/bundling +concern so strict isolates do not gain ambient filesystem access merely by using +`import`. + +Java host classes use an explicit `java:` URI and the same alias syntax: + +```ores +import class ArrayList as JArrayList from "java:java.util.ArrayList"; +``` + +The selected name (`ArrayList`) must match the Java simple class name; `JArrayList` is only the Oreslang-local alias. Java imports never grant authority by themselves: runtime use additionally requires the `JAVA_INTEROP` capability and an exact host-class allowlist supplied by the launcher/embedder. + +### Circular imports and file initialization + +Import cycles are legal. Oreslang does not reject a program merely because its +file/module graph contains a cycle such as `a.ores -> b.ores -> a.ores`. + +The loader uses a staged lifecycle: + +1. parse and statically validate the complete reachable source graph; +2. resolve/link imports for every code unit; +3. compute strongly connected components (SCCs) of the import graph; +4. for each dependency-first SCC, verify that **all** members are linked; +5. run each member's optional file init hook; +6. after initialization, invoke the entry unit's `main`. + +A file init hook has the exact shape: + +```ores +fnc init(): void { + // side effects are allowed here + return; +} +``` + +It is private, synchronous, non-actor, non-generic, takes no parameters, and +returns `void`. The hook runs at most once for that loaded code-unit +generation. Inside a cycle, init hooks execute in deterministic normalized +code-unit-id order, but code must rely only on the stronger barrier guarantee: +**every peer in the cycle is already linked before any peer's init begins**. + +This means an init hook may call exported declarations from a cyclic peer +without observing an "unloaded module" state. If application state requires a +specific sequencing relationship *between* two init hooks in the same cycle, +that relationship should be made explicit in application code rather than +inferred from the import edges. + +## Module interfaces / OCaml-style module signatures + +Interfaces can describe the structural public shape required of a module. A module opts into checking with `@AdheresTo(...)`: + +```ores +define module contracts + define interface MathApi + fnc add(int a, int b) => int; + String name; + end +end + +@AdheresTo(contracts.MathApi) +define module math + pub fnc add(int a, int b): int { return a + b; } + pub val String name = "math"; +end +``` + +Only exported (`pub`) module members satisfy an adherence contract. `@AdheresTo(A, B)` may name more than one interface. + +## Functions and returns + +Functions use `fnc` and are private by default. `pub` exports them. Return statements are always explicit; a non-`void` function must return on every control-flow path. + +Class fields, instance methods, and `static fnc` members are also private by default unless marked `pub`. Private class-member access is scoped to the **declaring class**, not to a particular receiver instance: code declared in class `A` may access an `A` private member on another `A` instance, but subclasses and external callers may not. A lexical lambda created inside an `A` method retains that private-access authority with its lexical environment; an explicit or inherited `nlex` lambda does not. Runtime member dispatch enforces the same rule for dynamically linked/wildcard-imported values whose static type is `Unknown`, so imports cannot bypass private visibility. Public/structural class shapes expose only public members. + +Named executable callables may spell their return type with either `: T` or +the executable slim arrow `-> T`; both forms are equivalent: + +```ores +pub fnc run() -> (() => void) { + return || -> { + return; + }; +} +``` + +The equivalent lambda-style declaration also uses executable `->` syntax: + +```ores +pub fnc run = || -> (() => void) { + return || -> { + return; + }; +} +``` + +Here `() => void` is a function **type**, while `->` is executable syntax. +The fat arrow `=>` is never the return separator for an executable +declaration; it remains type-level syntax. + +```ores +fnc add(int a, int b): int { + return a + b; +} + +@Ret +fnc answer() { + return 42; +} +``` + +`@Ret`, `: T`, and `-> T` declare the same return type. If more than one form is present they must agree. A function returns exactly one value; multiple logical values are represented by a tuple, array, object, class value, or another aggregate. + +Return types may be unions, homogeneous arrays, finite tuple types, or structural record types: + +```ores +type intOrBoolOrString = bool | int | string; + +fnc mixed(): Array { + return [3, true, "yes"]; +} + +fnc fixed(): [int, bool, string] { + return [3, true, "yes"]; +} + +fnc named(): {foo: int, bar: string} { + return obj{foo: 5, bar: "x"}; +} +``` + +The `type` marker inside `Array` is accepted as an explicit alias marker; `Array` is equivalent. A finite tuple type records exact arity and the type of each position even though the interpreter represents the value with a JVM `List`. A record type names required members; extra members remain compatible with the structural type system. + +## Bindings + +Every local binding is declared as exactly one of: + +- `const`: compile-time constant; cannot be reassigned. +- `val`: runtime single-assignment binding; cannot be reassigned. +- `let`: mutable binding and the only local binding kind that may be reassigned. + +```ores +const max = 10; +val request_id = process.context_id; +let retries = 0; +retries = retries + 1; +``` + +Destructuring carries mutability per element. A binding kind propagates through later unqualified names until another explicit binding kind appears. A binding kind may also prefix the whole pattern. + +```ores +[const number, flag, answer] = fixed(); // all const +const [x, y, z] = fixed(); // all const +[const head, let middle, tail] = fixed(); // head const; middle/tail let + +const {foo, bar} = named(); +// Equivalent per-item spelling, shown in a separate scope: +// {const foo, const bar} = named(); +``` + +A bare `_` is a sequence discard pattern: it consumes that array/tuple position without declaring a variable, so it can be repeated in the same sequence pattern or reused by later destructures. Object patterns do not accept bare `_` because object destructuring is key-based rather than positional. + +```ores +[const code, _, let body] = (200, "ignored", "ok"); +[const next, _, let tail] = (201, "ignored again", "done"); +[_, _, const final] = (1, 2, 3); +``` + +`_` is not readable after the destructure because no lexical binding is created for it. A destructured `const` is an immutable runtime binding; unlike a standalone `const x = ...` declaration, the aggregate being destructured does not need to be a compile-time constant. + +Sequence destructuring requires a returned tuple or array/list. Finite tuples are checked for exact arity and per-position type. Object destructuring requires a record/map-like value and every requested key must exist. If the returned type is a union, destructuring is allowed only when every union alternative supports the requested pattern; each extracted binding receives the union of the corresponding alternative member types. Function-parameter destructuring is intentionally not part of this syntax yet. + +## Classes, receivers, multiple inheritance, and interfaces + +Class headers use `as` as the required body delimiter. The canonical form is `define class Name as ... end`; when `extends` or `implements` are present, `as` follows the complete class header. + +Methods omit `fnc`. Instance methods always have an implicit receiver named `self`. + +```ores +define class Box as + val T value; + + @Ret + identity() { + return self; + } +end +``` + +The explicit receiver form remains available: + +```ores +find(self Box)(int key): self { + return self; +} +``` + +The receiver variable name is always `self`. + +A class may list multiple parent classes and multiple interfaces: + +```ores +define class Combined extends Cacheable, Serializable implements HasId, Named as +end +``` + +Parent order is significant and is the deterministic v0.2 method-resolution order after child methods: the first declared parent is searched before the next parent. The static checker rejects inheritance cycles and incompatible inherited member shapes. Child **methods** may override inherited methods only with compatible types. Storage fields are not virtual slots: a field name must be unique across the effective inheritance graph, so child fields may not shadow inherited fields and two distinct parent fields may not collide. Reaching the same field declaration twice through a diamond is not a collision. + +`Object` and `List` are extensible base classes: + +```ores +define class RecordBag extends Object as +end + +define class Names extends List as +end +``` + +Inline object and array literals are values, not classes, and cannot be inherited from. + +## Inline values + +Structural inline object: + +```ores +val user = obj{name: "Ada", age: 37}; +stdio.println(user.name); +``` + +Static object/map keys may be identifiers, reserved member keys such as +`stop`/`do`/`done`, or strings written with either single or double +quotes. Backticks make the key dynamic: the expression between the backticks +must evaluate to a string. + +```ores +val key = "score"; +val stats = obj{ + stop: 1, + 'do': 2, + "done": 3, + `key`: 4 +}; +``` + +An `obj{...}` containing a dynamic key has type `DynamicStruct`, where +`T` is the joined value type. A `DynamicStruct` can also be created +directly with `new DynamicStruct()`; it accepts arbitrary string keys but +only values assignable to `T`. + +Inline array: + +```ores +val values = arr[10, 20, 30]; +val first = values[0]; +``` + +`arr[...]` is the canonical inline-array spelling. The original bare `[...]` literal remains accepted for source compatibility and destructuring migration. + +Tuples preserve per-position static types. Parenthesized tuple literals and list-backed values returned against a finite tuple type both retain the declared positional types: + +```ores +val pair = (1, "one"); +[const number, let label] = pair; + +fnc result(): [int, bool, string] { + return [3, true, "yes"]; +} + +const [num, ok, answer] = result(); +``` + +## Structural typing and interfaces + +Interfaces are structural contracts. Explicit `implements` asks the compiler to prove conformance and documents intent; structural compatibility does not require nominal ancestry in every context. + +```ores +define interface Named + String name; +end + +define class User implements Named as + pub val String name; +end +``` + +Class interface satisfaction uses public members, including inherited public members. + +## Option, Result, and null + +Oreslang does **not** have ambient nullable references. A bare `null` value is a compile-time error, and `null` is not a standalone variable/parameter/return type. + +Optionality is explicit, using Rust-style `Some(value)` and `None`: + +```ores +fnc lookup(bool found): Option { + if found; do + return Some(42); + else + return None; + fi +} +``` + +Fallible operations use `Result`, constructed with `Ok(value)` or `Err(error)`. `Result` always has exactly two explicit type arguments. + +```ores +val Option present = Some(42); +val number = present.unwrap(); + +val Option missing = None; +val safe = missing.unwrap_safe(); // Err(OptionUnwrapError(...)) + +val Result parsed = Ok(123); +val same = parsed.unwrap_safe(); // Ok(123) +``` + +- `Option.unwrap(): T` returns the `Some` payload and panics on `None`. +- `Option.unwrap_safe(): Result` never panics for absence. +- `Result.unwrap(): T` returns the `Ok` payload and panics on `Err`. +- `Result.unwrap_safe(): Result` never panics; it preserves the error-as-value carrier. +- `expect(String)` is the descriptive panicking form; `unwrap_or(T)` supplies a fallback. +- `is_some()/is_none()` and `is_ok()/is_err()` inspect variants without extraction. + +Like Rust methods that take `self`, extraction consumes a move-only `Option` or `Result`. `Option` is `Copy` exactly when `T` is `Copy`; `Result` is `Copy` exactly when both payload types are `Copy`. + +Owned sum values cannot hide lexical borrows until explicit lifetime parameters exist, so `Some(&value)`, `Ok(&value)`, and `Err(&value)` are rejected. + +Panics are distinct from ordinary recoverable errors. Normal `try/catch` does not swallow an unwrap panic, while lexical cleanup and `finally` still execute during unwind. Use `unwrap_safe()`, matching, or explicit variant inspection when absence/failure should remain data. + +`Option` is accepted only as an explicit type-level escape hatch when an interoperability boundary truly needs to preserve a null marker. The `null` marker cannot escape that direct `Option` position. `Option` is invalid; use `void` when a function returns no value. + +## Numbers + +Built-in numeric families include integral, floating, decimal, and complex types. Imaginary literals use `i`: + +```ores +const complex z = 3 + 4i; +``` + +Numeric widening is loss-aware; real values can widen toward complex values, but silent lossy narrowing is not performed. + +## Lambdas + +Lambdas are lexical closures by default and use `->`. The canonical block +form keeps returns explicit: + +```ores +val Fnc inc = |int x| -> { + return x + 1; +}; +``` + +A normal lambda may capture activation-local bindings from its enclosing +function or block. Captured mutable state remains part of the closure. + +## Non-lexical callables (`nlex`) + +`nlex` is an opt-in **capture barrier**, not a ban on global/module lookup. +It prevents a callable from capturing bindings owned by an enclosing runtime +activation, so an `nlex` lambda does not retain or snapshot an outer local +environment. + +Inside an `nlex` region: + +- parameters and locals declared inside the callable remain available; +- locals shadow module/global/import bindings normally; +- module members, imports, top-level callables/classes, and built-ins remain + statically resolvable; +- enclosing activation-local bindings cannot be captured; +- lambdas nested in an `nlex fnc`, `nlex routine`, or `nlex` lambda inherit + the barrier. + +Actor entry points remain governed by their actor isolation rules. `nlex` may +add a capture-free guarantee to an actor fnc, but it does not replace mailbox, +private-slice, or shared-actor isolation. + +## Conditionals + +`fi` is a real, distinct conditional terminator. It is not an alias for module/class `end`. + +```ores +if ready, authorized | process.is_admin; do + return serve(); +elseif retryable; do + return retry(); +else + return reject(); +fi +``` + +Within a condition, comma means AND and `|` means OR. Comma binds more tightly. + +## Exceptions and defer + +Both structured exceptions and lexical `defer` are supported: + +```ores +try { + risky(); +} catch (err) { + recover(err); +} finally { + cleanup(); +} +``` + +`defer` executes in LIFO order when its lexical scope unwinds, including returns and exceptional exits. + +## Callable-only reserved keywords + +`stop`, `do`, and `done` are reserved language keywords. They cannot be used as ordinary identifiers for bindings, parameters, fields, types, classes, modules, or bare function references. + +They have one narrow compatibility exception: the three words may be declared as callable names and used when invoking that callable. This includes `fnc`/`routine` declarations, class/actor methods, interface function signatures, `import fnc` selections, direct calls such as `stop()`, and qualified calls such as `worker.done()`. + +The exception does not turn the keywords back into general identifiers. For example, `val stop = 1`, `fnc f(int do)`, `val callback = done`, and `val callback = worker.stop` are invalid. + +## Async / await + +Oreslang follows the useful parts of the C# Task-based Asynchronous Pattern while keeping ownership/isolation stricter than a shared managed heap: + +- an `async fnc` or `async routine` is typed as returning `Future`, where `T` is the declared source return type; +- calling an async callable returns the future immediately; `await` unwraps its result and propagates cancellation and the original failure rather than leaking a host-specific wrapper exception; +- `async main` is allowed and is awaited exactly once at the process boundary; +- async task arguments and results cross an owned-data boundary. Move-only arguments are consumed by the async call under Oreslang's normal affine rules; the reference interpreter then detaches supported data graphs before execution/completion so no hidden caller/task mutable alias is introduced. Copy-like scalars retain their ordinary copy semantics; +- futures, mutex/guard capabilities, functions/closures, unresolved generic values, actor instances, and host capabilities cannot cross that detached task boundary; +- generic async callables are temporarily rejected until Oreslang has an explicit task-safe/sendable generic bound; +- async instance methods are temporarily rejected until receiver move/borrow semantics are explicit. Use an async top-level/module callable or async static class function instead; +- an async actor callable is also rejected for now. Actor `await` needs compiler continuation lowering that suspends a mailbox turn and later resumes it; an actor dispatcher carrier must never park on an incomplete future. + +The initial interpreter backend uses host-owned virtual threads for ordinary async tasks. That is an implementation detail, not a language promise. The compiler is free to replace it with C#-style continuation/state-machine lowering. Guest source receives no raw thread handle and does not gain `THREAD_CREATE` authority merely by using `async`. + +This preserves the central async rule: **I/O/task latency should compose through futures and continuations; CPU-bound work that intentionally monopolizes a carrier must be explicit rather than hidden inside `async`.** + +## Actors + +Oreslang uses an Akka-style dispatcher model: an actor is **not** a thread. Every actor owns one mailbox, and at most one mailbox turn for a given actor may execute at a time. Actors are multiplexed over bounded thread pools, so the carrier thread may change between turns. + +There are two actor execution domains: + +```ores +pub actor fnc worker(int value): int { + return value; +} + +shared actor Account { + let int balance = 100; + + pub fnc withdraw(int amount): void { + self.balance = self.balance - amount; + return; + } +} +``` + +- an unqualified `actor` is **private**; +- `shared actor` is a **shared-memory-capable** actor; +- private and shared actors are scheduled on **different dispatcher pools** for bulkheading; +- compiler-generated/context-aware actor factories are capture-free for **both** actor kinds; mutable host state must enter through messages or explicit runtime-owned capabilities rather than Java closure capture; +- trusted host embedding has separately named supervisor-only construction escape hatches, and adversarial policies reject them; +- both kinds still process their own mailbox serially; +- actor-owned `let` fields may mutate during a mailbox turn because that turn is the exclusive mutation capability for `self`; +- no lock is required around ordinary actor-owned fields, including fields of a shared actor; +- actor `self` and move-only state rooted at `self` cannot escape the mailbox turn by value or returned borrow; copy-like values such as integers, booleans, and strings may be returned normally; +- synchronized shared memory requires the host-granted `SHARED_MEMORY` capability. + +Private actors do not accept explicitly shared mutable memory. Each private actor owns a **confined memory slice** identified by its actor id, independent of whichever dispatcher thread happens to execute a mailbox turn. Incoming messages are isolation-copied into that actor domain and charged against the destination slice before mailbox admission. Compiler-managed actor state allocations use the same slice. + +The slice has two simultaneous limits: + +- a per-actor limit from that actor's `IsolatePolicy.maxHeapBytes()`; +- an aggregate private-actor memory budget from the parent runtime policy. + +This prevents many private actors from multiplying the parent's memory ceiling. Destroying the actor closes its slice and releases its accounting. + +The JVM backend's slice is a language/runtime ownership and accounting boundary, not a separate Java GC heap. The slice follows the actor id across dispatcher workers; it is not thread-local state. When physical heap separation is required for adversarial tenant code, the same private-actor semantics must be backed by a cross-thread-capable private region or a separate Graal polyglot/native isolate. + +Shared actors may additionally receive: + +1. deeply immutable `Shared` values; and +2. explicit synchronized shared cells. + +The runtime primitive for the second case is `SyncCell`. A cell stores only frozen state and serializes replacement updates under a lock. Shared-cell state is quota-accounted against the same parent actor-memory ceiling as private actor slices. Private actor turns cannot create, inspect, mutate, or close a `SyncCell`. This is the intended lowering target for a future `sync` language construct; `sync` is **not** an implicit lock around actor methods. + +Actor message graphs are cyclicity-checked and bounded by nesting depth, node count, and logical byte quotas before admission so malicious container graphs cannot turn actor transport into unbounded recursion, CPU, or memory use. + +This preserves the central invariant: + +> Actor state is mutated through mailbox ownership. Shared mutable state outside an actor is exceptional and must use an explicit synchronization abstraction. + +Arbitrary mutable host objects remain invalid actor messages. Actor kind is part of the public ABI, so changing a normal callable/class into a private or shared actor invalidates dependent compiled units. + +Shared writable handles use transactional publication. A `SharedMutex` is reserved to the destination runtime before mailbox visibility, committed only after queue admission, and unbound again when first publication fails. This prevents failed sends from accidentally claiming a writable capability for the wrong runtime. + +## Isolates + +An isolate is stricter than an actor and is intended as a FaaS/tenant security boundary. Strict isolate contexts deny host reflection, native access, arbitrary filesystem/IO, child-process creation, guest-created threads, environment access, and unrestricted polyglot access unless an explicit capability is granted by the host. + +Actors may run inside an isolate. Actor semantics never weaken isolate policy. + +## Built-in globals + +`process` is an Oreslang runtime descriptor/capability facade, not unrestricted OS process access. `stdio` is capability-scoped standard IO. `print(value)` is shorthand for the output facade. + +## Compiler pipeline + +1. UTF-8 source -> lexer. +2. lexer -> parser / AST. +3. imports and declarations are collected without executing user code. +4. generic, structural, module-interface, inheritance, mutability, and return-flow checks run. +5. actor/isolate sendability constraints are enforced at relevant runtime boundaries. +6. checked source is lowered/executed as Truffle guest code. + +The parser and static checker execute no user code. + + +## File-level entrypoints + +Named modules remain the normal namespace unit, but a source file may also contain file-level callables such as an entrypoint. The compiler places those declarations in an internal file-root namespace; that namespace is not written by user code. + +```ores +define module x + define class y as + end +end + +pub routine main(): void { + val y = new x.y(); + stdio.stdout.write(y) +} +``` + +Qualified names such as `x.y` retain their module namespace. + +## `fnc` versus `routine` + +`fnc` and `routine` may both recurse. Recursion and tail-call optimization are not what distinguishes them. Eligible calls in tail position in `fnc`, `routine`, instance methods, `static fnc`, and lambda bodies are lowered as **proper tail calls**: they do not grow the Oreslang/host call stack. This is a runtime guarantee shared by JIT, Native Image AOT, and hybrid execution; it does not depend on the host JIT discovering recursive-call optimization. + +A tail call is eligible only when the current activation has no semantic work that must remain live after the call. Active `defer`/catch/finally cleanup and live mutex guards are tail-call barriers; in those cases the call executes normally so cleanup and return validation remain correct. `await` is also a scheduler/continuation boundary rather than a direct proper-tail-call hop: async-to-async composition uses `return await other_async();`, while `return other_async();` is rejected because the latter expression has type `Future`, not source return type `T`. Conditional return arms inherit tail position, so `return cond ? f() : g();` may tail-transfer through the selected arm. + +The runtime resolves the target and arguments before releasing the caller, then transfers through an iterative trampoline. Every 64 tail transfers it executes a scheduler safepoint so a long recursive chain cannot bypass OresVM scheduling/fairness. Reified Oreslang `fnc`/lambda values are tail-transferable while they remain in the evaluator/code unit that established their static contract; arbitrary host/interop callables complete before the caller is released. + +A linked call that crosses into another source code unit through an import whose signature is currently represented as `Unknown` is also a tail-call barrier. The caller remains live until that imported call returns so its declared runtime return-shape check cannot be skipped. Once execution is inside the imported unit, its own same-unit tail-call chain still uses the trampoline. Cross-unit proper-tail transfer can be re-enabled when the linker carries typed imported ABI contracts rather than `Unknown`. + +The distinction is **reifiability**: + +- a named `fnc` is a first-class callable value. It may be stored in a `Fnc<...>` binding, passed as a callback, or returned when its type matches; +- a `routine` is direct-call-only. `run_app()` is valid, but evaluating `run_app` as a value is a compile-time error; +- an instance/actor method is likewise direct-call-only. This also applies when the receiver is typed through a nominal interface or an `@Structural` contract: `worker.process(x)` is valid, but `worker.process` is not a bound-method value; +- `static fnc` and lambdas are reifiable first-class callables; +- module aliases preserve the same distinction: a public non-generic `fnc` remains a function-valued member, while a `routine` remains direct-call-only even after `val api = some_module`; +- a field whose declared value is `Fnc<...>` is callable data, not a method. `box.callback(x)` invokes that field when no method named `callback` exists, and `box.callback` may be reified normally. + +Field/binding names and instance-method names may not share the same base name on a class or interface, including through inheritance. Module runtime value members likewise share one base-name namespace across callables, classes, and bindings. These restrictions keep `x.name` and `x.name(...)` from silently selecting different semantic categories. + +When a callback must invoke a routine or instance method, make the closure explicit: + +```ores +routine rebuild(int value): void { + // ... +} + +define class Worker as + pub process(int value): void { + // ... + } +end + +fnc useCallbacks(Worker worker): void { + doWork(|int value| -> { + rebuild(value); + }); + + doWork(|int value| -> { + worker.process(value); + }); +} +``` + +This rule keeps ordinary routine/method calls as direct code-symbol dispatch. The runtime does not manufacture an implicit `(receiver, method)` bound-method object; a closure exists only when source code explicitly asks for one. Because a `routine` cannot escape as a callback value, the compiler is also free to inline, specialize, and devirtualize routine calls more aggressively; that optimization freedom is a consequence of direct-only semantics, not a separate recursion or TCO rule. + +Lambdas may recurse when their binding supplies an explicit function type so the closure's own signature is available while its body is checked: + +```ores +let Fnc fact = |int n| -> { + return n == 0 ? 1 : n * fact(n - 1); +}; +``` + +## Semicolons + +Semicolons are strongly recommended. They remain the canonical formatter output. + +They may be omitted only where the parser has an unambiguous structural boundary, such as the final expression immediately before `}`, `fi`, or `end`. Oreslang does not use broad JavaScript-style automatic semicolon insertion. + +```ores +pub routine main(): void { + stdio.stdout.write("done") +} +``` + +## Nominal typing and opt-in structural parameters + +Named classes and interfaces are nominal by default. Structural matching at an API boundary is explicit with `@Structural`: + +```ores +pub interface Brand { + markerBrand: 'marking/branding' +} + +fnc consume(@Structural Brand value): String { + return value.markerBrand; +} +``` + +A value does not need to nominally implement `Brand` for that parameter, but its public/static shape must satisfy the interface. Without `@Structural`, the normal nominal implementation/inheritance rules apply. + +Interfaces may inherit from other interfaces and support literal-string marker fields: + +```ores +pub interface Bar { + markerBrand: 'marking/branding' +} + +pub interface Foo extends Bar { +} +``` + +Explicit `implements` and module `@AdheresTo(...)` checks remain structural conformance proofs. + +## Method overloads + +Only methods overload, and only by arity: + +```ores +define class Lookup as + find(): Option { + return None; + } + + find(int id): Option { + return Some(id); + } +end +``` + +Two methods with the same name and same arity are a compile-time error even when their parameter types differ. Top-level/module `fnc` and `routine` declarations never overload. + +## Ternary expressions + +The ternary operator is right-associative and lazy in its selected branch: + +```ores +fnc find(bool found): Option { + return found ? Some(42) : None; +} +``` + +## Standalone lexical blocks and conditional bodies + +A standalone lexical scope is explicit: + +```ores +block { + val hidden = "local"; +} +``` + +`block { ... }` creates a new lexical scope. Oreslang has no declaration hoisting; bindings declared in the block are not visible after it. The block has no scheduler or concurrency semantics of its own. + +Conditionals support two equivalent body styles. Brace form: + +```ores +if condition { + work(); +} elseif other_condition { + recover(); +} else { + fallback(); +} +``` + +Keyword-delimited form: + +```ores +if condition then + work(); +elseif other_condition then + recover(); +else + fallback(); +fi +``` + +The older `do ... fi` spelling remains accepted for source compatibility, but `then ... fi` is canonical for keyword-delimited conditionals. + +## Loops, iterators, and scheduler safepoints + +Oreslang supports an explicit infinite loop with either braces or `do ... done`: + +```ores +loop { + if should_skip() { + continue; + } + if should_stop() { + break; + } + work(); +} + +loop do + if should_skip() { + continue; + } + if should_stop() { + break; + } + work() +done +``` + +`break` exits the nearest enclosing `loop` or `for`. `continue` starts the next iteration of the nearest enclosing loop. `return` exits the enclosing callable, even when nested inside one or more loops. Loop control never crosses a function or lambda boundary. + +Oreslang also supports conventional imperative loops. Parentheses are optional when the semicolon-delimited C-style header is unambiguous: + +```ores +for (let i = 0; i < 10; i++) { + work(i); +} + +for int i = 0; i < 30; i++ do + work(i) +done +``` + +In the typed shorthand, `int i = 0` creates an implicit mutable `let i: int` scoped to the loop. `i++` and `i--` are accepted in the for-update clause and lower to increment/decrement assignment of that simple local binding; Oreslang does not currently expose them as general field/index postfix expressions. + +and iterator-style loops. The compact `of` form does not require parentheses, and both body styles are valid: + +```ores +for item of values do + work(item) +done + +for [key, value] of entries do + consume(key, value) +done + +for let [key, value] of mutable_entries { + value = normalize(value); + consume(key, value); +} + +for (val item of values) { + work(item); +} +``` + +A sequence pattern defaults to `val` bindings. `for let [k, v] ...` or `for const [k, v] ...` applies that binding kind to the pattern, while an explicit kind inside the pattern propagates to subsequent names. `_` discards one tuple/list position without creating a binding. + +A bare `done` closes a `do` loop body. An invocation such as `done()` inside that body remains an ordinary callable use and does not terminate the loop. + +Classes can expose a JavaScript-like iterator symbol: + +```ores +define class Bag as + [Symbol.iterator](): Array { + return arr[1, 2, 3]; + } +end +``` + +The compiler/runtime inserts a scheduler safepoint on **every `loop`, conventional `for`, and iterator-loop iteration**. The current runtime hook checks cancellation/interruption and yields execution; it is intentionally centralized so actor supervisor/control-mailbox polling can evolve without changing source syntax. User code does not receive ambient thread-control capability. + +This means Oreslang does not require recursion as the only way to loop, while still giving actor/isolate schedulers a compulsory cooperation point inside generated loop execution. + +## Standard output + +In addition to `stdio.print` and `stdio.println`, the stream-shaped form is available: + +```ores +stdio.stdout.write(value); +stdio.stdout.println(value); +``` + + +## Execution profiles: JIT, AOT, and hybrid + +The same Oreslang source model supports three deployment profiles: + +- **JIT** — normal GraalVM/JVM host with Truffle JIT available. +- **AOT** — Native Image host with the Truffle interpreter retained and guest JIT disabled. This is the conservative mobile/FaaS profile and still supports source hot reload because new Oreslang source is data consumed by the precompiled interpreter. +- **HYBRID** — Native Image host plus Truffle guest JIT on targets where executable-code generation is permitted. + +The CLI accepts `--mode=jit|aot|hybrid` and `--platform=server|windows|macos|linux|android|ios`. The iOS execution contract is intentionally AOT-only. Source hot reload does not depend on executable dynamic libraries, JNI, or NFI. + +Maven profiles: +- `mvn -Pnative-aot -DskipTests package` +- `mvn -Pnative-hybrid -DskipTests package` + +## Capability-secure isolates + +Security is layered. Oreslang uses a deny-by-default language capability policy **in addition to** Graal/Native Image isolation and the host OS/mobile sandbox. + +An isolate policy can independently allow or deny: + +`STDIN`, `STDOUT`, `PROCESS_INFO`, `ACTOR_SHARE_READONLY`, `SHARED_MEMORY`, `NETWORK`, `FILESYSTEM_READ`, `FILESYSTEM_WRITE`, `ENVIRONMENT`, `HOT_CODE_LOAD`, `FFI`, `NATIVE`, `REFLECTION`, `CHILD_PROCESS`, `THREAD_CREATE`, and `POLYGLOT`. + +The trusted compiler API can reject forbidden API usage before execution: + +```java +OresCompiler.validateForIsolate(source, policy); +``` + +Runtime facades perform the same check again. A source file therefore cannot grant itself a capability. The launcher/supervisor chooses policy. + +The strict FaaS baseline permits only stdout. Host reflection, native access, unrestricted polyglot access, environment access, guest-created threads, and host IO remain disabled at the Graal context boundary. + +Actor cells may receive a policy stricter than their parent runtime. Their mailbox capacity is also bounded by that policy. + +## Hot reload without FFI + +`HotReloadManager` loads each code revision into a new versioned Polyglot context/generation: + +1. source arrives as data; +2. syntax/type/capability checks run; +3. a fresh restricted guest context is created; +4. the validated generation is staged and atomically becomes active without executing guest code; +5. the supervisor explicitly starts the generation when its actor/request boundary is ready; +6. the previous generation may remain alive while requests/actors drain; +7. the supervisor explicitly retires it. + +Each generation receives a monotonically increasing id and SHA-256 source digest. + +This model does not require `dlopen`, `LoadLibrary`, JNI, or Truffle NFI. A production server may additionally map each context to a Graal polyglot/native isolate. On AOT-only targets the precompiled interpreter executes newly loaded Oreslang source; on JIT-capable targets the same source may warm into optimized machine code. + +## Explicit structural calls + +Structural compatibility is never silently enabled for a nominal parameter. These three spellings are equivalent: + +```ores +pub interface Bar { + marker: 'brand' +} + +pub interface Foo extends Bar { + markerBrand: 'marking/branding' +} + +fnc a(@Structural Foo y): void { + return; +} + +fnc b(y structural Foo): void { + return; +} + +@AllowStructural(y) +fnc c(y Foo): void { + return; +} +``` + +All three may accept: + +```ores +val branded = obj{ + marker: "brand", + markerBrand: "marking/branding" +}; + +a(branded); +b(branded); +c(branded); +``` + +Without one of those explicit structural opt-ins, passing that object to a nominal `Foo` parameter is a compile-time error. + +`structural` is a contextual keyword, so existing identifiers named `structural` remain legal elsewhere. + +## Receiver identity and method calls + +`self` is injected by the compiler/runtime as an immutable receiver binding. It cannot be declared as a local parameter name or reassigned. + +Direct method calls do not create per-instance closures: + +```ores +box.get(); +``` + +The runtime resolves the shared class method definition and passes the receiver as the hidden first argument. + +Instance and actor methods are intentionally **not** first-class values: + +```ores +val Fnc callback = box.get; // compile-time error +``` + +When callback behavior is required, the receiver capture must be explicit: + +```ores +val Fnc callback = || -> { + return box.get(); +}; +``` + +The lambda has ordinary closure-capture semantics; the method itself remains one shared class definition. Oreslang therefore has no implicit bound-method object and no JavaScript-style dynamic `this` rebinding. + + +## Incremental compilation and code units + +Oreslang's canonical compiler output is **decomposable**. A monolithic native executable is a packaging choice, not the semantic compilation unit. + +Each source file is a separately versioned **code unit**: + +- source digest; +- checked AST / future serialized Ores IR; +- explicit import dependencies; +- package identity; +- zero or more flat modules; +- optional flat source namespace. + +With no explicit namespace, the file/code-unit identity is its default package identity. An explicit namespace is written once at the top of the file: + +```ores +namespace payments; + +import fnc {authorize} from "./auth.ores"; + +pub fnc charge(): void { + return; +} +``` + +Namespaces are flat. `namespace company.payments;` is illegal. Modules are also flat: a module name is one identifier and a module may not contain another module. + +The incremental compiler uses separate **source** and **ABI** digests: + +1. hash every source unit; +2. derive a deterministic exported ABI digest from public functions/bindings, class public members and static functions, interfaces, type aliases, inheritance, structural markers, and module adherence contracts; +3. rebuild a unit whenever its source or resolved dependency set changes; +4. when a dependency ABI digest changes, invalidate the transitive reverse-import closure conservatively; +5. reuse every importer artifact across implementation-only dependency edits; +6. keep unchanged/unaffected compiled-unit objects intact. + +Public inferred bindings are fingerprinted conservatively from their initializer AST until the compiler materializes their inferred exported type in the unit manifest. + +This separates code-generation dirtiness from public-contract dirtiness. An implementation edit such as changing a function body from `return 42;` to `return 43;` recompiles that file without recompiling importers when the exported signature is unchanged. Until the cross-unit linker records exact public imported-symbol dependencies, ABI changes intentionally propagate transitively for correctness. + +Actors and isolates consume versioned code-unit generations. `HotReloadManager` tracks the active generation **per code-unit id**, so staging `worker.ores` does not replace the active `helper.ores` generation. A hot reload therefore does **not** require rebuilding or replacing every actor: changed units receive new generations, unchanged units remain active/shared, and supervisors migrate actors/requests according to policy. + +A deployment may still aggregate many code units into one Native Image for startup/distribution reasons. That aggregate is never the only compiler artifact and must not erase per-unit identities or dependency metadata. + +## Static class functions + +Instance methods continue to omit `fnc`: + +```ores +define class Counter as + read(): int { + return self.value; + } +end +``` + +Class-level functions are not methods. They are declared with the explicit `static fnc` form: + +```ores +define class Counter as + pub static fnc twice(int value): int { + return value * 2; + } +end + +val doubled = Counter.twice(21); +``` + +A static class function: + +- is resolved through the class namespace; +- has no implicit or explicit `self`; +- cannot be invoked through an instance; +- may be extracted as a function value from the class namespace; +- has one shared definition, just like any other named function. + +Static data fields are intentionally not part of v0.5 yet; `static` on a class binding is rejected rather than silently acquiring Java-like global mutable state semantics. + +## Function types, functors, and arrows + +The arrows have distinct jobs: + +- `:` declares the return type of a **named executable callable/method**. +- `->` is executable syntax for lambdas and lambda-style callable declarations. +- `=>` is type-level syntax for function types and interface callable signatures. + +Function aliases can use `typeof fnc`: + +```ores +type F = typeof fnc() => int; +type Predicate = typeof fnc(bool value) => bool; +``` + +The shorter inline function type is also valid: + +```ores +fnc sink(): ((bool foo) => void) { + return |foo| -> { + stdio.println(foo); + return; + }; +} +``` + +Parameter names inside function types are documentation-only; structural function compatibility is determined by parameter/result types. + +The canonical lambda syntax is pipe-delimited and block-only: + +```ores +fnc find(bool found): F { + return || -> { + return found ? 5 : 6; + }; +} + +fnc callback(): ((bool foo) => void) { + return |foo| -> { + stdio.println(foo); + return; + }; +} +``` + +Lambda parameters may be inferred from a contextual function type (`|foo|`) or typed explicitly (`|bool foo|`). + +There are no expression-body lambdas. Every lambda has braces. When the contextual result type is non-void, every control-flow path must contain an explicit `return ;`. Void lambdas may use `return;`. + +This means higher-order functions and functors do not introduce a second return convention: named functions, methods, static functions, and anonymous functions all use the same explicit `return` statement semantics. + + +## Lexical closures + +Closures are lexical. A lambda resolves free variables from the scope where the lambda is created, not from the scope where it is called. + +```ores +fnc makeCounter(): (() => int) { + let int count = 0; + + return || -> { + count = count + 1; + return count; + }; +} +``` + +The returned closure owns the captured lexical environment, so repeated calls observe the same captured `count`. + +Capture rules are ownership-aware: + +- immutable `Copy` captures are copied into the closure environment; +- non-`Copy` captures transfer ownership into the closure; +- a capture that the closure mutates also transfers the mutable lexical slot into the closure; +- after a move-only/mutable capture is transferred, the outer binding cannot be used; +- an already borrowed value may not be captured by an escaping closure; pass the borrow as a lambda parameter or capture the owner by value. + +This makes returned closures safe without retaining raw stack references. + +## Parameter immutability and `mut` + +Parameters are immutable by default. + +```ores +fnc bad(Bar b): void { + b.foo = "foobar"; // compile-time error + return; +} +``` + +An owned parameter may explicitly opt into mutation by putting `mut` between the type and parameter name: + +```ores +fnc change(Bar mut b): Bar { + b.foo = "foobar"; + return b; +} +``` + +`Bar mut b` still receives `Bar` **by value**. For a non-`Copy` value, the caller transfers ownership to `change`; returning the value transfers ownership back. + +Local mutation continues to use `let`. `val` and `const` remain immutable. + +Class fields follow the same bias: a field declared with `val` or `const` cannot be assigned after construction. Mutable object state must use a `let` field and mutable access to the owning value. + +## Ownership, moves, and borrows + +Oreslang uses Rust-style affine ownership for mutable/heap-backed values. + +The initial `Copy` family is: + +- integer types; +- floating/decimal/complex scalar types; +- booleans; +- immutable strings. + +Class instances, arrays/lists, object records, and closures are move-only by default. + +A by-value binding, argument, or return consumes a non-`Copy` value: + +```ores +fnc consume(Bar value): void { + return; +} + +fnc example(): void { + let Bar b = new Bar(); + consume(b); + // b.foo; // compile-time error: use of moved value + return; +} +``` + +Shared immutable borrowing uses `&T`: + +```ores +fnc inspect(&Bar value): void { + stdio.println(value.foo); + return; +} +``` + +Exclusive mutable borrowing uses `&mut T`: + +```ores +fnc change(&mut Bar value): void { + value.foo = "changed"; + return; +} + +fnc example(): void { + let Bar b = new Bar(); + change(&mut b); + stdio.println(b.foo); // owner is usable again after the call + return; +} +``` + +Borrow rules: + +- any number of immutable borrows may coexist; +- a mutable borrow is exclusive; +- mutation/move of the owner is forbidden while any borrow is active; +- reading the owner is forbidden while an exclusive mutable borrow is active; +- mutable borrowing requires a mutable owner; +- a borrow of a local value may not escape the owner's lifetime; +- temporary call borrows end at the call boundary; +- borrows stored in local bindings remain active until that binding's lexical scope ends. + +The initial checker is deliberately conservative around complex branch/loop lifetime shortening. It rejects uncertain aliasing rather than silently accepting it. Later control-flow/NLL work may accept more programs without weakening these invariants. + +Structural parameters remain read-only views and therefore do not consume the supplied value. + +## Type refinement, pattern matching, and case dispatch + +Oreslang keeps four related operations separate: + +- `is` performs a nominal type test and flow refinement. `x is Dog dog` + binds `dog` only on the successful edge. +- `matches` tests a full pattern, for example + `if value matches Some(inner) then ... fi`. +- `as` is a checked cast; `as?` returns `Option`. +- `match` performs proof-checked pattern partitioning, while `switch` is + constant/equality case dispatch. + +Every `if` closes with `fi`, even when its branch bodies use braces. +Executable match/switch arms use `->`; `=>` remains type-level syntax. + +Plain `match` is exclusive-by-default. The checker proves every pair of +explicit arms disjoint and proves coverage, or rejects the program. A final +unguarded `else`, `_`, or catch-all binding represents the complement of +the preceding explicit arms. `match first` is the explicit ordered escape +hatch when priority is intended. + +Refinement and pattern bindings are ownership aliases, not copies. A move +through a narrowed alias consumes the same underlying move-only place. + +See [PATTERN_MATCHING.md](PATTERN_MATCHING.md) for the proof model and +[NATIVE_RUNTIME_ABI.md](NATIVE_RUNTIME_ABI.md) for backend requirements. + +## Multi-threaded targets + +Actors/isolate message passing remains the primary concurrency model, but the ownership contract is backend-independent. + +The same compiled program can target a secondary multi-threaded runtime because: + +- mutable state has one owner unless temporarily accessed through an exclusive `&mut` borrow; +- shared aliases are immutable; +- move-only values cannot remain accessible from both sides of an ownership transfer; +- closures cannot smuggle an outstanding stack borrow into a longer-lived task; +- actor messages continue to cross actor boundaries only through the existing frozen/sendable contract. + +When explicit thread/task spawning is added, cross-thread transfer will require move semantics and a `Send`-equivalent capability; shared cross-thread references will additionally require a `Sync`-equivalent guarantee. Those marker traits are intentionally a future surface feature—the current source language has no ambient raw-thread API, so there is no unchecked escape hatch to bypass ownership. + + +## Serialization annotations and generated accessors + +`@FromJson("key")` is a compiler annotation for typed class fields. It expands before type/ownership checking into public typed getters/setters; no JVM reflection or guest-code macro execution is involved. The field must have an explicit type and mutable storage. The name-first shorthand `field_name: Type` is mutable only when annotated with `@FromJson`; otherwise it is an immutable `val` field. + +Generated setters still require a mutable owner at the call site. Duplicate/blank keys, immutable annotated fields, accessor collisions, annotations on module bindings/callables, and annotations on actor state all fail closed. JSON wire keys participate in incremental ABI fingerprints. diff --git a/docs/MIXED_JAVA_ORES.md b/docs/MIXED_JAVA_ORES.md new file mode 100644 index 00000000..b23db39b --- /dev/null +++ b/docs/MIXED_JAVA_ORES.md @@ -0,0 +1,200 @@ +# Mixed Java / Oreslang source files + +Oreslang source identity is **filesystem-path based**. Do not add Java/Go-style source declarations such as: + +```ores +module demo; +``` + +A file's canonical Unix-style path is its code-unit identity and the base for relative dependency lookup. + +## Java inside an `.ores` file + +An `.ores` file is Oreslang by default. There are **two deliberately different Java forms**. + +`java` is treated as a contextual mixed-source keyword only in these brace forms; it is not being turned into a broad new Oreslang identifier ban. `do` is already a reserved Oreslang control-flow keyword. + +### `java { ... }`: inert declarations + +`java { ... }` declares Java types. The block is compiled, but its presence does not execute Java code. + +```ores +java { + final class JavaHelper { + public static String decorate(String value) { + return "[" + value + "]"; + } + } +} + +pub fnc decorate(String value): String { + // JavaHelper is automatically available to this .ores source unit. + return JavaHelper.decorate(value); +} +``` + +"Inert" means **declaration-only**, not inaccessible. The declared type is automatically imported into the surrounding Oreslang compilation unit and can be constructed or called normally. It only becomes active when ordinary Java semantics require it—for example when Oreslang constructs the class, calls a static method, or otherwise initializes the class. + +Merely compiling/linking this source does not initialize the declared class. The runtime loads mixed Java declaration classes with initialization disabled until they are actually used. + +A declaration island: + +- must appear at Oreslang declaration/source scope, not inside a callable body; +- currently declares exactly one top-level Java class, interface, record, or enum; +- cannot declare its own Java package; +- cannot use the compiler-reserved `__OresJavaDo...` type prefix; +- does not implicitly run a constructor, static method, initializer, or `main`. + +If Java is written inside executable Oreslang code, the explicit `do java` form is required. + +### `do java { ... }`: execute Java now + +`do java { ... }` is a statement-level execution island: + +```ores +java { + final class State { + private static int count = 0; + + public static void increment() { + count++; + } + + public static int count() { + return count; + } + } +} + +pub fnc main(): void { + stdio.println(State.count()); + + do java { + State.increment(); + } + + stdio.println(State.count()); + return; +} +``` + +The compiler lowers each execution island to an internal Java class equivalent to: + +```java +final class __OresJavaDo0 implements Runnable { + @Override + public void run() { + State.increment(); + } +} +``` + +and the Oreslang statement position behaves equivalently to: + +```ores +new __OresJavaDo0().run(); +``` + +The generated helper name is compiler-private and its prefix is reserved. + +This gives `do java` ordinary `Runnable.run()` semantics: + +- it executes exactly where the Oreslang statement appears; +- `run()` returns `void`, so a `do java` block cannot return a value; +- uncaught checked Java exceptions are rejected by javac because `Runnable.run()` does not declare checked exceptions; +- runtime exceptions propagate back through the Java/Ores interop boundary; +- a `do java` block cannot appear at source declaration scope. + +### No implicit Ores-local capture + +`do java` intentionally does **not** capture Oreslang lexical locals in this version. + +This is rejected by javac: + +```ores +pub fnc main(): void { + val value = 42; + + do java { + System.out.println(value); // no implicit capture of Ores `value` + } + + return; +} +``` + +This avoids inventing hidden boxing, ownership, lifetime, or cross-language mutation semantics. Java can instead communicate through: + +- Java types declared by `java { ... }`; +- arguments/results of normal Java methods invoked from Oreslang; +- public Oreslang functions exposed through the generated `Ores` bridge. + +For example, a Java declaration or `do java` block in the same generated package can call an exported Oreslang function through `Ores.some_function(...)`. + +## Oreslang inside a `.java` file + +A `.java` file is Java by default. Use an `ores { ... }` island: + +```java +import java.util.ArrayList; + +public final class MixedDemo { + public static void main(String[] args) { + var values = new ArrayList(); + values.add("java"); + + Object same = Ores.identity(values); + if (same != values) throw new AssertionError("identity changed"); + + System.out.println(Ores.count(values)); + } + + ores { + import class ArrayList as JArrayList from "java:java.util.ArrayList"; + + pub fnc identity(JArrayList value): JArrayList { + return value; + } + + pub fnc count(JArrayList value): int { + return value.size(); + } + } +} +``` + +The compiler generates a package-local `Ores` facade for public Oreslang functions. Primitive return types are converted to their Java equivalents; imported Java return types remain the Java class. `Ores.call("function_name", args...)` is always available as the dynamic escape hatch. + +## Object passing + +Inside the same trusted JVM/runtime domain, Java objects cross the Java/Ores boundary **by reference**. They are not serialized into Oreslang collections and reconstructed later. Returning the same Java object from Oreslang therefore preserves Java reference identity. + +Java host access is still capability checked. Imported Java classes require `JAVA_INTEROP` plus the exact host-class allowlist. Arbitrary Java source islands additionally require `JAVA_SOURCE_INTEROP`. + +Raw Java object references never become a mechanism for crossing an adversarial/untrusted isolate boundary. Private/adversarial isolation must use explicit messages, immutable copies, safe shared buffers, or capability handles instead. + +## Security and execution mode + +`java { ... }`, `do java { ... }`, and `ores { ... }` source islands are intentionally more privileged than a `java:` class import because compiled Java source can execute with ordinary JVM authority once invoked. `java { ... }` itself remains declaration-only; `do java { ... }` is the explicit execution form. + +For that reason: + +- `JAVA_SOURCE_INTEROP` and `JAVA_INTEROP` are both required. +- adversarial policies cannot acquire `JAVA_SOURCE_INTEROP`; +- private actors have Java-source and Java-host authority stripped; +- annotation processing is disabled while compiling source islands; +- source-island compilation currently requires `--mode=jit`; +- AOT/hybrid deployments must precompile the Java side instead of compiling arbitrary Java source at runtime. + +## Path lookup + +Relative Oreslang imports remain Unix-style filesystem lookups: + +```ores +import fnc hash from "./crypto/hash.ores"; +import class User from "../models/user.ores"; +``` + +Mixed source units may use an explicit `.java` path where that Java file contains an `ores { ... }` island. Extensionless resolution checks `.ores` first, then `.java`. + +There is no synthesized Oreslang module name. Canonical paths remain the linker/incremental-compiler identity. diff --git a/docs/NATIVE_RUNTIME_ABI.md b/docs/NATIVE_RUNTIME_ABI.md new file mode 100644 index 00000000..f928a094 --- /dev/null +++ b/docs/NATIVE_RUNTIME_ABI.md @@ -0,0 +1,89 @@ +# Native runtime ABI direction + +Oreslang source semantics must not depend on Java runtime classes. The current +Truffle implementation is a bootstrap/reference backend; native backends should +preserve the same contracts with Ores-owned metadata and native runtime +services. + +## Boundary rule + +Prefer this direction: + +```text +Oreslang source + -> typed Ores AST / IR + -> Ores runtime intrinsic + -> native runtime implementation + -> OS primitive when necessary +``` + +Do not define a language feature as: + +```text +Oreslang source + -> Java library semantic + -> Java reflection/class identity +``` + +JNI (or Panama/FFM in trusted hosts) may be used as a narrow transport boundary +while the Java-hosted compiler exists, but the native function implements an +Oreslang runtime contract. JNI is not the type system. + +## Pattern/type intrinsics + +The pattern implementation should eventually lower to a small native ABI +conceptually equivalent to: + +```text +ores_type_id(value) -> TypeId +ores_type_is(value, target: TypeId) -> bool +ores_type_is_subtype(actual: TypeId, target: TypeId) -> bool +ores_constructor_tag(value) -> ConstructorTag +ores_constructor_field(value, index) -> ValueRef +``` + +Names here describe semantics, not a frozen C ABI. Before stabilization the +native runtime may change calling convention, handles, and layout. + +Required invariants: + +1. Type IDs belong to Oreslang, not JVM `Class` objects. +2. Constructor tags are Oreslang sum/enum metadata. +3. A failed checked cast becomes an Oreslang `CastError`; optional casts + produce `Option`. +4. Pattern binding does not copy or clone ownership. Bindings refer to the same + Ores ownership place or to a proven projection of it. +5. Exclusive-match proof happens before code generation. The runtime retains a + defensive invariant check in debug/reference builds, but source order is not + the semantics of ordinary `match`. +6. Host Java objects never become nominal Oreslang values merely because a JVM + `instanceof` relationship succeeds. Host interop remains capability-gated. + +## Decision DAG lowering + +After static overlap/exhaustiveness proofs, a normal `match` may be reordered +and lowered to an optimized decision DAG because its explicit predicates are +proven disjoint. Backends may choose type-tag switches, constructor-tag +switches, interval branches, or another equivalent implementation. + +`match first` is different: source order is semantic and must be preserved. + +## Native-first migration + +For runtime facilities that Oreslang owns (type metadata, collections, files, +networking, scheduling, futures, actors, mutexes, GC/arenas), new work should +prefer Ores-native/runtime-native implementations. Java facades should become +compiler/bootstrap adapters around those facilities rather than the canonical +implementation. + +A practical migration path is: + +1. keep parser/type checker/IR validation host-side while the compiler is Java; +2. define narrow Ores runtime intrinsics with backend-independent semantics; +3. implement those intrinsics in the native runtime; +4. have the Truffle backend call the intrinsic layer (JNI only where required); +5. progressively move library/runtime behavior out of Java; +6. keep Java interop explicitly optional and capability-gated. + +This lets the same Ores program retain its meaning under Truffle/JIT, +Native-Image/AOT, a JNI-backed native runtime, or a future non-JVM compiler. diff --git a/docs/PATTERN_MATCHING.md b/docs/PATTERN_MATCHING.md new file mode 100644 index 00000000..e1271dd6 --- /dev/null +++ b/docs/PATTERN_MATCHING.md @@ -0,0 +1,122 @@ +# Pattern matching, refinement, and casting + +Oreslang deliberately separates type refinement, full pattern matching, and +value-case dispatch. + +## Surface syntax + +```ores +if value is Dog dog then + dog.bark(); +fi + +if result matches Some(value) then + use(value); +fi + +val dog = animal as Dog; +val maybeDog = animal as? Dog; + +match result + Some(value) -> { use(value); } + None -> { recover(); } +end + +switch status + case 200, 201 -> { success(); } + default -> { failure(); } +end +``` + +Every `if` closes with `fi`, including brace-bodied forms. Executable arms +use the slim arrow `->`. The fat arrow `=>` remains type-level syntax for +function types and interface callable signatures. + +## Semantic separation + +- `x is T` is a nominal type predicate and flow refinement. +- `x is T name` additionally introduces a lexical refinement alias. +- `x matches P` tests a complete pattern and exposes its bindings only on the + successful control-flow edge. +- `x as T` is a checked cast. A failed runtime cast raises `CastError`. +- `x as? T` is a non-panicking cast with type `Option`. +- `match x` is proof-checked pattern partitioning. +- `match first x` is the explicit ordered/priority escape hatch. +- `switch x` is constant/equality dispatch and never performs destructuring. + +A refinement binding is not a copy. If `dog` is introduced by +`animal is Dog dog`, both names identify the same ownership place. Moving +through either name consumes the same move-only value. + +## Exclusive match proof obligation + +For a scrutinee domain `D`, each explicit arm is normalized to a predicate +`P_i(x)`. A normal match is admitted only when the checker establishes: + +```text +for every i != j: D(x) && P_i(x) && P_j(x) is UNSAT +exhaustiveness: D(x) && !(P_1(x) || ... || P_n(x)) is UNSAT +``` + +An unguarded final `else`, `_`, or catch-all binding is not treated as an +ordinary universal arm. It denotes the complement of all preceding explicit +arms. + +The first proof engine is intentionally conservative. It has exact reasoning +for: + +- booleans and literal equality; +- `Option` constructors `Some` / `None`; +- `Result` constructors `Ok` / `Err`; +- nominal subtype/interface relations when a relation is provable; +- simple numeric guard intervals using `< <= == >= >`; +- wildcard/catch-all coverage. + +If disjointness cannot be proved, ordinary `match` is rejected rather than +silently using source order. Code that deliberately needs priority semantics +must say `match first`. + +This is the important semantic distinction: + +```ores +match value + is Dog dog -> { dog.bark(); } + is Animal animal -> { handle(animal); } +end +``` + +is rejected because a `Dog` witnesses both predicates. The ordered form is +explicit: + +```ores +match first value + is Dog dog -> { dog.bark(); } + is Animal animal -> { handle(animal); } +end +``` + +Arbitrary calls inside guards are opaque to the proof engine unless a future +verified predicate/contract system gives the compiler a sound logical summary. +The checker must never infer exclusivity from undocumented function behavior. + +## Lowering contract + +Patterns are represented by dedicated Oreslang AST nodes rather than generic +binary operators. The compiler can therefore normalize them into a decision +DAG and constraint representation before backend lowering. + +The language contract is independent of Java object identity. A backend must +implement Oreslang type tests through Oreslang type/constructor metadata: + +- stable type identity; +- superclass/subtype edges; +- implemented interface/trait edges; +- constructor tags for sum types; +- reified generic information only where the language declares it reifiable. + +The Java/Truffle evaluator is a reference/bootstrap backend. Its internal Java +objects may represent Oreslang values, but Java `Class.isInstance`, +reflection, or arbitrary host `instanceof` relationships are not Oreslang +type semantics. + +See `NATIVE_RUNTIME_ABI.md` for the native/JNI boundary. diff --git a/docs/PROJECT_MANIFEST.md b/docs/PROJECT_MANIFEST.md new file mode 100644 index 00000000..726e3e69 --- /dev/null +++ b/docs/PROJECT_MANIFEST.md @@ -0,0 +1,78 @@ +# Oreslang project manifest + +Oreslang projects may define compiler/project configuration in the nearest +`.oreslangc.cfg.toml`. The compiler walks upward from the entry source file +(or from the current working directory when no source file is supplied) and +uses the first manifest it finds. + +This is intentionally a project/compiler manifest: lighter than Maven's build +lifecycle, but more project-aware than a source-only compiler switch file. + +## Supported v1 contract + +```toml +schema_version = "1" + +[project] +name = "my-ores-app" +version = "0.1.0" +root = "." + +[source] +roots = ["src"] +import_paths = ["lib", "vendor"] + +[entrypoints] +main = "src/main.ores" +``` + +All manifest filesystem paths are resolved relative to `[project].root`, +which itself is relative to the manifest directory unless absolute. + +The current implementation intentionally rejects unsupported schema versions +and only assigns behavior to fields that the compiler/runtime actually +consumes. Additional compiler, target, profile, dependency, workspace, and +actor-policy sections should be added version-by-version rather than accepted +and silently ignored. + +## Import resolution + +Oreslang source identity remains filesystem/path based. The manifest does not +create a Java-style package namespace and source files do not need a top-level +`module` declaration. + +Resolution order is: + +1. `./foo` and `../foo`: only relative to the importing file. +2. Absolute source path: exactly that path. +3. Bare path such as `pkg/http`: each `source.roots` entry in order. +4. Each `source.import_paths` entry in order. +5. Each `ORESLANG_PATH` entry in order. + +For a path without an extension, the resolver tries the exact file and then +`.ores` and `.java`. + +Project-local roots intentionally precede ambient `ORESLANG_PATH` entries so +a developer machine cannot silently shadow a dependency pinned by the project. + +## ORESLANG_PATH + +`ORESLANG_PATH` is the ambient Oreslang import/search path. It is a list of +directories separated by the host OS path-list separator, exactly like +`PATH`: + +```bash +# macOS / Linux +export ORESLANG_PATH="$HOME/.oreslang/lib:/opt/oreslang/lib" + +# Windows PowerShell +$env:ORESLANG_PATH = 'C:\\oreslang\\lib;D:\\shared\\ores' +``` + +Relative entries are resolved from the compiler process working directory. +Empty entries are ignored and never mean the current directory; this avoids +accidental dependency injection from a leading/trailing separator or `::`. + +The compiler records the resolved target for every loaded import and reuses +that exact mapping during static linking and runtime linking. Compilation and +execution therefore cannot disagree about which file a bare import names. diff --git a/docs/PURE.md b/docs/PURE.md new file mode 100644 index 00000000..643052e4 --- /dev/null +++ b/docs/PURE.md @@ -0,0 +1,752 @@ +# Reserved `pure` keyword and effect contract + +Status: **draft design / compiler contract** + +This document specifies the proposed reserved `pure` keyword for Oreslang. The first implementation target is named `fnc` and `routine` declarations. The keyword is a compiler-enforced semantic contract, not documentation and not an optimizer hint. + +This design is intentionally built on an internal effect system so Oreslang can add richer effect declarations later without weakening or redesigning `pure`. + +## Goals + +A declaration that successfully compiles with `pure` must be unable to: + +- observe ambient mutable state; +- mutate state reachable outside the invocation; +- perform I/O; +- observe time, randomness, scheduler state, mailbox state, process state, environment variables, or other nondeterministic ambient inputs; +- spawn actors, send or receive actor messages, or mutate actor/process/singleton state; +- escape through Java/JNI/native/host calls whose effects are not compiler-trusted; +- hide an impure operation behind a helper, closure, callback, dynamic dispatch, generic instantiation, imported package, or recursive call graph. + +The guarantee is transitive and fail-closed. + +## Non-goals + +`pure` does **not** mean: + +- total; +- guaranteed to terminate; +- non-throwing, non-raising, or non-panicking; +- allocation-free; +- recursion-free; +- immutable implementation syntax; +- automatically memoizable; +- automatically safe for common-subexpression elimination; +- automatically safe to reorder across exceptions/divergence; +- constant-time or side-channel-safe; +- bounded in CPU, memory, stack, allocation count, or wall time; +- immune to runtime termination such as OOM, stack exhaustion, sandbox fuel exhaustion, OS kill, or hardware failure; +- full referential transparency for identity-bearing freshly allocated results. + +Those properties are separate compiler facts. `pure` is an observational language-level effect contract: no forbidden ambient observation and no forbidden externally visible mutation/effect. Fresh allocation and exceptional control remain separate facts, so the optimizer must not silently strengthen `pure` into a stronger mathematical property. + +## Syntax + +`pure` is a globally reserved keyword. + +Canonical forms: + +```ores +pub pure fnc add(int a, int b): int { + return a + b; +} + +pub pure routine normalize(): void { + // side-effect-free orchestration is legal; + // routine's existing no-recursion rule still applies. + return; +} +``` + +The canonical named-callable modifier order is: + +```text +[visibility] pure [nlex] fnc ... +[visibility] pure [nlex] routine ... +``` + +Class-level static functions use the existing class-static position: + +```ores +define class Math as + pub static pure fnc twice(int value): int { + return value * 2; + } +end +``` + +Lambda-style named declarations, where accepted by the callable grammar, carry the same contract: + +```ores +pub pure routine normalize = || -> void { + return; +} +``` + +`fnc` retains its existing recursive semantics. `routine` retains its existing prohibition on direct or indirect recursive cycles. Purity is orthogonal to recursion. `nlex` is compatible with `pure`; it adds the stronger no-activation-capture rule but does not replace effect checking. + +V1 deliberately rejects `pure async fnc`, `pure async routine`, and `pure actor fnc`. Async/future completion and actor execution are scheduler/effect boundaries. A synchronous pure helper may be called freely from async or actor code. + +The first implementation does not require public syntax for `pure` instance methods. The compiler must still infer method effects so calls from a pure callable cannot escape through an impure method. A later language revision may expose `pure` on methods/interfaces once method effect contracts are specified. + +Other callable modifiers such as the separately designed `trap` modifier may compose with `pure` only when their control-flow semantics preserve the effect contract. Modifier composition is checked semantically, not accepted merely because the parser can order the words. + +`pure` is not a callable-only compatibility keyword. It cannot be used as an ordinary identifier, parameter, binding, type, class, module, field, bare callable reference name, or unquoted object key. + +## Semantic definition + +A pure invocation may compute using: + +- explicit input values; +- immutable/read-only views of explicit input values; +- uniquely owned values moved into the invocation; +- locally created state; +- values returned by other compiler-proven pure computations; +- compile-time constants embedded in checked IR. + +It may not depend on ambient mutable or nondeterministic state. Time, random values, locale, configuration, IDs, or other nondeterministic information are allowed when they arrive as ordinary explicit data values rather than being observed through an ambient capability. + +A pure invocation may mutate storage only when the ownership checker proves that storage is private to the invocation for the duration of that mutation. The mutation must not be visible through any simultaneously live external alias. + +This makes local imperative implementation compatible with side-effect freedom without pretending that fresh object identity, allocation cost, or exceptional control are mathematically invisible. + +### Allowed local mutation + +```ores +pub pure fnc sum(List values): int { + let int total = 0; + + for (val value of values) { + total += value; + } + + return total; +} +``` + +Local mutation is allowed when the mutated storage is invocation-private. + +### Forbidden external mutation + +Conceptually, mutation through a caller-visible mutable borrow is rejected: + +```ores +pub pure fnc increment(&mut Counter counter): void { + counter.value += 1; // ERROR: caller-visible mutation +} +``` + +### Owned/moved inputs + +Oreslang's affine ownership model lets the checker be more precise than a blanket "arguments are immutable" rule. + +A by-value non-shared value whose ownership has moved into the pure invocation may be mutated as invocation-private storage when the borrow checker proves there are no live aliases. This is equivalent to mutating a local temporary. + +An exclusive mutable borrow remains forbidden because its mutation is observable through the caller's retained ownership. + +The built-in ownership operations therefore interact with purity as follows: + +- `copy(struct)`: compiler-derived recursive copy is pure only when every nested copy step is pure; +- `copy(class)`: invokes the class copy contract and is pure only when that concrete class copy implementation is compiler-proven pure and satisfies the fresh/non-aliasing copy rules; +- `take`: allowed as an ownership transfer; subsequent mutation is allowed only under unique ownership; +- `borrow` / `&T`: allowed for read-only access to explicit input state; +- mutable borrow / `&mut T`: forbidden in a pure contract because the caller retains observable ownership; +- `share`: allowed only for a deeply immutable/frozen snapshot; access to a live mutable shared region is forbidden. + +This aligns purity with the struct/class split: structs receive compiler-derived value-copy semantics; classes never become pure-copyable merely because a method named `copy` exists. + +## Escape and ownership rule + +Purity forbids **alias escape**, not the useful return of uniquely owned values. + +Allowed examples include: + +- returning a newly allocated mutable object that has no alias outside the invocation; +- consuming a uniquely owned input with `take`, mutating it locally, and returning that same uniquely owned value; +- returning a closure whose mutable captured state was freshly created inside the invocation, provided the returned closure's own callable effect metadata correctly records that later invocation is stateful/impure. + +For example, an in-place transform can remain pure when ownership is transferred: + +```ores +pub pure fnc sort_owned(List mut values): List { + // values is uniquely owned and explicitly mutable in this invocation. + values.sort_in_place(); + return values; +} +``` + +The checker must reject any return/capture/store that exposes a mutable alias to storage that was already externally reachable or remains reachable through another live alias. It must also reject escape through: + +- assignment to module/global/singleton/actor state; +- actor messages or process/global registries; +- callbacks registered with or retained by an external runtime service; +- foreign/native handles that alias externally mutable storage; +- returned borrows whose owner does not outlive the result; +- nested aggregates that launder a prohibited borrow/alias; +- any other longer-lived alias that violates ownership provenance. + +A returned value need not be deeply immutable merely because its factory was pure. The result's later methods/callables keep their own effect summaries. + +Fresh allocation may still carry runtime identity. Therefore `pure` alone is not permission for identity-changing optimizations such as memoization or allocation CSE. Those require a separate value-semantics/identity analysis. + +## Ambient reads + +Read-only syntax does not imply purity. + +These are ambient reads and are forbidden even when the returned value is not mutated: + +- mutable module/global/thread-local state; +- singleton state; +- actor state not supplied as an explicit value snapshot; +- environment variables and process/system properties; +- current working directory; +- process metadata; +- locale, timezone, default charset, or other host defaults; +- current time or hardware/performance counters; +- ambient randomness or process-seeded randomized hashing; +- scheduler/thread identity, cancellation state, or task/future completion state; +- mailbox/channel state; +- lock/atomic/volatile state; +- GC state, heap statistics, weak-reference liveness, or finalization state; +- caller/call-stack inspection, loaded-module enumeration, classloader state, or similar runtime reflection; +- raw addresses, pointer values, or externally mutable foreign memory; +- external files, sockets, databases, devices, memory maps, or host state. + +Identity equality between explicit object references can be pure because it depends on explicit inputs. Producing or inspecting raw addresses/process-assigned identity numbers is not. + +A stable content hash with a specified algorithm is pure. A hash whose seed comes from process/runtime randomness is not unless the seed is supplied explicitly. + +A deterministic PRNG is pure when its state/seed is explicit and the function returns the updated PRNG state as data. Calling an ambient/global RNG is not pure. + +The conservative v1 exception for ambient bindings is a compile-time constant whose value is embedded in checked IR. Arbitrary runtime `const` objects are not assumed pure merely because their binding cannot be reassigned. + +## Closures + +Closures are allowed. They are not a purity escape hatch. + +A closure used by or returned from a pure callable must have compiler-proven effects compatible with the pure contract. + +Allowed: + +- capture of immutable/value-semantic data; +- immutable borrowing of explicit input state while the borrow is valid; +- mutation of closure-local state that is invocation-private; +- returning a stateful closure when all mutable captured state is freshly owned by that returned closure and the closure's own effect summary is not falsely marked pure. + +Rejected: + +- mutation of an outer caller-visible binding; +- capture of mutable shared/module/singleton/actor state; +- escaping a borrow whose provenance/lifetime cannot be represented safely; +- laundering captured mutable aliases through nested closures; +- invoking a closure whose effect summary is unknown or incompatible with purity. + +Creating or passing an impure callable value is not itself an effect. Invoking it, registering it with an external service, or causing it to escape through an effectful runtime boundary is what matters. + +## Hidden calls, desugaring, and implicit behavior + +Purity is checked on resolved/desugared semantics, not surface syntax. No implicit call may bypass the effect checker. + +The compiler must include effects from: + +- constructors and field/default initializers; +- class `copy()` hooks and any future clone/conversion hooks; +- overloaded operators and comparisons; +- equality/hash implementations; +- property getters/setters and indexers; +- `[Symbol.iterator]()`, iterator `next`, and consuming iteration; +- implicit coercions/conversions; +- string/template interpolation and formatting hooks; +- destructuring helpers; +- user-defined assertion/error construction hooks; +- `defer`, `finally`, cleanup/drop hooks, and any future destructor/finalizer semantics. + +A type whose guest-visible finalizer/destructor can perform a forbidden external effect cannot be created in a pure callable merely because that effect happens later. GC-timed externally visible finalization would make the allocation itself semantically effectful. + +Effect collection must therefore happen after name resolution/desugaring has identified every callable edge, while ownership provenance is still available. + +## Actors, isolates, async, and scheduling + +The following effects are forbidden in a pure callable: + +- `spawn`; +- mailbox/channel send or receive; +- actor lookup with observable mutable state; +- actor state mutation; +- actor state reads unless the state was first copied/frozen and passed as an explicit value; +- creating or awaiting live futures/promises/tasks in v1; +- observing completion, cancellation, deadlines, queue depth, or scheduler state; +- scheduler queries; +- thread identity; +- sleep/yield/park; +- locks; +- mutable atomics or volatile memory; +- isolate/process capability operations. + +The initial syntax rejects `pure actor fnc`, `pure async fnc`, and `pure async routine`. Actor and async entrypoints are effectful boundaries. Pure helpers may be called from actor/async code normally. + +V1 also rejects `await` inside a pure callable even if the awaited computation is believed to be pure. A later design may admit effect-typed immediate/deterministic futures, but the first checker fails closed. + +A common pattern is: + +```ores +val snapshot = copy state.snapshot(); +val result = calculate(snapshot); + +pub pure fnc calculate(StateSnapshot snapshot): ResultValue { + // deterministic/value-only work +} +``` + +## I/O and runtime capabilities + +Pure callables cannot perform: + +- `stdio`; +- filesystem I/O; +- network I/O; +- HTTP/socket operations; +- database operations; +- environment/process reads; +- device access; +- memory-mapped/volatile external-memory access; +- capability acquisition or authority escalation; +- externally visible logging/tracing/metrics/audit emission. + +Passing an opaque capability value through a pure callable without invoking/acquiring/registering it is not automatically an effect; using the capability is. + +A pure callable may construct or transform ordinary data representing a request, path, packet, query, log record, timestamp, random seed, capability descriptor, etc. It simply cannot execute the external effect represented by that data. + +## Exceptions and divergence + +Purity is separate from totality. + +A guest-language `throw`, `raise`, or `panic` may occur in a pure callable when constructing/propagating the control signal performs no forbidden external effect. A `trap`/recovery boundary may likewise remain pure when every executed handler/cleanup path is pure. + +A recursive `fnc` may diverge and still be pure. + +The compiler must therefore track at least `throw`, `raise`, `panic`, and `diverge` separately from forbidden external effects. Handlers may discharge the corresponding control effect, but they do not erase external effects performed while constructing the signal, unwinding, running `defer`/`finally`, or handling it. + +Optimizations must not assume that `pure` means `nothrow`, `noraises`, `nopanic`, or `terminates`. + +Call-stack/debug metadata attached by the runtime is diagnostic metadata, not an ambient capability granted to pure code. Reading caller stack, thread, source-loader state, or similar runtime metadata inside a pure callable is an introspection effect and is forbidden. + +See `docs/TRAP.md` for the detailed `trap` / `throw` / `raise` / `panic` / `recover` contract. + +Host/FFI failures are not automatically trusted as pure guest control effects and remain covered by foreign-effect rules. Fatal VM/native corruption, OS termination, hardware faults, OOM, stack exhaustion, or sandbox fuel exhaustion are outside the source-level purity guarantee. + +## Java, JNI, native, and host interop + +Foreign code is fail-closed. + +A call into Java/JNI/native/host code has effect `ffi_unknown` unless one of the following is true: + +1. the operation is a compiler/runtime-owned intrinsic with audited effect metadata; or +2. a future trusted ABI/manifest mechanism establishes a purity contract. + +There is no user-level `assume_pure` escape hatch in v1. + +Examples of compiler-owned classifications: + +```text +integer.add pure +string.length pure +math.sin pure +socket.write io +clock.now clock +random.next random +thread.yield scheduler +unknown JNI call ffi_unknown +``` + +## Internal effect model + +Every callable receives an internal effect summary whether or not source code declares `pure`. + +Initial effect kinds should include at least: + +| Effect | Meaning | Allowed by `pure` | +| --- | --- | --- | +| `local_mutation` | mutation of invocation-private storage | yes | +| `allocation` | local allocation | yes | +| `throw` | ordinary guest exception/control effect | yes | +| `raise` | trap-bypassing recovery signal | yes | +| `panic` | invariant/runtime recovery signal | yes | +| `diverge` | possible nontermination | yes | +| `ambient_read` | read mutable/ambient runtime state | no | +| `external_mutation` | mutate caller/global/shared reachable state | no | +| `io` | stdio/filesystem/network/database/device I/O | no | +| `environment` | env/process/cwd/locale/timezone/host configuration | no | +| `clock` | wall/monotonic/hardware time observation | no | +| `random` | nondeterministic ambient random source | no | +| `actor` | actor/mailbox/process-state interaction | no | +| `scheduler` | async/thread/scheduler/sleep/yield/cancellation state | no | +| `synchronization` | locks/mutable atomics/volatile/external synchronization | no | +| `introspection` | caller stack/runtime/loader/GC/heap/identity-address observation | no | +| `capability` | acquire/register/use external authority | no | +| `external_memory` | access live foreign/MMIO/shared memory without frozen ownership proof | no | +| `ffi_unknown` | foreign code with no trusted summary | no | +| `unsafe_external` | unchecked external memory/authority effect | no | + +A closed bitset alone is **not sufficient** for mutation soundness. The compiler also needs region/parameter-relative read/write footprints and higher-order effect variables. The source-language contract is defined by semantics, not the internal representation. + +## Region- and parameter-relative mutation effects + +A callee's mutation effect must say **what region it may mutate**, not merely that "some mutation" occurs. + +Conceptually: + +```text +List.sort_in_place(self) => write(self) +append(mut param0, value) => write(param0) +make_list(...) => allocation + write(fresh) +global_cache_put(...) => external_mutation(global_cache) +``` + +At each call site, the checker substitutes ownership provenance: + +- `write(fresh)` -> `local_mutation`, allowed; +- `write(self)` where `self` is uniquely owned/moved into the invocation -> `local_mutation`, allowed; +- `write(param0)` where param0 is an owned `Type mut name` parameter consumed by the invocation -> local/owned mutation, allowed; +- `write(self/param)` through a caller-visible `&mut`, shared alias, captured alias, singleton/global region, or unknown provenance -> `external_mutation`, forbidden; +- unknown or unsupported provenance -> fail closed. + +This is required for code such as: + +```ores +pub pure fnc sort_owned(List mut values): List { + values.sort_in_place(); // write(self), discharged as local because values is unique + return values; +} +``` + +without incorrectly blessing: + +```ores +pub pure fnc sort_borrowed(&mut List values): void { + values.sort_in_place(); // ERROR: write(self) targets caller-visible storage +} +``` + +Effect summaries therefore need, at minimum: + +- closed external/control effect bits; +- region/receiver/parameter read-write footprints; +- ownership/precondition information needed to discharge those footprints; +- higher-order effect variables/constraints. + +These footprints must survive generics, method extraction, dynamic dispatch, imports, compiled metadata, hot reload, and inlining. A method override cannot widen a pure/region-safe mutation footprint without invalidating callers. + +## Transitive effect inference + +Purity must be proven over the complete resolved call graph. + +Example: + +```ores +pub pure fnc a(): int { + return b(); +} + +fnc b(): int { + return c(); +} + +fnc c(): int { + return stdio.stdin.read_int(); +} +``` + +Compilation must fail even though the direct body of `a` contains no obvious I/O. + +The compiler must compute a fixed point over call-graph strongly connected components so recursive `fnc` groups receive complete effect summaries. + +A declared pure callable succeeds only when every reachable operation is compatible with the pure contract. + +## Indirect calls and higher-order functions + +Purity must be part of callable type metadata internally from the first implementation. + +A pure callable may invoke a function value/callback only when the compiler can prove that the invoked target effect set is compatible with purity. + +Effect metadata must support **effect variables/constraints**, not only a single closed bitset. Otherwise generic higher-order helpers become either unsound or unnecessarily unusable. Conceptually: + +```text +map(values, f) effects = effects(f) ∪ local_allocation +``` + +A concrete `map(values, pure_callback)` may therefore be pure while `map(values, io_callback)` is not. If v1 source syntax cannot express a pure/effect-bounded callback parameter, exported generic code must fail closed rather than erase the dependency. + +A pure callable may still accept, pass through, store in invocation-local data, or return an impure callable value when it never invokes/registers it and ownership rules permit the value to escape. + +This covers: + +- closures; +- function values; +- generic callable parameters; +- interface dispatch; +- trait dispatch; +- virtual/class dispatch; +- imported callable values. + +If the target effect is unknown, a declared pure caller fails closed. + +The exact source spelling for a "pure callable type" is intentionally left for the callable-type syntax design. The compiler metadata must support it now so higher-order calls cannot punch a hole through v1 purity. + +## Dynamic dispatch + +For a direct/final target, the compiler may use the inferred target effect summary. + +For open dynamic dispatch, a pure caller may invoke the slot only if every legal runtime target is proven compatible with purity. + +If the dispatch set cannot be closed, the contract must eventually live on the interface/trait/method slot itself. Until that surface exists, unknown/open dispatch from a pure caller is rejected. + +A future `pure` method/interface contract must obey variance by guarantee: + +- a pure interface/base slot may only be implemented/overridden by a pure implementation; +- an implementation may strengthen an impure/unspecified slot by being pure. + +## Imports and compiled metadata + +Effect summaries are part of the checked interface of a compiled Oreslang unit. + +The compiler must serialize enough effect metadata to prove cross-file/package calls without re-parsing implementation bodies. + +Rules: + +- exported `pure` is part of the public contract; +- removing `pure` from an exported callable is a contract weakening and must be treated as an API compatibility change; +- adding `pure` is a strengthening of the guarantee; +- imported artifacts lacking trusted effect metadata are `unknown`, never silently assumed pure; +- metadata version skew fails closed for explicit pure callers; +- effect metadata is compiler-produced/verified metadata, never a user-authored sidecar assertion; +- metadata must be cryptographically/content-address tied to the exact implementation artifact or source/code-unit digest it summarizes; +- the metadata schema/effect-lattice version and trusted intrinsic-table version are part of compatibility; +- reverse dependencies are invalidated when a callee's effect summary or public effect contract changes; +- hot reload is generation-scoped: an active pure caller cannot begin dispatching to a newly loaded impure generation without revalidation; +- a pure interface/slot contract cannot be weakened by hot replacement; +- binary-only/foreign code whose implementation cannot be validated remains `ffi_unknown` unless covered by a separately trusted ABI attestation mechanism. + +Purity is therefore a property of a checked code generation/artifact, not a timeless promise about a symbol name. + +## Diagnostics + +Purity diagnostics must report the effect path rather than only the final declaration. + +Example: + +```text +error[E-PURE-004]: pure fnc 'a' reaches an I/O effect + + a + └── calls b + └── calls c + └── calls stdio.stdin.read_int + └── effect: io +``` + +Proposed stable diagnostic families: + +- `E-PURE-001`: direct forbidden effect; +- `E-PURE-002`: ambient mutable/nondeterministic read; +- `E-PURE-003`: externally reachable mutation; +- `E-PURE-004`: transitive impure call; +- `E-PURE-005`: unknown foreign/imported effect; +- `E-PURE-006`: escaping mutable state; +- `E-PURE-007`: impure/unknown callback or dynamic target; +- `E-PURE-008`: invalid modifier combination; +- `E-PURE-009`: purity contract override/implementation mismatch. + +Diagnostics should identify the first useful source location and include a shortest effect trace when the violation is transitive. Hidden/desugared calls should name both the surface operation and the resolved callee, e.g. `for-of -> Symbol.iterator -> next -> io`. + +## Tree shaking, build defines, and unreachable code + +Purity checking is a correctness pass, not a tree-shaking optimization. + +Consistent with the build/tree-shaking contract: + +1. parse and resolve the complete source program; +2. type/ownership/effect-check it; +3. only then perform reachability pruning/tree shaking. + +A branch that is merely unreachable after optimizer folding cannot hide an impure operation inside a declared pure callable. This keeps the public purity contract stable across build configurations and avoids using DCE as an effect escape hatch. + +Compiler build defines may still provide ordinary compile-time constant **values** to pure code because guest code is not reading the compiler process environment at runtime. The checked artifact/code-unit digest must bind the effective build configuration used to produce the IR. + +## Runtime implementation effects + +The purity contract governs guest-visible semantics, not invisible implementation bookkeeping. + +The runtime/JIT may use internal counters, profiling, allocation metadata, caches, GC barriers, or sandbox fuel accounting while executing pure guest code only when that state is not exposed back to guest code as an ambient observable. + +Oreslang's mandatory loop safepoints are included in this rule. A pure loop may still execute injected scheduler/fuel/cancellation safepoints. Those hooks are runtime control machinery, not guest effects, provided pure guest code cannot read their state, branch on scheduler identity/queue state, or explicitly request yield/sleep/park. External cancellation or sandbox termination may stop a pure computation, but that does not grant the computation an ambient scheduler capability. + +User-visible tracing, logging, metrics, hooks, callbacks, weak-reference state, allocation counters, or profiling APIs remain effects when guest code can observe or trigger them. + +## Compiler pipeline + +Recommended implementation order: + +1. reserve and parse `pure`; +2. record purity declaration on callable AST/symbols; +3. add internal effect metadata (closed effects plus effect variables/constraints) to every callable; +4. classify built-ins/runtime primitives per execution backend; +5. resolve/desugar implicit calls before effect collection; +6. perform intraprocedural effect collection on the resolved CFG, including receiver/parameter region footprints; +7. integrate ownership, alias, borrow, and escape facts and discharge region writes at call sites; +8. compute transitive call-graph effects/footprints to a fixed point over SCCs; +9. validate explicit `pure` contracts; +10. add higher-order/dynamic-dispatch effect constraints; +11. serialize versioned/content-bound effect summaries into compiled-unit metadata; +12. add hot-reload/reverse-dependency invalidation; +13. add foreign/native fail-closed metadata and JIT/AOT/native parity checks; +14. only after correctness is established, consume purity in optimizers. + +## Required ownership-checker integration + +The purity pass must not duplicate or guess aliasing facts that belong to the ownership/borrow checker. + +The passes must share enough information to determine: + +- allocation origin; +- ownership; +- move state; +- immutable vs mutable borrowing; +- alias count/reachability; +- shared/frozen status; +- capture origin; +- escape paths; +- return reachability; +- whether returned mutable storage is fresh/uniquely owned versus an alias of pre-existing storage; +- nested aggregate provenance; +- destructor/finalizer/drop behavior where applicable. + +Purity decisions based only on syntax are insufficient. Unsupported provenance fails closed rather than being guessed. + +## Required tests + +### Compile-pass + +- arithmetic-only pure `fnc`; +- pure `routine`; +- recursive pure `fnc`; +- local loop accumulator mutation; +- local temporary object/buffer mutation with no escape; +- read-only borrow of explicit input; +- uniquely owned moved input mutated locally; +- owned `Type mut name` parameter mutated locally; +- receiver-relative helper mutation discharged against a unique local receiver; +- immutable closure capture; +- pure helper called transitively; +- mutually recursive pure `fnc` SCC; +- deterministic guest exception; +- immutable/frozen return value; +- fresh uniquely owned mutable return value; +- `take` + local mutation + return of the consumed unique value; +- creation/return of a stateful closure with fresh private state, while the closure itself remains effect-typed as stateful; +- explicit timestamp/random-seed values transformed as ordinary data; +- deterministic explicit-state PRNG step; +- stable content hash with fixed algorithm/seed; +- identity equality over explicit input references; +- `pure nlex fnc`; +- compiler-trusted pure intrinsic. + +### Compile-fail + +- use `pure` as an identifier; +- `pure actor fnc`; +- `pure async fnc` / `pure async routine` in v1; +- `await` / future completion / cancellation observation in pure v1; +- stdout/stderr/stdin; +- file/network/database/socket I/O; +- environment variable read; +- cwd/process metadata read; +- wall/monotonic clock read; +- randomness; +- actor spawn; +- mailbox send/receive; +- actor/singleton/module mutable state read; +- actor/singleton/module state write; +- lock/mutable atomic/volatile/thread/scheduler query; +- runtime locale/timezone/default-charset read; +- caller stack/runtime reflection/GC/weak-reference/heap-state read; +- randomized process-seeded hash; +- raw address / pointer identity observation; +- mutable borrow parameter/receiver mutation that remains caller-visible; +- receiver-relative helper mutation against shared/borrowed/unknown provenance; +- return of an alias to pre-existing externally reachable mutable storage; +- returned closure that captures an externally reachable mutable alias; +- constructor/initializer with hidden effect; +- overloaded operator/comparison/hash with hidden effect; +- iterator/getter/indexer/coercion/interpolation with hidden effect; +- effectful defer/finally/drop/finalizer path; +- transitive impure helper; +- indirect impure callback; +- unknown callback effect; +- open dynamic dispatch with unknown targets; +- imported callable with missing effect metadata; +- Java/JNI/native call with unknown effect; +- foreign callback retention; +- unsafe external memory access. + +### Regression/adversarial + +- effect hidden behind several helper layers; +- effect hidden inside generic instantiation; +- effect hidden in mutually recursive SCC; +- effect hidden in closure capture; +- effect hidden in virtual dispatch; +- effect hidden across package boundary; +- effect metadata version mismatch; +- alias created before a `take`; +- region-write summary incorrectly reused across unique versus borrowed receivers; +- receiver/parameter provenance lost through generic instantiation or method extraction; +- borrowed alias laundered through tuple/array/object/constructor field; +- nested closure capture laundering; +- mutable state frozen before explicit input transfer; +- returned fresh mutable graph accepted while returned alias of pre-existing graph is rejected; +- class `copy()` that is effectful or aliases source storage; +- struct copy whose nested class copy is impure; +- exception/raise/panic path that performs I/O; +- deferred cleanup that performs I/O; +- finally block that performs I/O; +- effectful constructor reachable only through an implicit conversion; +- effectful iterator hidden behind `for ... of`; +- process-randomized hash hidden behind map/set iteration; +- stale/forged/mismatched effect metadata; +- hot reload that attempts to weaken a pure dependency/dispatch slot; +- JIT vs AOT vs native intrinsic classification drift; +- explicit impure callback passed through but not invoked remains allowed; +- callback invoked through an effect-polymorphic generic propagates its effect; +- unreachable source branch containing forbidden effects remains rejected before optimizer/tree-shaking removal. + +## Optimizer contract + +`pure` is useful compiler information, but it is only one prerequisite. + +A compiler may not infer any of the following from `pure` alone: + +- call elimination when the result is unused, because the call may throw, raise, panic, or diverge; +- memoization, because identity-bearing or freshly mutable allocations may be returned; +- common-subexpression elimination across identity-sensitive results; +- arbitrary reordering, because throw/raise/panic/divergence ordering may change; +- speculative duplication, because cost/divergence/resource behavior may change; +- automatic parallelization when scheduling, cancellation, allocation pressure, or exceptional ordering would become guest-observable; +- constant-time or side-channel safety. + +Stronger optimizations require additional proven facts such as `nothrow`, `noraises`, `nopanic`, termination, value semantics, identity irrelevance, and cost/resource constraints. + +## Compatibility with a future effect system + +The source language should remain simple in v1: + +```ores +pub pure fnc ... +pub pure routine ... +``` + +Internally, however, all callables carry effect summaries. This permits future explicit capabilities/effects such as I/O, actor operations, clock, or environment access without redefining `pure`. + +The invariant to preserve is: + +> If an Oreslang `fnc` or `routine` successfully compiles with `pure`, no execution path—direct, transitive, recursive, generic, dynamically dispatched, captured, imported, actor-mediated, Java-mediated, JNI-mediated, or native-mediated—may observe forbidden ambient state or produce a forbidden externally observable effect. diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md new file mode 100644 index 00000000..050a8d64 --- /dev/null +++ b/docs/RUNTIME.md @@ -0,0 +1,246 @@ +# Runtime isolation, hot reload, and compilation profiles + +## Invariants + +1. Guest source never grants itself authority. +2. The supervisor/launcher supplies an `IsolatePolicy`. +3. Compiler admission rejects language APIs not in that policy. +4. Runtime API facades repeat the authorization check. +5. Graal host access/class lookup, native access, environment access, guest-created threads, host IO, and unrestricted polyglot access are disabled by default in restricted contexts. +6. Actors cannot exceed their configured mailbox capacity. +7. Hot reload never requires loading executable native libraries. +8. Every hot-loaded generation is a fresh guest context and may be mapped to a stronger Graal/native isolate by the production host. +9. `self` cannot be rebound. +10. An actor has one mailbox and never executes two mailbox turns concurrently. +11. Private and shared actors use separate dispatcher thread pools. +12. Private actor transport rejects synchronized shared-memory cells. +13. Shared actor state is still actor-owned; ordinary actor field mutation is serialized by the mailbox, not by implicit locks. +14. Actor `self` and move-only actor-owned state cannot escape a mailbox turn as ordinary mutable aliases. +15. Synchronized shared memory requires `SHARED_MEMORY`; strict FaaS does not grant it by default. +16. Private slices and synchronized shared cells compete for one parent actor-memory ceiling. +17. Actor message graphs are cycle-checked and bounded by depth, node count, and logical byte quotas before transport. +18. SharedMutex runtime ownership is reserved before mailbox visibility and committed only after successful admission; failed first publication rolls back. + +## Deployment matrix + +| Profile | Host | Guest execution | Hot reload | +| --- | --- | --- | --- | +| JIT | JVM/GraalVM | interpreter -> Truffle JIT | fresh source generation | +| AOT | Native Image | precompiled interpreter | fresh source generation, no executable-code load | +| HYBRID | Native Image | interpreter -> guest JIT where supported | fresh source generation | + +iOS is treated as AOT-only by the execution-profile validator. Android may use AOT or another profile where platform policy allows it. + +## Why hot reload is source/IR based + +Native Image is fundamentally closed-world for Java classes. Oreslang therefore does not make hot reload depend on dynamically linking new Java/native code. The runtime/interpreter is part of the shipped artifact; newly downloaded Oreslang source (and later a stable serialized Ores IR) is treated as untrusted data, validated, then executed in a new generation. + +That makes the mechanism consistent across Windows, macOS, Linux, Android, and AOT-only targets. Platform-specific native dynamic linking can remain an optional trusted-host optimization, never a semantic dependency. + +## Native-first language/runtime boundary + +Java/Truffle is a reference/bootstrap backend, not the semantic definition of +Oreslang runtime features. Ores-owned facilities should lower through +backend-independent runtime intrinsics and, where practical, be implemented in +the native runtime. JNI may be used as a narrow bridge from the Java-hosted +compiler/runtime, but Java reflection, JVM class identity, and Java library +behavior must not define Oreslang semantics. + +Type tests, casts, and pattern matching therefore use Oreslang type and +constructor metadata. Java host objects remain explicit capability-gated +interop values and do not acquire Oreslang nominal identity through JVM +`instanceof`. + +The native ABI direction and migration rules are specified in +[NATIVE_RUNTIME_ABI.md](NATIVE_RUNTIME_ABI.md). + +## Java host interop boundary + +Java imports are a two-key boundary. Source may name an explicit `java:` class, but execution requires both: + +1. the Oreslang `JAVA_INTEROP` capability; and +2. an exact fully-qualified host-class allowlist entry supplied by the launcher/embedder. + +The runtime uses Graal host lookup rather than guest-side `Class.forName`, disables host class loading, exposes only public **declared** members of explicitly allowlisted classes, and disables access inheritance. This prevents admitting one class from automatically exposing inherited reflection such as `Object.getClass()`. + +Private/memory-isolated actors have `JAVA_INTEROP` stripped from their effective policy. Java host objects are wrapped as host capabilities, are not ordinary Oreslang data, and are rejected from synchronized shared-state publication. Adversarial isolates cannot grant `JAVA_INTEROP` at all. + +Class-level interop blocks VM-control/reflection infrastructure (for example `Runtime`, `System`, `Class`, class loaders, reflection/invoke, script/compiler APIs, and JDK internals). Broad JDK families additionally require their corresponding Ores capabilities: filesystem, network, thread creation, native access, or process info. Third-party classes remain the embedder's responsibility: allowlisting one explicitly grants access to its public declared host surface. + +Example: + +```text +oreslang-compiler --allow=JAVA_INTEROP --allow-host-class=java.util.ArrayList app.ores +``` + +## Capability ownership + +Capabilities belong to a launch policy, not to source code. Source may eventually declare required capabilities for diagnostics, but declarations will never grant them. + +The strict production direction is: +- parent supervisor owns maximum authority; +- child isolate/actor receives an equal-or-smaller capability set; +- no child may escalate its own policy; +- cross-actor values must pass sendability/freezing rules; +- hot-loaded code gets a new generation and new policy admission. + +## Receiver implementation + +Method code is stored once per class declaration. Direct calls dispatch to that shared definition with the receiver as an implicit immutable argument. Instance and actor methods are non-reifiable: evaluating `receiver.method` as a callable value is illegal, so the runtime never allocates an implicit bound-method pair. Code that needs a callback writes an explicit lambda that captures the receiver. `static fnc` remains reifiable because it has no receiver to bind. + + +## Proper tail-call runtime + +OresVM implements proper tail calls itself instead of relying on GraalVM to infer tail-recursion optimization. A tail-position call is prepared as an internal invocation descriptor after its receiver/callee and arguments have been evaluated. The current activation unwinds, and an iterative trampoline executes the next raw activation. Self-recursion, same-evaluator mutual recursion, routines, instance methods, `static fnc`, same-unit module calls, lambdas, and evaluator-owned first-class Oreslang function values therefore share the same constant-call-stack mechanism. Untyped cross-code-unit imports are the explicit contract barrier described below. + +The trampoline performs a scheduler safepoint every 64 tail transfers. This prevents a very long recursive chain from becoming an uncooperative scheduling loophole. + +Tail transfer is deliberately blocked when caller-owned cleanup still exists: active `defer`, catch/finally semantics, or live mutex guards. Those calls use ordinary call/return behavior so cleanup ordering and lock lifetime remain correct. Arbitrary Java/host `Invokable` values are also not tail-transferred. + +Tail-transferable first-class Oreslang callables carry the evaluator that owns their validated contract. A target owned by another linked code unit is treated as a contract barrier because imports are currently typed as `Unknown` within an individual unit. The caller waits for that imported invocation and performs its own declared return-shape validation. Internal tail calls in the target evaluator still trampoline normally. This avoids weakening runtime contracts while keeping retained heap state O(1); no return-validator chain is accumulated across recursive depth. + +This mechanism is part of Oreslang semantics and runs identically inside the JVM/Graal JIT runtime, the Native Image AOT launcher, and the AOT-host/guest-JIT hybrid launcher. + +## Truffle thread boundary + +`ActorRuntime` owns host dispatcher threads; guest code still receives no ambient thread-creation authority. A dispatcher carrier is marked by the runtime, explicitly enters/leaves the associated `TruffleContext` for each actor batch, and only marked actor carriers are accepted for concurrent context access. + +Non-adversarial contexts may therefore execute independent actor turns concurrently. Strict/adversarial contexts currently serialize guest actor turns with a fair context-level lock even though private/shared dispatcher pools remain separate. This preserves the strict one-guest-thread sandbox contract until isolated/private actor execution is backed by per-actor inner/polyglot/native contexts. + +The guest `THREAD_CREATE` capability is separate from host/runtime dispatcher scheduling. Denying guest-created threads is never bypassed merely because the runtime owns carrier pools. + + +## Mutex and shared-memory model + +Oreslang has two deliberately different mutex domains: + +- `Mutex` is actor/private-domain state. It owns the protected value, uses no JVM lock, is non-reentrant, and is confined to the creating semantic actor/execution domain. Shared actors may migrate between JVM workers without changing that domain. +- `SharedMutex` is an explicit same-OS-process shared-memory capability within one `ActorRuntime`. It is non-reentrant and uses acquire/release synchronization. Only shared actors may receive/use it; private actors reject it even when the parent runtime is otherwise trusted. Sender and receiver must have `SHARED_MEMORY`. It binds transactionally to the first runtime that successfully publishes it, and later cross-runtime transport is rejected. Publication validation is nonblocking and runs while the mutex's physical permit is held, so transport never races a legitimate protected mutation; publishing a currently locked/contended mutex fails fast and may be retried later. +- The payload and declared type argument of `SharedMutex` must be **SharedSafe**: concrete owned data whose reachable field graph contains no borrows, actor-local `Mutex`, `MutexGuard`, pending `Future`, closure/function values, or unresolved dynamic/generic state. This applies to signatures/fields/aliases as well as `SharedMutex.new(...)`. Until Oreslang has an explicit SharedSafe generic bound, unconstrained `SharedMutex` is rejected conservatively. The type checker recursively validates class fields (including inherited generic substitutions), and the interpreter repeats runtime admission checks as defense in depth. Nested `SharedMutex` values are also rejected for now; recursive publication and lock-order semantics must be explicit before lock-containing-lock state is admitted. +- `MutexGuard` is a lexical linear capability. The runtime releases it on normal scope exit and poisons a shared mutex on abnormal scope exit. Guest code may call `guard.release()` for early release; there is intentionally no `mutex.unlock()`. A released guard can no longer expose its protected value. +- Guard access is deliberately non-escaping. Copy-like fields may be read, mutable fields may be replaced, and methods may be invoked directly when they return `void` or a copy-like value. Move-only nested fields cannot be extracted through a guard, and instance methods are direct-call-only everywhere rather than becoming bound method values. For compound mutation, use `with_lock(|state| -> { ... })`. +- `with_lock` and `recover` require an inline one-argument, `void` lambda. The callback parameter is treated as a lexical exclusive `&mut T`: it may mutate protected state but cannot move or return that state, escape it through a closure, or suspend with `await`. +- `await` while a guard is live and closure capture of a guard are compile-time ownership errors. Guard-bearing results must be bound once with `val`; they cannot be discarded, reassigned, stored in aggregates, passed through arbitrary calls, or hidden inside another mutex. +- Blocking `SharedMutex.lock()`/timed acquisition is rejected while executing an actor. `lock_async()` returns a runtime-owned, caller-cancellable `GuardFuture` and is the nonblocking acquisition primitive. Contended async acquisition is queued inside the mutex and receives the permit by direct guard handoff; it does **not** allocate one helper thread per waiter. Acquisitions reserve the semantic actor/execution domain before waiting, so recursive async acquisition fails instead of self-deadlocking even if an actor migrates JVM workers. Cancellation removes queued waiters and releases their domain reservation; poisoning drains queued async waiters with `PoisonedMutexException`. Admission is bounded by the current actor mailbox policy, an 8,192-waiter ceiling per mutex, and a 32,768-waiter JVM-process ceiling. When blocking host waiters and async waiters coexist, release alternates handoff preference so neither class monopolizes the mutex. The runtime also exposes `lockAsyncFor(Duration)`, using the same queue plus one shared daemon timeout scheduler; expiry completes with `LockTimeoutException` and removes the waiter immediately. This remains a backend/runtime API until source-level duration/timeout representation is finalized. Language `await` lowering must suspend/resume the actor turn rather than synchronously join an incomplete future; until continuation lowering is complete, actor-backed singleton/mailbox serialization is preferred over contended shared-memory locking from shared actors. +- A poisoned `SharedMutex` rejects ordinary acquisition until `recover(...)` repairs invariants and clears poison. `recover` is not an ordinary lock operation: it is rejected when the mutex is healthy. Inside actor execution recovery is nonblocking; if another recovery owns the permit, the actor must retry on a later mailbox turn rather than park. + +Example: + +```ores +val state = Mutex.new(new Counter()); +val guard = state.lock(); +guard.count = guard.count + 1; +guard.release(); + +val shared = SharedMutex.new(new Cache()); +val shared_guard = await shared.lock_async(); +shared_guard.increment_hits(); +shared_guard.release(); + +shared.with_lock(|cache| -> { + cache.put("key", "value"); + return; +}); +``` + +A process-wide `singleton module` is **not** raw shared memory. It is owned by one hidden singleton actor and accessed through its typed mailbox/proxy, so its mutable state is serialized by actor execution and normally requires no mutex. Do not wrap singleton-module state in `SharedMutex` merely because multiple actors can call it. Use `SharedMutex` only when code deliberately opts into a writable same-process memory object that multiple actor/execution domains may dereference directly. + +When the private-arena/`isoactor` runtime is stacked with this work, isolated actors must run without `SHARED_MEMORY` authority. An `isoactor` may receive copied/frozen messages, but it must not receive a `SharedMutex` or any other writable JVM-heap alias; otherwise the language would no longer be able to claim true actor memory isolation. + +The current reference runtime uses a one-permit JVM semaphore for `SharedMutex`. Java semaphore release/acquire provides the required memory-ordering edge and, unlike a thread-owned `ReentrantLock`, allows an asynchronously acquired guard to be resumed and released by the actor execution context. + +Actor transport is independently hardened from mutex synchronization. Ordinary messages are recursively frozen with cycle detection and hard depth/node/byte budgets (256 levels, 100,000 nodes, 16 MiB estimated frozen size). Read-only shared wrappers are runtime-constructed and revalidated on every boundary. `ActorRef` capabilities may cross only inside their owning `ActorRuntime`; a wrapper cannot be used to smuggle a foreign actor reference into another runtime. Arbitrary host-controlled `Sendable` callbacks are not part of the transport boundary. Runtime-owned capabilities have explicit cases, while ordinary message graphs are recursively frozen/copied and validated. + +Compiler-generated/context-aware `BehaviorFactory` values are capture-free for both private and shared actors. This prevents a shared actor from bypassing mailbox/capability semantics by closing over an arbitrary mutable JVM object. `spawnPrivateTrusted(...)`, `spawnSharedTrusted(...)`, and trusted `Supplier` construction are host/supervisor escape hatches only; adversarial policies reject them. + +## Async task runtime + +Ordinary `async` callables are separate from actor dispatchers. The reference interpreter owns one context-local async scheduler and returns `CompletionStage`/language `Future` values immediately. Its first backend uses Java virtual threads so blocking host/runtime operations do not consume the bounded private/shared actor worker pools. This is a transitional execution strategy: Oreslang source semantics are future/continuation based, not virtual-thread based. + +The design intentionally mirrors the strongest C# async/await practices: + +- do not make `async` synonymous with "new OS thread"; +- avoid sync-over-async on bounded actor workers; +- propagate cancellation to the underlying task; +- preserve the original exception at `await`; +- keep the execution scheduler out of the source-level future contract; +- separate I/O/task concurrency from explicitly CPU-bound scheduling. + +Because the current interpreter has not yet lowered `await` into a resumable state machine, an ordinary async virtual carrier may block while awaiting another future. Actor carriers are different: an incomplete `await` from an actor turn is rejected rather than parking the dispatcher. Adversarial contexts also fail closed for ordinary async execution until continuation lowering can release the strict guest-turn serialization lock at suspension points. + +Async callable arguments/results are detached at the evaluator boundary. This is stricter than C#'s shared managed heap and preserves Oreslang's ownership direction: mutable task state is owned by the task instead of becoming an implicit cross-thread alias. Generic async boundaries remain closed until a Send/task-safe generic contract exists. + +## HungryActor: explicit dedicated CPU carrier + +`HungryActor` is the deliberate exception to the ordinary multiplexed actor rule. It is a runtime primitive for sustained CPU-bound or thread-affine work and owns one dedicated **JNI-attached pthread carrier** from construction until `release()`/termination. It does not create a Java platform thread. + +Its invariants are: + +- exactly one dedicated native pthread carrier per live HungryActor; +- bounded nonblocking mailbox admission; +- messages are frozen before delivery; +- serial message execution; +- fail-stop behavior on callback failure; +- cooperative CPU-loop cancellation through `schedulerSafepoint()`; +- `release()`/close relinquishes the carrier, with bounded shutdown observation; +- it never consumes a private/shared ActorRuntime dispatcher worker. + +A HungryActor is intentionally expensive. It is appropriate when reserving a whole carrier is the requirement—not as the default way to obtain parallelism. Ordinary actors should remain multiplexed, and ordinary `async` should remain task/future based. The current class is a host/compiler runtime primitive; exposing a richer source-level constructor must preserve the same ownership and capability checks rather than becoming a raw guest thread API. + +## Actor dispatchers + +The host actor runtime follows the same scheduling shape as Akka's event-based dispatcher: many actors share an executor, each actor has its own mailbox, and a scheduled actor drains only a bounded number of messages before yielding back to the executor. The configured throughput bound prevents one hot mailbox from monopolizing a worker. + +Oreslang deliberately uses two executors: + +- **private dispatcher** — private actors, isolation-copy message transport; +- **shared dispatcher** — shared actors, immutable sharing plus explicit `SyncCell` shared state. + +A per-actor atomic scheduling gate ensures only one drain task for that actor is active. The executor may run different turns on different threads; thread identity is never actor identity. + +The runtime does not interrupt a carrier thread to stop one actor because that thread belongs to the dispatcher and may subsequently execute unrelated actors. Actor cancellation is observed at compiler-injected scheduler safepoints. Whole-runtime shutdown may interrupt the dispatcher executors. + + +## Shared actor memory + +Shared actors keep ordinary mutable fields actor-owned and mailbox-serialized. Cross-actor mutable memory is exceptional and represented by `SyncCell`. + +`SyncCell` is runtime-owned and closeable. Its frozen state consumes shared actor-memory quota; growth reserves quota before publishing a replacement value, shrink/close returns quota, and runtime teardown closes remaining cells. The combined private-slice plus shared-cell total cannot exceed the parent `IsolatePolicy.maxHeapBytes()`. + +A private actor turn cannot create, snapshot, read, update, or close synchronized shared state even if trusted host code accidentally captured a cell handle. Source admission and runtime creation both require `SHARED_MEMORY`. + +Actor failures are fail-stop in this layer. The actor ref retains the failure cause for diagnostics, queued reservations are drained, and later sends receive an `ActorTerminatedException` rather than silently targeting a dead mailbox. + +## Private actor memory confinement + +A private actor is assigned an `ActorMemorySlice` when it is created. The slice is keyed by actor identity rather than by dispatcher thread because actor turns may migrate between worker threads. + +Private mailbox admission is: + +1. reject explicitly shared mutable handles such as `SyncCell`; +2. isolation-copy/freeze the message graph; +3. conservatively estimate its logical Oreslang heap size; +4. reserve those bytes against the destination actor slice and the parent runtime budget; +5. enqueue only after both reservations succeed; +6. release transient mailbox bytes after the mailbox turn completes. + +Persistent generated actor state reserves from the same slice. Actor teardown closes the entire slice, so leaked host-side reservation handles cannot keep a dead actor's memory budget alive. + +The logical size metric intentionally does not claim to equal JVM object layout. It exists to enforce Oreslang memory-domain policy while actors remain multiplexed on one JVM. A hardened backend may replace the accounting implementation with arena/region allocation or a Graal/native isolate without changing source semantics. + +A private actor's memory owner is its **ActorId**, never its carrier thread. Successive mailbox turns may execute on different private-dispatcher workers. Consequently a future FFM/off-heap backend must not make `Arena.ofConfined()` carrier-thread identity part of Oreslang semantics. It should use a cross-thread-capable region whose access is guarded by the actor owner token, or map the private actor to a true Graal/native isolate when physical heap isolation is required. + +## Native runtime boundary + +Oreslang's preferred actor carrier backend is now a JNI bridge to a bounded pthread pool on Linux and macOS. The library is built from `src/main/c/oresthread.c`; each pthread attaches to the host VM once and then multiplexes many unrelated Oreslang actor turns. Actor identity remains independent of physical carrier identity. + +The backend selector is `-Dores.runtime.carriers=auto|native|java`: + +- `auto` prefers the native pthread backend on supported Unix hosts and falls back only when the native library cannot be linked; +- `native` fails closed if the JNI runtime cannot be loaded or initialized; +- `java` is an explicit compatibility/debugging backend and must not be treated as the production Oreslang scheduler. + +`process.descriptor.actor_carrier_backend` reports the physical backend so tests and supervisors can verify that native execution is actually active. + +This does **not** mean the whole runtime is native yet. The current actor mailbox containers, shared-memory synchronization, async virtual-thread bridge, GC timer, and several host-integration data structures still use Java runtime primitives. Those are migration targets behind Oreslang-owned abstractions; Native Image compilation by itself is not considered proof that a primitive is natively implemented. New runtime features should avoid exposing Java concurrency types in language semantics and should prefer the JNI/native substrate where a physical scheduler, clock, thread, or memory primitive is required. + diff --git a/docs/TRAP.md b/docs/TRAP.md new file mode 100644 index 00000000..f369c06a --- /dev/null +++ b/docs/TRAP.md @@ -0,0 +1,891 @@ +# Reserved `trap` keyword and error-control contract + +Status: **draft design / compiler contract** + +This document specifies the proposed reserved `trap` modifier for Oreslang callables and the control-flow distinction between `throw`, `raise`, and `panic`. + +The central guarantee is: + +> A successfully compiled `trap` callable never lets an ordinary `throw` cross its call boundary. The compiler converts the uncaught throw into an explicit two-slot result. `raise` and `panic` are deliberately different control effects: they bypass `trap` and unwind to the nearest `recover` boundary. + +This is a language and ABI contract, not merely parser sugar around a handwritten `try/catch`. + +## Goals + +- reserve `trap` as a first-class callable modifier; +- guarantee that uncaught `throw` cannot escape a `trap` invocation; +- make success and trapped failure explicit in the call-expression type; +- require callers to consume the trap result rather than silently discard errors; +- preserve a deliberate escape mechanism for exceptional control flow through `raise` and `panic`; +- make `throw`, `raise`, `panic`, `trap`, and `recover` distinct compiler effects so optimization, inlining, callbacks, interfaces, foreign code, actors, and AOT/JIT lowering cannot erase the boundary; +- integrate with Oreslang's existing one-return-value rule: tuples/arrays/records remain ordinary single values. + +## Syntax + +`trap` is globally reserved. + +Canonical declarations: + +```ores +pub trap fnc load_user(UserId id): User { + return db_load(id); +} + +pub trap routine initialize(): void { + perform_initialization(); + return; +} +``` + +For instance methods, which intentionally omit `fnc`/`routine`: + +```ores +define class Loader as + + pub trap load(UserId id): User { + return self.lookup(id); + } + +end +``` + +Canonical modifier ordering: + +```text +[visibility] [pure] trap fnc ... +[visibility] [pure] trap routine ... +[visibility] [pure] trap method(...) +``` + +The parser may accept equivalent modifier ordering during migration, but formatting and diagnostics should normalize to the canonical order. + +## Declared return type versus call-expression type + +The declared return type is the **success value**. + +Example: + +```ores +pub trap fnc load_user(UserId id): User { + ... +} +``` + +The body type-checks as though successful `return` statements return `User`. + +The call expression has a compiler-owned nominal result type: + +```text +TrapResult +``` + +Its mandatory two-slot destructuring view is: + +```text +[ + Option<[User]>, + Option +] +``` + +This distinction is deliberate. A raw structural tuple of two `Option` values would permit user code to fabricate invalid trap states. `TrapResult` is compiler/runtime-owned and cannot be constructed with an ordinary tuple/list literal, object literal, unchecked cast, or user-defined constructor. + +Oreslang still has one return value. The `[User]` in the success projection is the normalized success bundle used by trap destructuring, not language-level multiple return values. + +For `void`: + +```text +TrapResult + destructures as +[ + Option<[]>, + Option +] +``` + +A successful void trap exposes `Some([])` in the success slot. + +## Trap-result invariant + +Every `TrapResult` has exactly one populated side. + +Success: + +```text +[Some([value]), None] +``` + +Failure: + +```text +[None, Some(error)] +``` + +For `void` success: + +```text +[Some([]), None] +``` + +These states are unrepresentable as `TrapResult`: + +```text +[None, None] +[Some(...), Some(...)] +``` + +The runtime should store the value as a tagged success/error representation and synthesize the two Option projections for destructuring. Backends may choose a compact tagged ABI, but they may not expose a writable pair of independent option fields. + +A manually created ordinary tuple that happens to have the same visible element types is **not** a `TrapResult` and is not implicitly assignable to one. + +## Caller destructuring + +The ordinary form keeps the success bundle intact: + +```ores +const [results, err] = load_user(id); +``` + +with: + +```text +results : Option<[User]> +err : Option +``` + +Oreslang also supports Option-aware nested trap destructuring for the common single-success-value case: + +```ores +[const [user], const err] = load_user(id); +``` + +with: + +```text +user : Option +err : Option +``` + +On success: + +```text +user = Some(User) +err = None +``` + +On trapped failure: + +```text +user = None +err = Some(TrapError) +``` + +This syntax does **not** mean the function has multiple return values. It is compiler-supported destructuring/lifting of the optional normalized success bundle. + +The parser/AST must therefore support a recursive destructuring pattern for this form. The current flat `DestructureStmt(List)` representation is not sufficient; trap implementation must introduce a recursive pattern node rather than special-casing token text after parsing. + +If the declared return type itself is a tuple, that tuple remains one success value. The trap layer does not flatten the user's returned tuple: + +```ores +pub trap fnc pair(): [int, String] { + return [7, "seven"]; +} + +[const [pair_value], const err] = pair(); +// pair_value : Option<[int, String]> +``` + +There is no ambiguity between the compiler-owned outer success bundle and a tuple returned by user code. + +### Ownership of trap projections + +Trap destructuring obeys the ordinary ownership model. + +- `TrapResult` owns either the success payload or the `TrapError`; +- destructuring consumes/moves the selected payload exactly once unless the surrounding operation is an explicit borrow; +- nested Option-aware destructuring does not implicitly copy a class, closure, buffer, actor capability, or other non-`Copy` value; +- after a consuming destructure, the original `TrapResult` cannot be reused; +- borrowing a trap result may expose borrowed projections, but a borrow cannot outlive the result owner; +- the absent branch carries no hidden alias to the present payload. + +For a non-`Copy` `User`, this: + +```ores +[const [user], const err] = load_user(id); +``` + +moves the success `User` into the resulting `Option` on success. It does not clone the user object. + +## `throw`: the trappable error channel + +`throw` is the ordinary exception/error control effect. + +Inside a `trap` invocation, any `throw` that is not handled by a nearer explicit `try/catch` is absorbed by the nearest dynamic trap boundary. + +Example: + +```ores +pub trap fnc foo(): String { + throw new Error("bad"); +} +``` + +Conceptual lowering uses an **internal throw-only boundary**, not a source-level broad `try/catch`: + +```text +foo$trap_lowered() : TrapResult { + trap_boundary { + execute foo body + } + on ThrowSignal(err) { + return TrapResult.error(TrapError.from(err)) + } +} +``` + +The lowering must not be implemented as `catch (RuntimeException)`, `catch (Throwable)`, or another host-language catch that could accidentally intercept `RaiseSignal`, `PanicSignal`, cancellation, termination, or VM-fatal failures. + +A successful return: + +```ores +return "ok"; +``` + +conceptually becomes: + +```text +[Some(["ok"]), None] +``` + +A `throw` in a nested non-trap helper also belongs to the **dynamic execution extent** of the trap boundary: + +```ores +fnc helper(): User { + throw new Error("missing"); +} + +pub trap fnc load(): User { + return helper(); +} +``` + +The caller of `load()` receives the error side. The exception does not escape `load`. + +This is dynamic, not merely transitive/static. A synchronous callback invoked while the trap frame is active is covered. Work that is detached and executed later on another task/actor/domain does **not** inherit the old trap frame. It follows its own task/actor supervision rules unless it establishes its own trap boundary. + +If no dynamic `trap` boundary exists, `throw` retains normal Oreslang `try/catch` / unhandled-exception behavior. + +## `raise`: deliberate trap escape + +`raise` is a distinct reserved control-flow keyword/effect. + +A `raise`: + +- is **not** converted into `TrapError`; +- is **not** absorbed by `trap`; +- unwinds across nested `trap` callables; +- continues until the nearest matching `recover` boundary; +- runs required `defer` / `finally` cleanup while unwinding; +- remains distinguishable from `panic`. + +Example: + +```ores +pub trap fnc parse_config(): Config { + if unrecoverable_configuration_state { + raise new ConfigAbort("cannot continue"); + } + + if malformed_user_value { + throw new ParseError("bad value"); + } + + return config; +} +``` + +The malformed-value `throw` becomes: + +```text +[None, Some(TrapError(...))] +``` + +The `raise` crosses the trap boundary and keeps unwinding toward `recover`. + +This gives library/application code an explicit way to say: + +> This failure must not be normalized into the local trap result. + +## `panic`: invariant/runtime escape + +`panic` is also a distinct reserved control-flow keyword/effect. + +Like `raise`, `panic` bypasses `trap` and unwinds to the nearest `recover` boundary. + +It exists for invariant violations, impossible states, compiler/runtime assertions exposed to guest semantics, and failures that should not be mistaken for an ordinary trappable application error. + +Example: + +```ores +pub trap fnc decode(Packet p): Message { + if compiler_proven_impossible_state_became_reachable { + panic new InvariantError("decoder invariant broken"); + } + + ... +} +``` + +A panic must never silently become the ordinary `err` slot of the trap result. + +`raise` and `panic` may share an internal unwinding mechanism, but their kinds must remain distinct in diagnostics, metadata, tracing, and `recover` handling. + +## `recover` boundary + +`recover` is the dynamic boundary that can intercept `raise` and `panic`. + +The trap design requires the compiler IR to represent a recover boundary explicitly so that optimizer/inlining transformations cannot accidentally turn a non-trappable unwind into a trapped `throw`. + +The exact final surface syntax for typed/selective recovery may evolve independently, but these semantics are fixed: + +1. `throw` does not skip a trap boundary. +2. `raise` skips every trap boundary until recover. +3. `panic` skips every trap boundary until recover. +4. `defer` / `finally` execute according to normal unwinding rules. +5. If no recover boundary exists, an unhandled `raise` or `panic` terminates the current top-level execution domain according to runtime policy (process, actor, isolate, or untrusted actor), rather than being fabricated into a trap result. + +A future/selective `recover` syntax may choose to recover only `raise`, only `panic`, or specific payload types. The semantic distinction must already exist in compiler metadata. + +A recover handler is **outside** the boundary it handles. If the handler executes another `raise` or `panic`, that new signal cannot be caught again by the same recover frame; it continues to the next matching outer recover boundary. This prevents self-recursive recovery loops. + +Recovery may explicitly convert a signal into ordinary control flow, including returning a value or deliberately issuing a new ordinary `throw`. That conversion is explicit; the runtime never silently downgrades `raise`/`panic` into `throw`. + +## Interaction with explicit `try/catch` + +Ordinary `try/catch` handles the `throw` channel. + +It does not implicitly downgrade `raise` or `panic` into `throw`. + +Therefore: + +```text +try/catch -> throw +trap -> uncaught throw at callable boundary +recover -> raise / panic +``` + +This separation prevents a broad local catch from accidentally swallowing a deliberate trap escape. + +A future explicit syntax may allow a recover block to classify or rethrow a raised/panicked signal. + +## Cleanup precedence: `defer` / `finally` + +Cleanup must not accidentally weaken a stronger in-flight control signal. + +The runtime uses this precedence when cleanup itself fails: + +```text +fatal termination > panic > raise > throw > return/success +``` + +Rules: + +- a cleanup `throw` cannot replace an already-unwinding `raise` or `panic`; it is attached as a suppressed/secondary failure; +- a cleanup `raise` may supersede an in-flight `throw`, but not an in-flight `panic`; +- a cleanup `panic` supersedes an in-flight `throw` or `raise`; +- fatal runtime termination/cancellation cannot be suppressed by user cleanup; +- when two failures have the same precedence, preserve the original in-flight failure as primary and attach the later cleanup failure as suppressed; +- all applicable cleanup still runs in the required LIFO/order semantics unless the execution domain is no longer capable of safely executing guest code. + +This makes trap bypass monotonic: once execution is unwinding as `raise` or `panic`, an incidental cleanup `throw` cannot turn it back into something an outer `trap` consumes. + +## Cancellation, budget exhaustion, and forced termination + +Scheduler/runtime control is **not** the ordinary throw channel. + +The following must bypass `try/catch` and `trap` and cannot be converted into `TrapError` merely because guest code surrounds work with a trap: + +- actor/task cancellation used to stop execution; +- untrusted-actor fuel exhaustion; +- untrusted-actor wall-clock lifetime expiration; +- hard memory-budget enforcement; +- isolate revocation/termination; +- supervisor-forced shutdown; +- process/VM fatal termination. + +Application-level cancellation represented deliberately as a normal guest `throw` is still trappable. Runtime-enforced cancellation is a separate internal control effect (for example `CancelSignal` / `TerminateSignal`) and must remain non-catchable by ordinary guest code unless a future capability explicitly permits cooperative cancellation handling. + +This rule is required so untrusted or buggy code cannot defeat resource limits with: + +```ores +pub trap fnc keep_running_forever(): void { + // runtime cancellation must not become err=None/Some and then be ignored +} +``` + +## Error type + +The trap error should be structured, not reduced to a string: + +```ores +struct TrapError { + kind: TrapErrorKind, + message: String, + cause: Option, + stack: Option, + source: Option +} +``` + +Initial trappable categories may include: + +```text +Thrown +Assertion +Arithmetic +Bounds +ForeignException +Io +Runtime +``` + +Runtime-enforced cancellation/termination is intentionally absent. `Raised` and `Panic` are also **not** ordinary `TrapErrorKind` values because they do not travel through the trap error slot. + +`TrapError` must be a guest-safe immutable value. It must not retain a raw Java `Throwable`, native pointer, file descriptor, actor capability, mutable host object, or other ambient authority. Host causes are normalized/redacted into guest-safe metadata before crossing the trap boundary. Cause/suppressed chains must be cycle-safe and bounded by runtime policy. + +The stack/source location is captured at the original throw/failure point, before trap conversion, so the error does not misleadingly appear to originate at the trap boundary itself. + +## Returned Error objects remain data + +Only control flow determines whether a value is trapped. + +Example: + +```ores +pub trap fnc inspect(): Error { + return new Error("this is data"); +} +``` + +Result: + +```text +[Some([Error("this is data")]), None] +``` + +Returning an `Error` value is not equivalent to `throw`. + +## Trap results may not be silently discarded + +A trap call used as a bare expression is a compile error: + +```ores +load_user(id); // ERROR: trap result discarded +``` + +The caller must bind/consume the result: + +```ores +const [results, err] = load_user(id); +``` + +or: + +```ores +[const [user], const err] = load_user(id); +``` + +A future explicit discard operator may be introduced, but silent discard is forbidden. + +Using the existing destructuring discard token `_` for the **error slot** is also rejected in v1: + +```ores +[const results, _] = load_user(id); // ERROR in v1 +``` + +The language may later add an unmistakably explicit `discard trap_error`/annotation if intentional loss is needed, but ordinary sequence destructuring must not become a loophole around mandatory error acknowledgement. + +## Async/await and futures + +`trap` must have defined asynchronous semantics because Oreslang already has `async` / `await`. + +For an async trap callable: + +```ores +pub async trap fnc fetch(): Payload { + ... +} +``` + +the call expression type is: + +```text +Future> +``` + +The trap boundary spans the complete asynchronous computation, including code resumed after suspension. + +- an ordinary `throw` before or after an `await` completes the future successfully with `TrapResult.error(...)`; +- a `raise`/`panic` completes the future with its distinct non-trappable control signal; +- awaiting that future re-emits the same `raise`/`panic` at the await site so a surrounding recover boundary can handle it; +- a detached/unawaited task has no dynamic caller recover frame, so unrecovered `raise`/`panic` is routed to task supervision rather than fabricated into `TrapError`; +- runtime cancellation of the future remains the non-trappable cancellation channel described above; +- callbacks/tasks explicitly detached from the async computation do not inherit its trap/recover frames. + +This preserves the same semantics across suspension instead of making `trap` only cover the synchronous prefix of an async function. + +Generator/yield + `trap` is rejected until generator suspension semantics receive an equally explicit contract. + +## Callable types and ABI + +`trap` is part of the callable contract. + +These are not the same callable type: + +```text +fnc(): User +trap fnc(): User +``` + +The latter has an explicit trap-result call ABI and a no-`throw`-escape guarantee. + +The compiler must preserve this distinction through: + +- function values; +- callbacks; +- generics; +- closures; +- interface slots; +- trait slots; +- virtual dispatch; +- imported/compiled package metadata; +- Java/JNI/native interop; +- actor messages/callbacks; +- JIT and AOT lowering. + +Unknown metadata fails closed when an exact trap guarantee is required. + +## Overrides and interfaces + +A trap contract cannot be erased by implementation. + +If an interface/base slot requires a trap callable, an override/implementation must preserve the trap guarantee. + +An implementation may eventually be allowed to strengthen a non-trap slot into a trap slot only if callable variance/ABI rules make the conversion explicit and safe. V1 should require exact trap-effect compatibility for overrides and interface implementation. + +## `pure trap` + +`pure` and `trap` are orthogonal and may be combined: + +```ores +pub pure trap fnc divide(int a, int b): int { + return a / b; +} +``` + +A deterministic arithmetic failure may enter the trappable `throw` channel without making the computation impure. + +Likewise, `raise` and `panic` are control effects rather than automatically external side effects. A callable may remain pure if constructing and propagating the signal performs no forbidden effect. + +Therefore `pure` does not imply `nothrow`, `noraises`, or `nopanic`. + +The internal effect system must track these properties independently. + +## Internal effect model + +The compiler effect model must distinguish at least: + +| Effect | Meaning | Absorbed by `trap` | +| --- | --- | --- | +| `throw` | ordinary guest exception/error | yes, if uncaught before boundary | +| `raise` | deliberate non-trappable exceptional escape | no | +| `panic` | invariant/runtime non-trappable escape | no | +| `diverge` | possible nontermination | no | +| other effects | I/O, mutation, actor, scheduler, FFI, etc. | unrelated | + +A single undifferentiated "exception" bit is insufficient. + +Transitive effect inference must preserve whether a call may throw, raise, or panic. + +Example: + +```ores +fnc inner(): User { + raise new Abort("stop"); +} + +pub trap fnc outer(): User { + return inner(); +} +``` + +`outer` has no escaping `throw` after trap lowering, but it **does** retain a transitive `raise` effect. + +## Compiler IR requirements + +Do not immediately desugar `trap` into arbitrary source-level `try/catch` and then lose its identity. + +Recommended IR nodes/effects: + +```text +TrapBoundary +ThrowSignal +RaiseSignal +PanicSignal +CancelSignal +TerminateSignal +RecoverBoundary +``` + +These are language/runtime control signals, not an invitation to model every case as an arbitrary host exception class. + +The trap boundary must survive long enough for: + +- effect checking; +- control-flow analysis; +- cleanup-edge construction; +- inlining; +- exception-table generation; +- actor/isolate boundary lowering; +- JVM/Graal lowering; +- native/AOT lowering. + +After semantics are fixed, a backend may implement the trap boundary with exception tables, tagged returns, CPS, setjmp-like native machinery, or another representation, provided observable behavior is identical. + +The current interpreter already has an internal `OresPanic` used by operations such as `Option.unwrap` / `Result.unwrap`, and current `try/catch` deliberately rethrows it. Implementation should preserve that useful behavior while replacing the ad-hoc Java-class distinction with explicit first-class panic/control metadata. In particular, ordinary Oreslang `try/catch` must never rely on a broad `RuntimeException` catch once these channels are implemented. + +Tail-call optimization must also preserve boundaries. A tail call from a trap callable to a potentially-throwing callee may not erase the caller's `TrapBoundary`. A recursive trap implementation may optimize the recursion only when the observable nearest trap/recover behavior and stack/error metadata contract remain unchanged. + +## Optimizer rules + +Inlining may not erase or move a trap/recover boundary in a way that changes which signal catches which failure. + +In particular: + +- a `throw` from an inlined callee must still be absorbed by the same nearest trap boundary; +- a `raise` from an inlined callee must still bypass trap; +- a `panic` from an inlined callee must still bypass trap; +- cleanup ordering must remain unchanged; +- dead-code elimination may not assume `trap` means the call cannot `raise`, `panic`, or diverge; +- pure-call optimizations must continue to respect throw/raise/panic/divergence ordering. + +## Java/JNI/native/host interop + +Foreign failures require explicit classification. + +Default fail-closed behavior: + +- only an explicitly mapped, expected foreign exception class/category may enter the ordinary `throw` channel and therefore be trapped; +- a foreign fatal/invariant condition must not automatically be mislabeled as a trappable throw; +- unknown host failures are not silently swallowed; +- Java `Error`-class VM failures (for example OOM/stack/VM linkage failures) are not blanket-converted to `TrapError`; +- thread interruption/cancellation used by the scheduler is not blanket-converted to `TrapError`; +- process-kill, VM corruption, hardware failure, or OS termination cannot be promised recoverable merely because source contains `trap`. + +Compiler/runtime-owned adapters must classify foreign failures into the correct Oreslang control effect before they enter guest control flow. + +The runtime must use dedicated internal carrier types/tags for `ThrowSignal`, `RaiseSignal`, `PanicSignal`, cancellation, and termination. No generic `catch (RuntimeException)` / `catch (Throwable)` is allowed to define language semantics. + +Raw native crashes must never be described as ordinary `TrapError` unless the runtime actually isolated and converted them safely. + +## Actors, isolates, and untrusted actors + +The same semantic channels apply inside actor execution. + +A `throw` leaving a trap actor helper/entrypoint becomes its trap error result. + +A `raise` or `panic` bypasses trap and unwinds to the nearest recover boundary **within that execution domain**. + +A recover boundary never implicitly spans an actor, isolate, process, or detached-task boundary. Those boundaries are supervision/transport boundaries, not shared call stacks. An unrecovered signal from a child domain is converted into the domain's typed exit/supervision outcome; the original mutable error object/capability is not smuggled into the parent. Safe immutable metadata may be reported according to policy. + +If unrecovered: + +- a normal/shared actor follows actor-supervision failure policy; +- an isolated actor terminates its isolated execution domain safely; +- an untrusted actor is terminated and its confined memory/capabilities are reclaimed according to sandbox policy. + +The runtime must not forge a successful trap result after an unrecovered raise/panic merely to keep the caller alive. Actor supervision may separately report actor termination as a transport/supervision outcome. + +## Diagnostics + +Proposed stable diagnostic families: + +- `E-TRAP-001`: invalid `trap` modifier placement; +- `E-TRAP-002`: trap result discarded; +- `E-TRAP-003`: invalid trap-result destructuring; +- `E-TRAP-004`: trap callable override/interface mismatch; +- `E-TRAP-005`: incompatible trap callable value/callback assignment; +- `E-TRAP-006`: compiler cannot classify foreign failure semantics; +- `E-TRAP-007`: invalid trap result state exposed by compiler/runtime lowering; +- `E-TRAP-008`: illegal attempt to treat `raise`/`panic` as ordinary trappable throw; +- `E-TRAP-009`: missing/invalid recover metadata for a construct that requires it; +- `E-TRAP-010`: attempt to forge/coerce a raw tuple into `TrapResult`; +- `E-TRAP-011`: attempt to discard the trap error slot; +- `E-TRAP-012`: unsupported trap suspension form (for example generator/yield before specified); +- `E-TRAP-013`: runtime cancellation/termination incorrectly classified as trappable. + +Diagnostics for propagated effects should identify the shortest useful path. + +Example: + +```text +error[E-TRAP-008]: 'raise' bypasses trap and cannot be converted to TrapError + + load_user + └── calls validate_session + └── calls abort_session + └── effect: raise SessionAbort +``` + +## Required compiler work + +1. reserve `trap`, `raise`, `panic`, and the recover boundary keyword/symbol; +2. parse `trap` on `fnc`, `routine`, instance methods, and compatible interface/abstract slots; +3. record trap contract in callable AST/symbol/type metadata; +4. split compiler control effects into `throw`, `raise`, `panic`, cancellation, termination, and divergence; +5. represent `TrapBoundary` and `RecoverBoundary` explicitly in IR; +6. introduce nominal compiler-owned `TrapResult` with a read-only two-slot destructuring projection; +7. replace/extend flat destructuring AST with recursive destructuring patterns and implement Option-aware nested trap destructuring; +8. reject silently discarded trap results and error-slot `_` discards; +9. lower ordinary successful returns into the success variant; +10. lower uncaught `throw` at the trap boundary into a guest-safe immutable `TrapError`; +11. ensure `raise`, `panic`, cancellation, and termination bypass trap; +12. preserve `defer` / `finally` cleanup and enforce monotonic cleanup precedence on every unwind path; +13. define async `Future>` lowering across suspension/await; +14. enforce callable/override/interface/callback trap compatibility; +15. serialize trap and throw/raise/panic/cancellation effect metadata across compiled units; +16. classify Java/JNI/native failure mappings explicitly and remove broad host-exception catches from language semantics; +17. harden optimizer/inliner/tail-call/AOT/JIT passes against boundary erasure; +18. integrate unrecovered raise/panic/cancellation with task/actor/isolate/untrusted-actor termination/supervision policy. + +## Required tests + +### Compile-pass / runtime-pass + +- `trap fnc` success; +- `trap routine` success; +- trap instance method success; +- void success produces `Some([])`; +- one value produces `Some([value])`; +- ordinary `throw` becomes `TrapError`; +- throw from nested non-trap helper is trapped; +- inner explicit `try/catch` may handle throw before trap; +- returned `Error` object remains ordinary success data; +- `[const results, const err]` destructuring; +- `[const [result], const err]` Option-aware destructuring; +- `pure trap fnc`; +- trap callable through function value with compatible metadata; +- interface/override preserving trap contract; +- deterministic mapped foreign exception becomes trapped throw; +- defer/finally executes before trapped throw result is produced; +- manually constructed two-Option tuple cannot masquerade as `TrapResult`; +- trap error stack points to the original throw site; +- async trap catches a throw after an await; +- awaited async raise/panic re-emits the same signal kind at the await site. + +### Raise/panic escape + +- `raise` directly inside trap bypasses trap; +- `panic` directly inside trap bypasses trap; +- raise from nested helper bypasses outer trap; +- panic from nested helper bypasses outer trap; +- raise/panic cross several nested trap callables; +- nearest recover boundary receives the correct signal; +- raise remains distinguishable from panic; +- defer/finally runs while raise/panic unwinds; +- optimizer/inlining does not change nearest trap/recover boundary; +- JIT and AOT behavior match; +- actor/isolate execution does not rewrite unrecovered raise/panic into the trap error slot; +- raise in a recover handler skips that same recover frame and reaches the next outer matching frame; +- cleanup throw cannot downgrade an in-flight raise/panic; +- cleanup raise can supersede throw but not panic; +- cleanup panic supersedes throw/raise; +- synchronous callback throw is caught by the active trap, while a later detached callback throw is not; +- consuming trap destructuring moves a non-`Copy` success payload exactly once. + +### Compile-fail + +- use `trap` as an identifier; +- invalid modifier target; +- discard trap result; +- destructure trap result with invalid arity/shape; +- assign trap callable to incompatible non-trap callable type without explicit supported adaptation; +- implement trap interface slot with incompatible non-trap callable; +- attempt compiler lowering that routes raise/panic into `TrapError`; +- foreign call whose failure classification is required but unknown; +- `[const results, _] = trap_call()` error-slot discard; +- raw tuple -> `TrapResult` coercion/assignment; +- trap + generator/yield until suspension semantics are specified. + +### Adversarial + +- throw hidden behind recursive `fnc` SCC; +- raise hidden behind recursive `fnc` SCC; +- panic hidden behind callback; +- throw/raise/panic through generic callable parameters; +- dynamic dispatch with mixed control effects; +- imported compiled unit with stale/missing effect metadata; +- exception from Java callback invoked under trap; +- nested trap inside recover; +- recover inside trap; +- trap inside trap; +- finally block that itself throws; +- finally block that raises; +- finally block that panics; +- untrusted actor raise/panic during HTTP response ownership; +- AOT exception-table optimization preserves exact boundary semantics; +- tail-call optimization cannot erase a trap boundary; +- forced untrusted-actor cancellation/fuel exhaustion bypasses trap; +- OOM/VM-fatal host failure is not converted to `TrapError`; +- cause/suppressed chains are bounded and cycle-safe; +- cross-actor/isolate signal handling reports supervision metadata without leaking raw host/guest capabilities; +- trap result cannot be reused after consuming destructure of a non-`Copy` payload; +- borrowed trap projections cannot outlive the owning `TrapResult`. + +## Implementation staging + +A safe implementation sequence is: + +1. documentation + reserved-word/parser tests; +2. AST/callable metadata; +3. effect split (`throw` / `raise` / `panic`); +4. explicit trap/recover IR boundaries; +5. trap call-result typing; +6. destructuring/lifting rules; +7. throw-to-result lowering; +8. raise/panic unwind lowering; +9. cleanup-edge hardening; +10. callable/interface/callback compatibility; +11. foreign failure classification; +12. actor/isolate integration; +13. optimizer/JIT/AOT hardening; +14. full adversarial matrix. + +Do not enable the surface keyword while any backend can still route `raise` or `panic` through the ordinary trap error slot. + +## Non-goals + +This contract does not promise that `trap` can recover from: + +- OS `SIGKILL`; +- process termination; +- hardware faults; +- corrupted native memory; +- VM failure; +- host failures that prevent the runtime from executing the trap boundary. + +The source-level guarantee is specifically about Oreslang's classified `throw` channel. + +`raise` and `panic` deliberately escape that guarantee. + +## Core invariant + +The implementation must preserve this distinction everywhere: + +```text +throw -> nearest explicit catch, otherwise nearest trap -> TrapError result +raise -> bypass catch/trap -> nearest recover +panic -> bypass catch/trap -> nearest recover +``` + +No parser rewrite, optimizer pass, foreign adapter, actor boundary, JIT optimization, or native backend may collapse these three channels into one. diff --git a/docs/TREE_SHAKING.md b/docs/TREE_SHAKING.md new file mode 100644 index 00000000..3b4a29b7 --- /dev/null +++ b/docs/TREE_SHAKING.md @@ -0,0 +1,126 @@ +# Build-time tree shaking + +Oreslang executable builds use a closed-world reachability pass before backend lowering. + +The optimizer: + +1. parses and type-checks the complete program; +2. resolves explicit build-time `const` overrides; +3. propagates constants and folds ternaries / `if` branches; +4. starts from executable entry points (normally `main`); +5. follows symbolic function, field, class, interface, and type references; +6. removes unreachable declarations, empty modules, and imports that are no longer referenced. + +This ordering is intentional. Tree shaking is an optimization and must not make invalid source valid merely because a build flag makes a branch unreachable. + +## Build switches + +Build switches override existing Oreslang `const` declarations. They do not create arbitrary globals and guest code never reads the compiler process environment implicitly. + +Example source: + +```ores +pub const bool use_a = false; + +define module A + pub fnc foo(): String { return "hi"; } +end + +define module B + pub fnc foo(): String { return "bye"; } +end + +type F = typeof fnc() => String; + +pub fnc choose(bool t): F { + return t ? A.foo : B.foo; +} + +pub routine main(): void { + val F selected = choose(use_a); + stdio.stdout.write(selected()); + return; +} +``` + +A build frontend may construct the build options from either environment variables or repeated CLI switches: + +```text +ORESLANG_BUILD_DEFINES=use_a=true,backend=native +ORESLANG_DEFINE_DEBUG=false + +--define=use_a=true +--define=backend=native +``` + +CLI definitions take precedence over environment definitions. A definition must target a real `const` declaration; overriding `val` or `let` is rejected. + +The compiler backend exposes the same path today for build diagnostics: + +```bash +ORESLANG_BUILD_DEFINES=use_a=true \ + oreslang-compiler --build-analysis app.ores + +oreslang-compiler --build-analysis --define=use_a=true app.ores +``` + +It prints retained and removed symbols. This is intentionally an analysis surface until the per-application artifact emitter is wired; it already consumes the same pruned AST that the emitter should consume. + +With `use_a=true`, the build constant becomes a literal at the call site. The optimizer specializes the simple single-return `choose(true)` call, folds its ternary to `A.foo`, and then performs reachability. `choose`, `B.foo`, and module `B` disappear when nothing else retains them. + +The compiler API is: + +```java +var defines = BuildOptions.mergeDefines(System.getenv(), cliDefines); +var result = OresCompiler.compileForBuild( + source, + BuildOptions.executable(defines)); + +result.program(); // pruned AST for backend lowering +result.retainedSymbols(); // diagnostics / build reporting +result.removedSymbols(); +``` + +Library builds use `BuildOptions.library(...)` and preserve the public API while still removing private unreachable declarations. + +## JVM / GraalVM guidance + +The design intentionally follows the same broad model used by GraalVM Native Image: build a closed-world reachability graph and include only program elements required at runtime. + +Relevant upstream guidance: + +- GraalVM Native Image overview and closed-world reachability: + https://www.graalvm.org/latest/reference-manual/native-image/ +- GraalVM reachability metadata: + https://www.graalvm.org/latest/reference-manual/native-image/metadata/ +- GraalVM file-size optimization (`-Os`) and build reports: + https://www.graalvm.org/latest/reference-manual/native-image/guides/optimize-for-file-size/ +- Oracle `jlink`, for JVM distributions that want a custom runtime containing only required Java modules: + https://docs.oracle.com/en/java/javase/26/docs/specs/man/jlink.html + +### Oreslang native images + +Normal native builds continue to use `-O2`. To prefer executable size, combine the native profile with `native-size`: + +```bash +mvn -Pnative-aot,native-size -DskipTests package +mvn -Pnative-hybrid,native-size -DskipTests package +mvn -Pnative-isolate,native-size -DskipTests package +``` + +The `native-size` profile selects GraalVM `-Os`. This is opt-in because GraalVM documents a size/performance tradeoff. + +For future per-application native images, the backend should feed the already-pruned Oreslang program to Native Image rather than asking GraalVM to recover language-level reachability from the generic interpreter/runtime. Dynamic Java/JNI/reflection registrations should remain conditional and as narrow as possible so they do not accidentally retain unrelated code. + +## Deliberate boundaries + +The first pass is symbol-level within an Oreslang program and folds explicit build constants and local immutable constants. + +Further optimizer work can build on the same symbolic reachability graph: + +- broaden constant-argument specialization beyond simple single-return `fnc` bodies to multi-statement/control-flow partial evaluation; +- cross-file symbol-level linking rather than file-granular import retention; +- class-member-level removal after virtual dispatch / reflection rules are fully specified; +- build reports with retained-byte attribution once the code-emission backend exposes sizes. + +Those additions should not change the build-switch contract introduced here. diff --git a/docs/channels-select-and-actor-cancellation.md b/docs/channels-select-and-actor-cancellation.md new file mode 100644 index 00000000..9f4df151 --- /dev/null +++ b/docs/channels-select-and-actor-cancellation.md @@ -0,0 +1,451 @@ +# Channels, select, actor mailboxes, and cancellation + +This document is the source/runtime contract for Oreslang channel waiting, +selection, actor mailbox transport, parent/child lifetimes, and cancellation. + +## One concurrency model: actors + +Oreslang has one general-purpose concurrency identity: the **actor**. + +An `async` callable, `Future`, `Awaitable`, channel wait, or select +registration is not a second goroutine/process model. It is suspension and +continuation machinery owned by an actor execution domain (or by the +runtime/root actor while bootstrapping). Code that wants an independently +concurrent job spawns an actor. + +This distinction is intentional: + +- actor identity owns mutable state, mailbox order, lifetime, supervision, and + cancellation; +- a Future represents completion, not a new concurrency identity; +- `await` suspends the owning actor/task continuation; +- Future/channel completion only makes a continuation runnable; +- completion threads never execute guest continuation code inline. + +The Java/Truffle reference runtime still contains transitional async plumbing. +That backend detail must not become language semantics. + +## Mailbox = actor policy around Channel + +Every actor owns exactly one inbound mailbox. The mailbox is **not** a second +queue primitive. Its transport container is an Oreslang channel: + +```text +ActorMailbox + actor id / owner + policy + quotas + freeze/copy/sendability rules + supervision + lifecycle metadata + scheduling metadata + Channel> transport +``` + +The mailbox layer remains responsible for: + +- actor/runtime affinity; +- private-copy vs shared/frozen transport; +- heap/mailbox quota reservation; +- capability validation; +- ActorRef validation; +- shared-memory restrictions; +- fail-stop actor behavior; +- scheduling the actor when an envelope becomes ready. + +The channel owns: + +- buffering/rendezvous; +- read/write waiters; +- close state; +- cancellation-safe waiter removal; +- select registration and arbitration. + +Runtime-only continuation envelopes use the same mailbox channel but are never +visible as guest messages. They have bounded reserved headroom so a full user +mailbox cannot silently discard a resumed `nb select` arm. + +Public `Channel`, `SelectCase`, and `SelectSet` values are +**execution-domain-local capabilities** in this version. They cannot be sent +through an actor mailbox or used as actor-callable parameters/results. +Actor-to-actor communication remains `ActorRef`/mailbox transport. This avoids +letting a raw channel object bypass actor isolation and share mutable guest +objects by reference. A future cross-actor channel capability would need an +explicit copy/freeze/ownership-transfer contract before it can be admitted. + +## Parent and child actors + +Parenthood is a **structured lifetime/supervision relation**, not a second +hidden communication transport. + +Holding an `ActorRef` grants message-send authority, not arbitrary lateral +termination authority. Inside actor code, lifecycle control is limited to the +actor itself and its structured descendants. An actor cannot stop/cancel a +parent, sibling, or unrelated actor merely because it was given that actor's +reply/recipient reference. Host/supervisor code remains able to control any +actor in its runtime. Actor-initiated child stop/cancel is nonblocking so it +never parks a scheduler carrier waiting for child finalization. + +When actor A spawns actor B during A's turn: + +1. B is registered as A's child. +2. The spawn result gives A the typed ActorRef/recipient capability used to send + messages to B. +3. B communicates back to A by receiving/passing an ActorRef/Recipient for A + when its protocol needs that capability. +4. Both directions still send into the destination actor's mailbox channel. +5. Stopping/failing/cancelling A cancels its structured descendants. +6. A is not fully finalized until its child set is finalized. + +There is no scheduler or full ActorMailman per actor. Schedulers/thread pools +remain shared runtime resources; a parent/child edge does not create a new +thread pool, scheduler, or queue. + +A future typed parent endpoint can make step 3 more ergonomic, but it must +remain an ActorRef/Recipient capability rather than an ambient mutable parent +object. + +## Channel operations + +A bounded channel is created with an explicit element type: + +```ores +val Channel input = Channel.new(64); +``` + +Capacity zero is a rendezvous/unbuffered channel. + +### Blocking/suspending forms + +```ores +val msg = readch input; +writech output, msg; +``` + +"Blocking" means **suspend the owning Ores continuation and release the carrier**. +It never means park a bounded actor carrier thread. + +The target lowering is: + +```text +readch ch + -> ch.read_async() + -> Future + -> suspend current actor state machine + -> return to scheduler + -> requeue actor continuation when Future settles + +writech ch, value + -> ch.write_async(value) + -> Future + -> same suspension path +``` + +The current reference interpreter executes already-ready blocking operations +and fails closed if a pending actor operation would otherwise require parking a +carrier. Full source-frame lowering to the existing OresScheduler resumable +Task ABI is the remaining implementation step. This is deliberately safer than +sync-over-async. + +### Nonblocking registration forms + +```ores +val Future pending_read = nb readch input; +val Future pending_write = nb writech output, value; +``` + +`nb` means **register and return immediately**. It does not mean "probe once." + +This distinction is important. A pending `nb readch` remains registered until +it completes or is cancelled. + +### Immediate probe forms + +```ores +val Option now = try readch input; +val bool wrote = try writech output, value; +``` + +`try` means **succeed now or return immediately without leaving a waiter**. + +Immediate probes and select registrations use the same channel arbitration, so +an immediate write can rendezvous with a pending select-read and an immediate +read can rendezvous with a pending select-write. + +## Static select + +Canonical static syntax: + +```ores +select { +case readch incoming: let msg + stdio.println("Received:", msg); + +case writech outgoing, payload: + stdio.println("Sent payload successfully"); + +case readch shutdown: const signal + return; +} +``` + +A read arm may bind with `let`, `val`, or `const`. `const` means the +selected runtime value is bound immutably; it does not imply the message was a +compile-time constant. + +A select may include one `default:` arm. + +### Deterministic selection policy + +Oreslang does **not** copy Go's pseudo-random default case choice. + +The default is: + +```ores +select { ... } // FAIR +``` + +FAIR is deterministic round-robin over a stable select site/set. If more than +one case is simultaneously ready, the next fairness cursor chooses the first +probe position. Static select sites retain a rotation ticket across repeated +executions; reusable dynamic SelectSet values retain their own cursor. + +Explicit strict priority: + +```ores +select first { +case readch control: const command + ... +case readch data: let value + ... +} +``` + +Explicit random choice, only when the program actually wants it: + +```ores +select random { + ... +} +``` + +Fairness policy governs the probe order among cases observed ready together. +It cannot reverse a case that has already atomically won a readiness race. + +## Nonblocking static select + +```ores +nb select { +case readch incoming: let msg + stdio.println("Received:", msg); + +case readch payload: const body + stdio.println("Received payload:", body); + +case readch shutdown: const signal + return; +} +``` + +Semantics: + +1. evaluate/arm the case set; +2. register one select operation; +3. return immediately to the current actor code; +4. exactly one case wins; +5. Future completion enqueues an internal continuation envelope back to the + owning actor; +6. only a later serialized actor mailbox turn executes the selected branch. + +The branch never runs on a producer/I/O/Future callback thread and never runs +concurrently against that actor's state. + +Because the enclosing stack has already continued, a nonblocking arm is a +detached `void` continuation scope: + +- `return;` exits the arm itself; +- `return value;` is invalid; +- `break`/`continue` cannot escape into an enclosing loop that has already + continued; +- Copy values and locally-copyable channel/select handles may be captured while + the current turn continues; +- move-only owned locals referenced by any arm transfer into the armed + selection, so the continuing outer code cannot use them afterward; +- mutable captures transfer exclusively into the continuation; +- ordinary borrowed locals and MutexGuard-bearing values cannot outlive the + current turn through an `nb select` arm; +- actor `self` is the deliberate borrowed exception because the arm can only + re-enter the same actor under its single-turn execution lease. + +Capture transfer is conservative across the whole arm set: if any possible arm +owns a move-only value, that value belongs to the armed selection until one arm +wins or the selection is cancelled. + +If the owning actor terminates before the select wins, actor teardown cancels +the pending select Future and detaches all channel registrations. + +## Dynamic select + +Static and dynamic select lower to the same runtime `SelectSet` primitive. + +Cases can be assembled at runtime: + +```ores +val Channel a = Channel.new(16); +val Channel b = Channel.new(16); + +val Array cases = [ + SelectCase.read(a), + SelectCase.write(b, 42) +]; + +val SelectResult result = select from cases; +val Future pending = nb select from cases; +val Option ready = try select from cases; +``` + +A reusable set can retain its fairness cursor: + +```ores +val set = SelectSet.new(cases); +val result = select from set; +``` + +Runtime list/array and map values are accepted. For maps, value iteration order +defines the case order used by `first` and the initial deterministic fair +ordering. + +Dynamic policies use the same spellings: + +```ores +select first from cases +select fair from cases +select random from cases +nb select first from cases +try select from cases +``` + +`SelectResult` exposes: + +- `index` +- `operation` (`read`, `write`, or `default`) +- `value` for a read result + +## Cancellation + +Oreslang does not require Go-style `context.Context` propagation through every +function just to stop work. + +### Structured cancellation + +Ordinary actor cancellation is structured: + +- mark the actor stopped immediately; +- close its mailbox channel; +- prevent new message/continuation admission; +- cancel its pending actor-owned channel/select registrations, including raw + `nb readch`, `nb writech`, dynamic `nb select from ...`, and deferred + static-select continuations; +- drain/release mailbox resource reservations; +- cascade cancellation to structured child actors; +- running trusted/co-resident actor code observes an uncatchable control-plane + unwind at scheduler safepoints; +- language `finally`/`defer` cleanup may run during that controlled unwind; +- external actor finalization waits for running turns and structured children to + leave. + +Cancellation is control flow, not a normal guest exception. An Oreslang +`try/catch` cannot swallow the cancellation signal and keep the actor alive. + +Carrier-thread interruption is deliberately **not** an actor cancellation +signal. Actors are multiplexed over shared carriers, so a Java/native worker +interrupt caused by executor shutdown or host machinery cannot be attributed to +the actor currently occupying that carrier. Structured cancellation is keyed by +actor/runtime state; non-cooperative untrusted termination is keyed by the +revocable isolate/domain boundary. This keeps carrier identity completely +separate from actor identity. + +### Force cancellation for untrusted actors + +Force cancellation is a **host/supervisor authority**, not an ordinary actor +capability. Actor turns may request normal structured cancellation, but they +cannot invoke the isolate-revocation hook themselves. + +Untrusted code cannot be expected to poll, yield, honor callbacks, or run +cleanup. Therefore **untrusted actors must run inside a host-revocable execution +boundary** (for example the dedicated untrusted Graal/native isolate/domain in +the hardened OresVM stack). + +`forceCancel` follows this order: + +1. ask the outer isolation manager to revoke/terminate the actor's isolated + execution domain; +2. require positive confirmation that guest execution can no longer continue; +3. only then perform logical actor/mailbox/child teardown. + +If no revocable boundary is installed, force cancellation fails closed and has +no logical side effect. It must never pretend that interrupting a shared +dispatcher carrier is equivalent to killing an isolated actor. + +For force-killed untrusted code, guest cleanup is **not trusted and not +required**. The outer runtime owns deterministic reclamation of: + +- isolated heap/arena; +- mailbox reservations; +- host request/response capabilities; +- file/socket/native handles granted through runtime-owned capabilities; +- pending channel/select registrations; +- child execution domains where the sandbox policy permits children. + +This is the Erlang-style safety property Oreslang wants: once isolation is real, +termination does not depend on cooperation from hostile guest code. + +## Cancellation races + +Channel and select cancellation use one atomic arbitration state. + +If cancellation wins: + +- the waiter/selection is detached; +- it cannot later consume a channel value; +- a later value remains available to another reader/select. + +If a channel case wins first: + +- cancellation returns false for that already-claimed operation; +- exactly one case completes; +- no second case may consume or publish a result. + +This same rule applies to static select, dynamic select, read waiters, and write +waiters. + +## Implementation status in PR #252 + +Implemented: + +- Ores-owned bounded/rendezvous Channel; +- cancellable async read/write registrations; +- immediate probes interoperating with select; +- deterministic FAIR / explicit PRIORITY / opt-in RANDOM selection; +- dynamic SelectSet from iterable/map; +- mailbox transport backed by Channel; +- static/dynamic parser + AST + type/ownership rules, including complete + closure-capture scanning for channel/select syntax and explicit deferred + `nb select` capture transfer; +- `nb select` serialized actor continuation re-entry; +- actor-owned cleanup of pending `nb readch`, `nb writech`, dynamic + `nb select from ...`, and deferred static-select registrations; +- structured parent/child cancellation; +- uncatchable actor cancellation safepoints; +- force-cancel isolation-revocation contract; +- bounded internal continuation headroom that is accounted separately from + the configured user-message quota, so runtime control traffic cannot silently + shrink `maxMailboxMessages`. + +Remaining compiler/runtime integration: + +- lower arbitrary pending blocking `readch`, `writech`, `select`, and + ordinary `await` from actor source frames into the existing resumable + OresScheduler Task/state-machine ABI; +- connect the force-cancel hook to the dedicated untrusted-isolate runtime stack + when that stack is reconciled onto current main; +- migrate transitional free-standing async backend behavior so every source + async continuation is explicitly owned by an actor/root-actor domain. diff --git a/docs/garbage-collection.md b/docs/garbage-collection.md new file mode 100644 index 00000000..08b41303 --- /dev/null +++ b/docs/garbage-collection.md @@ -0,0 +1,33 @@ +# Garbage collection and lifetimes + +Oreslang uses ownership and borrowing as the primary memory-safety model. GC is a fallback runtime service for reflection, FFI, host handles, interop wrappers, compiler/runtime metadata, and other resources outside the ordinary guest ownership graph. + +## Ownership first + +Ordinary values remain governed by move/borrow rules. Borrows are lexically inferred by default; explicit lifetime syntax is only needed if future inter-procedural cases cannot be inferred safely. The runtime collector must never make an otherwise-invalid ownership program valid. + +## Runtime cleanup registry + +Host/interop resources may register an idempotent cleanup hook with `RuntimeGarbageCollector.track(owner, cleanup)`. The owner is weakly referenced, so registration does not extend guest lifetime. Cleanup closures must not strongly capture the owner. + +The registry is bounded per context to prevent unbounded metadata growth. Failed cleanup hooks remain registered and may be retried by later sweeps, so hooks must be idempotent. + +`track` also returns a deterministic `CleanupHandle`; interop wrappers should close that handle on an explicit release path and rely on GC only as a leak fallback. + +## Periodic sweeps + +A process-shared daemon timer schedules context sweeps, so Oreslang does not park one sleeping sweeper thread per language context. Weak owners are registered with a `ReferenceQueue`; normal periodic/process sweeps drain queued dead owners instead of rescanning the entire cleanup registry. + +Periodic sweeps never call `System.gc()`; normal Java heap tracing remains under the JVM collector. Cleanup failures are retained in a retry set, preserving the idempotent retry contract even after a weak reference has already been dequeued. + +## Explicit collection + +`process.gc()` requires `GC_CONTROL`. It performs a best-effort process/context sweep and may request JVM collection. JVM requests are throttled per context so guest code cannot turn `process.gc()` into a high-frequency global pressure point. Strict FaaS does not grant `GC_CONTROL`. + +`actor.gc()` is actor-domain local and never requests JVM-wide collection. Calling it outside an actor mailbox turn is an error. Actor-domain identity comes from the actor runtime's stable semantic execution domain, not carrier-thread identity. Actor cleanup entries are indexed by that domain, and one `actor.gc()` call inspects at most 256 entries so a guest cannot hide an unbounded O(context-registry) scan behind one actor operation. + +Actor termination retires that semantic domain deterministically. Runtime/interop cleanup hooks registered by the actor become eligible immediately at actor exit even if stale host references remain, while failures stay retryable from later process/periodic sweeps. This makes actor lifetime—not JVM reachability—the upper bound for actor-local host resources. + +## Shutdown + +Context shutdown closes the actor runtime first, stops the periodic sweeper, then drains remaining runtime cleanup hooks. Shutdown cleanup is best-effort; durable external resources should still use explicit close/release APIs. diff --git a/docs/grammar.ebnf b/docs/grammar.ebnf new file mode 100644 index 00000000..d9314aff --- /dev/null +++ b/docs/grammar.ebnf @@ -0,0 +1,439 @@ +(* Oreslang v0.6 concrete grammar. Semantic restrictions are described in LANGUAGE.md. *) + +source = [ namespace_decl ] { import_decl } { top_level_item } EOF ; +namespace_decl = "namespace" IDENT ";" ; + +import_decl = "import" import_spec "from" STRING_LITERAL ";" ; +import_spec = "*" "as" IDENT + | import_kind import_names ; +import_kind = "module" | "actor" | "class" | "fnc" + | "interface" | "trait" | "struct" + | "type" | "types" ; +import_names = IDENT { "," IDENT } [ "as" IDENT ] + | "(" IDENT { "," IDENT } ")" + | "{" IDENT { "," IDENT } "}" + | "*" "as" IDENT ; + +top_level_item = annotated_module_decl + | actor_decl + | actor_callable_decl + | callable_decl + | class_decl + | interface_decl + | type_decl + | binding_decl ; + +annotated_module_decl = { annotation } "define" "module" IDENT + { module_member } "end" ; + +module_member = actor_decl + | actor_callable_decl + | callable_decl + | class_decl + | interface_decl + | type_decl + | binding_decl ; + +visibility = [ "pub" | "private" ] ; +callable_modifier = "abstract" | "async" | "nlex" ; +method_modifier = "abstract" | "async" ; + +callable_decl = { annotation } visibility { callable_modifier } callable_kind callable_name [ generic_params ] + ( "(" [ param_list ] ")" return_spec block + | "=" "|" [ param_list ] "|" "->" [ type_ref ] block ) ; +callable_kind = "fnc" | "routine" ; + +actor_callable_decl + = { annotation } visibility [ "async" ] [ "nlex" ] [ "shared" ] "actor" "fnc" callable_name [ generic_params ] + ( "(" [ param_list ] ")" return_spec block + | "=" "|" [ param_list ] "|" "->" [ type_ref ] block ) ; + +actor_decl = visibility [ "shared" ] "actor" IDENT [ generic_params ] + [ "extends" type_ref { "," type_ref } ] + [ ( "implements" | "impl" ) type_ref { "," type_ref } ] + actor_body ; +actor_body = "{" { actor_member } "}" + | { actor_member } "end" ; +actor_member = field_decl + | actor_method_decl + | static_function_decl ; +actor_method_decl + = { annotation } visibility { callable_modifier } [ "fnc" ] method_name [ generic_params ] + method_head return_spec block ; + +class_decl = "define" [ "abstract" ] "class" IDENT [ generic_params ] + [ "extends" type_ref { "," type_ref } ] + [ ( "implements" | "impl" ) type_ref { "," type_ref } ] + "as" { class_member } "end" ; + +class_member = field_decl + | method_decl + | static_function_decl ; + +field_decl = { annotation } visibility + ( binding_kind type_ref IDENT [ "=" expression ] + | IDENT ":" type_ref [ "=" expression ] ) + stmt_terminator ; + +method_decl = { annotation } visibility { method_modifier } method_name [ generic_params ] + method_head return_spec ( block | stmt_terminator ) ; +method_name = callable_name | "[" "Symbol" "." IDENT "]" ; +callable_name = IDENT | "loop" | "block" ; +method_head = "(" [ explicit_receiver | param_list ] ")" [ "(" [ param_list ] ")" ] ; +explicit_receiver = "self" type_ref ; + +static_function_decl + = { annotation } visibility "static" [ "async" ] "fnc" callable_name [ generic_params ] + "(" [ param_list ] ")" return_spec block ; + +interface_decl = visibility "interface" IDENT [ generic_params ] + [ "extends" type_ref { "," type_ref } ] + interface_body + | "define" "interface" IDENT [ generic_params ] + [ "extends" type_ref { "," type_ref } ] + interface_body ; + +interface_body = "{" { interface_member } "}" + | { interface_member } "end" ; + +interface_member= "fnc" IDENT [ generic_params ] "(" [ param_list ] ")" type_return_spec member_terminator + | IDENT ":" type_ref member_terminator + | [ binding_kind ] type_ref IDENT member_terminator ; + +type_decl = "type" IDENT [ generic_params ] "=" type_ref stmt_terminator ; + +annotation = "@" IDENT [ "<" [ type_args ] ">" | "(" [ type_args ] ")" ] ; +return_spec = [ ( ":" | "->" ) type_ref ] ; +type_return_spec= [ "=>" type_ref ] ; +generic_params = "<" IDENT { "," IDENT } ">" ; +param_list = param { "," param } ; +param = type_ref [ "mut" ] IDENT + | "@Structural" type_ref [ "mut" ] IDENT + | IDENT "structural" type_ref [ "mut" ] ; + +binding_decl = binding_kind [ type_ref ] IDENT "=" expression stmt_terminator ; +binding_kind = "val" | "const" | "let" ; + +destructure_stmt= [ binding_kind ] sequence_pattern "=" expression stmt_terminator + | [ binding_kind ] object_pattern "=" expression stmt_terminator ; +sequence_pattern= "[" sequence_destructure_item { "," sequence_destructure_item } "]" ; +object_pattern = "{" object_destructure_item { "," object_destructure_item } "}" ; +sequence_destructure_item + = [ binding_kind ] IDENT | "_" ; +object_destructure_item + = [ binding_kind ] IDENT ; + +block = "{" { statement } "}" ; +statement = binding_decl + | destructure_stmt + | return_stmt + | block_stmt + | break_stmt + | continue_stmt + | if_stmt + | match_stmt + | switch_stmt + | try_stmt + | select_stmt + | defer_stmt + | for_stmt + | loop_stmt + | expression stmt_terminator ; + +return_stmt = "return" [ expression ] stmt_terminator ; +defer_stmt = "defer" expression stmt_terminator ; +block_stmt = "block" block ; +break_stmt = "break" stmt_terminator ; +continue_stmt = "continue" stmt_terminator ; +loop_stmt = "loop" block ; + +if_stmt = "if" condition + ( block + { "elseif" condition block } + [ "else" block ] + | [ ";" ] ( "then" | "do" ) { statement } + { "elseif" condition [ ";" ] ( "then" | "do" ) { statement } } + [ "else" { statement } ] + ) + "fi" ; +condition = condition_and { "|" condition_and } ; +condition_and = equality { "," equality } ; + +match_stmt = "match" [ "first" ] expression [ ";" ] + match_arm { match_arm } "end" ; +match_arm = ( pattern | "else" ) [ "when" expression ] "->" block ; +pattern = "_" + | literal + | IDENT + | IDENT "(" [ pattern { "," pattern } ] ")" + | "is" type_ref [ IDENT ] ; + +switch_stmt = "switch" expression [ ";" ] + { switch_case } [ switch_default ] "end" ; +switch_case = "case" expression { "," expression } "->" block ; +switch_default = "default" "->" block ; + +try_stmt = "try" block "catch" "(" IDENT ")" block [ "finally" block ] ; + +select_stmt = [ select_wait_mode ] "select" [ select_policy ] + "{" select_arm { select_arm } [ select_default ] "}" ; +select_wait_mode= "nb" | "try" ; +select_policy = "first" | "fair" | "random" ; +select_arm = "case" "readch" expression ":" + [ binding_kind IDENT ] { statement } + | "case" "writech" expression "," expression ":" + { statement } ; +select_default = "default" ":" { statement } ; + +(* In select_arm/select_default bodies, the next case/default or the closing + brace is a structural arm boundary and is not consumed as a nested + statement. At most one default arm is permitted. *) + +for_stmt = "for" "(" for_of_head ")" loop_body + | "for" "(" c_for_head ")" loop_body + | "for" for_of_head loop_body + | "for" c_for_head loop_body ; + +c_for_head = [ for_init ] ";" [ expression ] ";" [ for_update ] ; +for_init = binding_kind [ type_ref ] IDENT "=" expression + | type_ref IDENT "=" expression + | expression ; +for_update = IDENT ( "++" | "--" ) | expression ; + +for_of_head = [ binding_kind ] for_of_pattern "of" expression ; +for_of_pattern = IDENT + | "[" for_of_destructure_item { "," for_of_destructure_item } "]" ; +for_of_destructure_item + = "_" | [ binding_kind ] IDENT ; +loop_body = block | "do" { statement } "done" ; +expression = assignment ; +assignment = conditional [ "=" assignment ] ; +conditional = logic_or [ "?" assignment ":" conditional ] ; +logic_or = equality { "|" equality } ; +equality = comparison { ( "==" | "!=" ) comparison } ; +comparison = additive + { ( "<" | "<=" | ">" | ">=" ) additive + | "is" type_ref [ IDENT ] + | "matches" pattern + | "as" [ "?" ] type_ref + } ; +additive = multiplicative { ( "+" | "-" ) multiplicative } ; +multiplicative = unary { ( "*" | "/" | "%" ) unary } ; +unary = [ "!" | "-" | "+" | "await" ] postfix + | "&" [ "mut" ] unary + | channel_expr ; + +channel_expr = [ channel_wait_mode ] "readch" unary + | [ channel_wait_mode ] "writech" unary "," expression + | [ channel_wait_mode ] "select" [ select_policy ] "from" unary ; +channel_wait_mode + = "nb" | "try" ; +postfix = primary { call_suffix | member_suffix | index_suffix } ; +call_suffix = "(" [ argument_list ] ")" ; +member_suffix = "." member_name ; +member_name = IDENT | "stop" | "do" | "done" + | "match" | "matches" | "is" | "when" + | "case" | "default" | "first" | "switch" + | "nb" | "select" | "readch" | "writech" ; +index_suffix = "[" expression "]" ; +argument_list = expression { "," expression } ; + +primary = literal + | callable_name + | "self" + | "new" type_ref "(" [ argument_list ] ")" + | lambda + | tuple_literal + | array_literal + | legacy_array_literal + | object_literal + | "(" expression ")" ; + +lambda = [ "nlex" ] ( pipe_lambda | typed_legacy_lambda ) ; +pipe_lambda = "|" [ lambda_param { "," lambda_param } ] "|" "->" block ; +lambda_param = IDENT | type_ref IDENT ; +typed_legacy_lambda + = "(" [ param_list ] ")" "->" block ; + +tuple_literal = "(" expression "," expression { "," expression } ")" ; +array_literal = "arr" "[" [ expression { "," expression } ] "]" ; +legacy_array_literal = "[" [ expression { "," expression } ] "]" ; +object_literal = "obj" "{" [ object_field { "," object_field } ] "}" ; +object_field = static_object_key ":" expression + | "`" expression "`" ":" expression ; +static_object_key + = member_name | STRING_LITERAL ; + +literal = INT_LITERAL | FLOAT_LITERAL | IMAG_LITERAL | STRING_LITERAL + | "true" | "false" ; + +type_ref = type_atom { "|" type_atom } ; + +type_atom = borrow_type + | tuple_type + | record_type + | alias_marker + | named_type + | function_type + | grouped_type + | STRING_LITERAL + | "self" + | "void" + | "null" ; + +borrow_type = "&" [ "mut" ] type_atom ; +tuple_type = "[" [ type_ref { "," type_ref } ] "]" ; +record_type = "{" [ record_type_field { "," record_type_field } ] "}" ; +record_type_field + = ( IDENT | STRING_LITERAL ) ":" type_ref ; +alias_marker = "type" named_type ; +named_type = qualified_name [ "<" [ type_args ] ">" ] ; +function_type = "typeof" "fnc" "(" [ function_type_params ] ")" "=>" type_ref + | "(" [ function_type_params ] ")" "=>" type_ref ; +grouped_type = "(" type_ref ")" ; +function_type_params + = function_type_param { "," function_type_param } ; +function_type_param + = type_ref [ IDENT ] ; + +type_args = type_ref { "," type_ref } ; +qualified_name = IDENT { "." IDENT } ; + +(* Semicolons are recommended. stmt_terminator/member_terminator may be omitted + only at an unambiguous structural boundary such as immediately before + "}", "fi", or "end". Imports and namespace declarations keep explicit + semicolons in v0.4. *) + +stmt_terminator = ";" | SAFE_STRUCTURAL_BOUNDARY ; +member_terminator = ";" | INTERFACE_CLOSING_BOUNDARY ; + +(* Lexical comments use // for line comments and Java/C-style /* ... */ for + multiline comments. Parenthesized expressions are recursively valid anywhere + an expression is accepted. + + Semantic restrictions: + - Channel/select waiting has three distinct modes. Plain readch/writech/select + is suspending; nb registers and returns a Future/arms a detached static + select continuation; try probes once without leaving a waiter. Static + select defaults to deterministic fair rotation. "select first" is strict + lexical priority and "select random" is explicit opt-in randomness. + Static nb-select arms are detached void continuation scopes: return exits + only the selected arm and break/continue cannot target an already- + continued outer loop. Dynamic selection uses "select from cases" and + lowers to the same SelectSet primitive as static select. + - "block" followed by "{" is a standalone lexical scope. It has no implicit + scheduling behavior and declarations inside it do not escape the scope. + - Unparenthesized C-style headers are distinguished by their semicolon + clauses: "for int i = 0; i < n; i++ do ... done". A typed initializer + without an explicit binding kind creates an implicit mutable let binding. + Postfix ++/-- are currently update-clause syntax and lower to +=/-= one + for that simple local binding; they are not general field/index postfix + expressions. + - for-of sequence patterns default bindings to val. An outer binding kind + (for example "for let [k, v] of entries") seeds the pattern; an explicit + binding kind inside the pattern propagates to subsequent names exactly as + in ordinary sequence destructuring. "_" discards one sequence position. + - "loop" accepts either "{ ... }" or "do ... done". Iterator and + conventional for-loops accept the same body forms. The canonical iterator + shorthand is "for x of iterable do ... done"; parenthesized headers remain + valid for compatibility. A bare done closes a do-body, while done(...) + remains a callable invocation. + break exits and continue advances the nearest enclosing loop/for; return + still exits the enclosing callable. Loop control never crosses a callable + boundary. + - Every if statement closes with "fi". Braces delimit branch bodies but never + replace the structural terminator. Keyword-delimited then/elseif/else/fi + and legacy do ... fi lower to the same conditional semantics. + - "loop" and "block" are contextual statement keywords: outside the + statement-plus-"{" form they remain legal callable names/references. + - Single- and double-quoted object keys are equivalent static string keys. + Reserved stop/do/done are legal static object/map/member keys. + - A backtick-delimited object key evaluates its enclosed expression at + runtime and requires a string value. + - An obj{} literal containing any dynamic key has DynamicStruct type, + where T is the joined value type. DynamicStruct accepts arbitrary + string keys and values assignable to T. + - Each source file is a separately versioned compilation/code unit and is the + default package identity when no explicit namespace is declared. + - Source namespaces are flat: namespace a.b; is illegal. + - Modules are flat: module names are single identifiers and modules may not + contain modules. + - fnc and routine declarations may recurse. Eligible tail-position calls + in fnc, routine, instance/static methods, and lambdas are proper tail + calls and must not grow the Oreslang/host call stack in JIT, AOT, or + hybrid execution. defer/catch/finally cleanup, live mutex guards, and + currently-untyped cross-code-unit import boundaries are tail-call barriers + because the caller still owns semantic work or a runtime return contract. + - fnc declarations are reifiable first-class callable values; routine + declarations are direct-call-only and cannot be stored, returned, or + passed as callbacks without an explicit wrapping lambda. Reified module + aliases preserve this distinction. `import fnc` accepts only public, + non-actor, non-generic fnc declarations because generic fnc values require + direct-call specialization until polymorphic function values exist. + - Fnc<...>-valued fields are callable data and remain reifiable. A class or + interface field name may not collide with an instance-method name across + inheritance; module callables/classes/bindings likewise share one runtime + value-member base-name namespace. + - nlex on a fnc/routine is a capture barrier inherited by lambdas created + inside it; an explicit nlex lambda establishes the same barrier. + - nlex blocks activation-local captures only. Module members, imports, + top-level callables/classes, and built-ins remain statically resolvable. + - nlex may qualify an actor fnc as an additional capture-free guarantee; + actor classes and actor/class methods do not accept nlex. + - fnc and routine declarations do not overload. + - instance methods may overload only when arity differs. Instance, actor, + nominal-interface, and structural-contract methods are direct-call-only and + are never implicitly reified as bound method values; use an explicit lambda + when a callback must call a method. + - class-level functions use "static fnc"; they have no self receiver, are + dispatched through the class namespace, and are reifiable when unambiguous. + - "->" is executable syntax for lambdas and lambda-style callable + declarations. Ordinary named executable declarations use ": ReturnType". + - "=>" is type-level syntax for function types and interface callable + signatures; it is not an executable return separator. + - match/switch arm implementations use "->". "=>" is rejected in executable + arms so the slim-arrow/execution versus fat-arrow/type distinction remains + syntactically enforceable. + - "is" performs nominal type testing/refinement; "matches" evaluates a full + pattern predicate; "as" is a checked cast and "as?" returns Option. + - Plain match is exclusive and exhaustive: the static checker must prove + non-fallback arms disjoint and coverage complete. A final else/"_"/bare + binding is the complement fallback. "match first" explicitly requests + ordered first-success semantics when overlap is intentional. + - switch is equality/constant dispatch only. It has no pattern bindings, + destructuring, or implicit fallthrough. + - Unqualified "actor" declarations are private actors; "shared actor" opts + into the SHARED_MEMORY execution domain. + - Actor fields are private mailbox-owned state; public actor API is exposed + through actor methods/messages rather than public mutable fields. + - Actor inheritance must preserve actor isolation kind. + - In a destructure pattern, an explicit binding kind propagates to subsequent + unqualified names until another binding kind appears. A leading binding + kind (for example const [a, b]) seeds the whole pattern. "_" never binds. + - Sequence destructuring targets returned arrays/lists and finite tuple types; + object destructuring targets returned record/map-like values. + - Destructuring is not currently permitted in callable parameter lists. + - Lambdas always have braces. Non-void lambdas must use explicit return and + return on every path when a contextual function result type is known. + - named class/interface types are nominal by default. + - @Structural, name structural Type, or @AllowStructural(name) opt a + parameter into public-shape compatibility. + - every loop iteration contains an injected scheduler safepoint. + - [Symbol.iterator]() may provide iterator behavior for for-of. + - standalone null values are illegal. + - null is a type marker only directly inside Option. + - inline obj{} / arr[] values cannot be subclassed; extend Object/List. + - "structural" is contextual in parameter position, not globally reserved. + - self is an immutable receiver borrow and cannot be rebound. + - Parameters are immutable by default. Type "mut" name grants mutation of an + owned parameter inside the callee, e.g. Bar mut b. + - &T is a shared immutable borrow. &mut T is an exclusive mutable borrow. + - Non-Copy values move through by-value bindings, arguments, and returns. + - Structural parameters are read-only borrowed views and do not consume the + passed value. + - A local borrow cannot escape the lifetime of its owner. + - Closures use lexical scoping. Mutable/non-Copy captures transfer ownership + into the closure environment; borrowed values cannot be captured. + - Class fields declared val/const are immutable; field mutation requires let + plus mutable owner access. +*) diff --git a/docs/security/untrusted-actor-graalwasm.md b/docs/security/untrusted-actor-graalwasm.md new file mode 100644 index 00000000..5aacaff2 --- /dev/null +++ b/docs/security/untrusted-actor-graalwasm.md @@ -0,0 +1,65 @@ +# Hardened UntrustedActor execution with GraalWasm + +Tracking issue: #108 + +This branch prototypes the Oreslang execution path for adversarial guest code: + +```text +trusted Java / Oreslang supervisor + │ + │ capability API only + ▼ +┌─────────────────────────────────┐ +│ Graal native-image isolate │ +│ SandboxPolicy.UNTRUSTED │ +│ │ +│ GraalWasm │ +│ ↓ │ +│ untrusted Oreslang/Wasm │ +│ │ +│ bounded memory │ +│ bounded CPU │ +│ bounded threads │ +│ restricted host access │ +└─────────────────────────────────┘ +``` + +## Design requirements + +- Untrusted Oreslang code must not execute as an ordinary JVM thread or unrestricted polyglot `Context`. +- The guest receives explicit capabilities only; it does not receive ambient JVM authority. +- Direct filesystem, raw socket, native, process-creation, arbitrary reflection, and unrestricted host-object access are forbidden. +- CPU, memory, thread, output, and host↔guest transfer limits must be explicit and covered by regression tests. +- The trusted supervisor owns lifecycle, cancellation, observability, capability issuance, and cleanup. +- Support an optional stronger mode using `engine.IsolateMode=external` plus an OS-level sandbox. +- Do not claim equivalence with Cloudflare Workers production security unless the full defense-in-depth stack is comparable. + +## Intended lowering + +```text +Oreslang source + ↓ +typed Oreslang IR + ↓ +Wasm module + ↓ +GraalWasm + ↓ +SandboxPolicy.UNTRUSTED + ↓ +dedicated isolate + ↓ +optional external process + OS sandbox +``` + +## Initial implementation milestones + +1. Pin a GraalVM version supporting `wasm-isolate` and `SandboxPolicy.UNTRUSTED`. +2. Add a minimal Java supervisor that loads a trivial Wasm module through GraalWasm. +3. Configure all mandatory resource limits for the pinned GraalVM version. +4. Add capability-only host bindings. +5. Add hostile fixtures covering CPU, memory, threads, output, host access, filesystem, network, native/process creation, and guest crashes. +6. Add external isolate mode and verify supervisor survival across guest failure. +7. Add Linux sandboxing research/prototype for namespaces, seccomp, and cgroups. + +This document is intentionally a scaffold for the implementation work in issue #108. diff --git a/examples/actor-isolation-support.ores b/examples/actor-isolation-support.ores new file mode 100644 index 00000000..18abf86c --- /dev/null +++ b/examples/actor-isolation-support.ores @@ -0,0 +1,3 @@ +pub fnc actor_label(bool shared): String { + return shared ? "shared" : "private"; +} diff --git a/examples/async-await.ores b/examples/async-await.ores new file mode 100644 index 00000000..ec053607 --- /dev/null +++ b/examples/async-await.ores @@ -0,0 +1,15 @@ +// C#-inspired async/await: async returns Future immediately. +// Await consumes the future; it does not reserve a platform thread. + +define module AsyncDemo + pub async fnc calculate(int n): int { + return n * n; + } + + pub async fnc main(): void { + val pending = calculate(12); + val result = await pending; + stdio.println(result); + return; + } +end diff --git a/examples/callables.ores b/examples/callables.ores new file mode 100644 index 00000000..8f48d7c1 --- /dev/null +++ b/examples/callables.ores @@ -0,0 +1,31 @@ +namespace demo; + +define module math + pub fnc factorial(int n): int { + return n == 0 ? 1 : n * factorial(n - 1); + } + + pub fnc offset(int x): int { + return x + 10; + } +end + +pub routine main(): void { + val int base = 7; + + val Fnc lexical = |int x| -> { + return x + base; + }; + + val Fnc isolated = nlex |int x| -> { + val int base = 1; + return math.offset(x) + base; + }; + + stdio.stdout.write(math.factorial(5)); + stdio.stdout.write(":"); + stdio.stdout.write(lexical(2)); + stdio.stdout.write(":"); + stdio.stdout.write(isolated(2)); + return; +} diff --git a/examples/circular_imports/README.md b/examples/circular_imports/README.md new file mode 100644 index 00000000..705b7502 --- /dev/null +++ b/examples/circular_imports/README.md @@ -0,0 +1,24 @@ +# Circular imports + +Oreslang intentionally permits import cycles. Run: + +```bash +mvn -q -DskipTests exec:java -Dexec.args="examples/circular_imports/a.ores" +``` + +`a.ores` imports `b_value` from `b.ores`, while `b.ores` imports +`a_value` from `a.ores`. + +The host loader parses and validates the complete reachable graph, links every +code unit, then runs file-level `fnc init() => void` hooks. The A/B strongly +connected component is therefore fully linked before either init hook executes. + +Expected output: + +```text +init-a:B|init-b:A|main:AB +``` + +Init order inside a cycle is deterministic by normalized code-unit id. The +important semantic guarantee is the barrier: no init in the cycle can execute +while another member of that cycle is still unloaded. diff --git a/examples/circular_imports/a.ores b/examples/circular_imports/a.ores new file mode 100644 index 00000000..793ee823 --- /dev/null +++ b/examples/circular_imports/a.ores @@ -0,0 +1,19 @@ +import fnc {b_value} from "./b.ores"; + +pub fnc a_value(): String { + return "A"; +} + +fnc init(): void { + stdio.stdout.write("init-a:"); + stdio.stdout.write(b_value()); + stdio.stdout.write("|"); + return; +} + +pub routine main(): void { + stdio.stdout.write("main:"); + stdio.stdout.write(a_value()); + stdio.stdout.write(b_value()); + return; +} diff --git a/examples/circular_imports/b.ores b/examples/circular_imports/b.ores new file mode 100644 index 00000000..a0dfcaa1 --- /dev/null +++ b/examples/circular_imports/b.ores @@ -0,0 +1,12 @@ +import fnc {a_value} from "./a.ores"; + +pub fnc b_value(): String { + return "B"; +} + +fnc init(): void { + stdio.stdout.write("init-b:"); + stdio.stdout.write(a_value()); + stdio.stdout.write("|"); + return; +} diff --git a/examples/java-interop.ores b/examples/java-interop.ores new file mode 100644 index 00000000..5f22d304 --- /dev/null +++ b/examples/java-interop.ores @@ -0,0 +1,11 @@ +import class ArrayList as JArrayList from "java:java.util.ArrayList"; + +pub routine main(): void { + val values = new JArrayList(); + values.add(19); + values.add(23); + + stdio.println("Java ArrayList size from Oreslang:"); + stdio.println(values.size()); + return; +} diff --git a/examples/modules-namespaces-callables.ores b/examples/modules-namespaces-callables.ores new file mode 100644 index 00000000..6285dfa1 --- /dev/null +++ b/examples/modules-namespaces-callables.ores @@ -0,0 +1,35 @@ +namespace callable_demo; + +type IntFn = typeof fnc(int value) => int; + +define module math + + pub fnc factorial(int n): int { + if n <= 1; do + return 1; + else + return n * factorial(n - 1); + fi + } + +end + +define module closures + + pub fnc makeAdder(int base): IntFn { + return |value| -> { + return base + value; + }; + } + +end + +pub routine main(): void { + val IntFn addTen = closures.makeAdder(10); + + stdio.stdout.write(math.factorial(5)); + stdio.stdout.write("|"); + stdio.stdout.write(addTen(7)); + + return; +} diff --git a/examples/private-actor-sharing-invalid.ores b/examples/private-actor-sharing-invalid.ores new file mode 100644 index 00000000..06da0812 --- /dev/null +++ b/examples/private-actor-sharing-invalid.ores @@ -0,0 +1,52 @@ +// Compile-valid but capability-invalid by design. +// This file is a negative regression fixture for PRIVATE actor isolation. + +type SharedCounterCell = SharedMutex; + +fnc make_shared_cell(): void { + val shared = SharedMutex.new(10); + stdio.println(shared.is_poisoned()); + return; +} + +define class Helpers as + pub static fnc make_shared(): void { + val shared = SharedMutex.new(11); + stdio.println(shared.is_poisoned()); + return; + } +end + +isoactor InvalidPrivateCounter { + // Type-alias laundering must be rejected. + pub fnc accept_alias(SharedCounterCell cell): void { + return; + } + + // Read-only sharing is still sharing authority and is forbidden. + pub fnc try_readonly_share(): void { + val shared = process.share_readonly(arr[1, 2, 3]); + stdio.println(shared); + return; + } + + // Direct helper indirection must not regain SHARED_MEMORY. + pub fnc try_helper_indirection(): void { + make_shared_cell(); + return; + } + + // First-class function values must be scanned under the actor policy. + pub fnc try_callback_indirection(): void { + val callback = make_shared_cell; + callback(); + return; + } + + // Static method values are another callback-laundering path. + pub fnc try_static_callback_indirection(): void { + val callback = Helpers.make_shared; + callback(); + return; + } +} diff --git a/examples/process-gc.ores b/examples/process-gc.ores new file mode 100644 index 00000000..c61042cb --- /dev/null +++ b/examples/process-gc.ores @@ -0,0 +1,7 @@ +pub fnc main(): void { + // Manual GC is for VM/interop leftovers and diagnostics. + // Ordinary Oreslang values follow ownership/borrow semantics. + val report = process.gc(); + stdio.println(report); + return; +} diff --git a/examples/proper-tail-call.ores b/examples/proper-tail-call.ores new file mode 100644 index 00000000..9c956295 --- /dev/null +++ b/examples/proper-tail-call.ores @@ -0,0 +1,32 @@ +fnc fnc_down(int n): int { + if n == 0; do + return 0; + else + return fnc_down(n - 1); + fi +} + +routine routine_down(int n): int { + if n == 0; do + return 0; + else + return routine_down(n - 1); + fi +} + +pub routine main(): void { + let Fnc lambda_down = |int n| -> { + if n == 0; do + return 0; + else + return lambda_down(n - 1); + fi + }; + + stdio.stdout.write(fnc_down(50000)); + stdio.stdout.write("|"); + stdio.stdout.write(routine_down(50000)); + stdio.stdout.write("|"); + stdio.stdout.write(lambda_down(50000)); + return; +} diff --git a/examples/shared-private-actor-isolation.ores b/examples/shared-private-actor-isolation.ores new file mode 100644 index 00000000..a09d751b --- /dev/null +++ b/examples/shared-private-actor-isolation.ores @@ -0,0 +1,93 @@ +import fnc {actor_label} from "./actor-isolation-support.ores"; + +define class CounterState as + pub let int value = 10; +end + +// SHARED actor: +// - owns normal mailbox-confined fields like local_count +// - may also use explicit synchronized shared memory via SharedMutex +shared actor SharedCounter { + let int local_count = 0; + let SharedMutex shared_count = + SharedMutex.new(new CounterState()); + + pub fnc increment(): bool { + self.local_count = self.local_count + 1; + + // Actor mailbox turns must never park a dispatcher carrier. Until actor + // continuation lowering can suspend/resume an incomplete lock_async() + // future, shared actors use nonblocking acquisition for explicit shared + // memory. Ordinary actor-owned state like local_count needs no mutex. + val shared_count = self.shared_count; + val maybe_guard = shared_count.try_lock(); + + if maybe_guard.is_none(); do + return false; + fi + + val guard = maybe_guard.unwrap(); + guard.value = guard.value + 1; + guard.release(); + return true; + } + + pub fnc local_value(): int { + return self.local_count; + } + + pub fnc actor_kind(): String { + return actor_label(true); + } +} + +// PRIVATE / isolated actor: +// - "isoactor" explicitly means private / isolated +// - owns its mutable state exclusively +// - receives a private actor memory slice at runtime +// - SHARED_MEMORY capability is stripped from this actor domain +isoactor PrivateCounter { + let int value = 10; + + pub fnc increment(): void { + self.value = self.value + 1; + return; + } + + pub fnc current(): int { + return self.value; + } + + pub fnc actor_kind(): String { + return actor_label(false); + } +} + +/* + * Intentionally ILLEGAL Oreslang: + * + * A private actor cannot declare or use SharedMutex, because private actor + * capability admission removes both SHARED_MEMORY and ACTOR_SHARE_READONLY. + * The check is transitive through aliases, stored class state, instance methods, + * ordinary helper functions, and static class helpers. Runtime capability checks + * also consult the current actor-local policy, so imported/dynamic helper code + * cannot regain the parent Truffle context's authority. + * + * process.share_readonly(...) is therefore private-actor forbidden too. + * + * isoactor BrokenPrivateCounter { + * let SharedMutex shared_count = + * SharedMutex.new(new CounterState()); + * + * pub fnc increment_shared(): void { + * self.shared_count.with_lock(|counter| -> { + * counter.value = counter.value + 1; + * return; + * }); + * return; + * } + * } + * + * The compiler/runtime must reject that actor instead of silently sharing + * mutable memory with the shared actor domain. + */ diff --git a/examples/showcase.ores b/examples/showcase.ores new file mode 100644 index 00000000..65db9560 --- /dev/null +++ b/examples/showcase.ores @@ -0,0 +1,53 @@ +define module contracts + define interface MathApi + fnc add(int a, int b) => int; + end +end + +@AdheresTo(contracts.MathApi) +define module math + pub fnc add(int a, int b): int { + return a + b; + } + + fnc impedance(): complex { + return 3 + 4i; + } +end + +define module model + define class Base as + pub seven(): int { return 7; } + end + + define class Box extends Base as + val T value; + + @Ret + identity() { + return self; + } + end +end + +define module app + pub fnc main(): void { + let answer = math.add(40, 2); + answer = answer + 1; + val complex z = 3 + 4i; + val values = arr[answer, 2]; + val info = obj{name: "oreslang", stable: true}; + [const first, let second] = (values[0], values[1]); + + if first == 43, info.stable | false; do + stdio.println("oreslang-ok"); + else + stdio.println("unexpected"); + fi + + stdio.println(info.name); + stdio.println(z); + stdio.println(second); + return; + } +end diff --git a/examples/together.ores b/examples/together.ores new file mode 100644 index 00000000..9f9faaa4 --- /dev/null +++ b/examples/together.ores @@ -0,0 +1,15 @@ +define module x + + define class y as + + end + +end + +pub routine main(): void { + + val y = new x.y(); + + stdio.stdout.write(y) + +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..5911f0a1 --- /dev/null +++ b/pom.xml @@ -0,0 +1,224 @@ + + + 4.0.0 + dev.oreslang + oreslang-source + 0.1.0-SNAPSHOT + Oreslang + Oreslang reference implementation on GraalVM Truffle + + + 21 + UTF-8 + 25.3.4.1 + 5.11.4 + 1.1.1 + + -O2 + + + + + org.tomlj + tomlj + ${tomlj.version} + + + org.graalvm.truffle + truffle-api + ${graalvm.version} + + + org.graalvm.truffle + truffle-runtime + ${graalvm.version} + runtime + + + org.graalvm.polyglot + polyglot + ${graalvm.version} + + + org.graalvm.truffle + truffle-enterprise + ${graalvm.version} + runtime + + + org.graalvm.sdk + nativebridge + ${graalvm.version} + runtime + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + org.graalvm.truffle + truffle-dsl-processor + ${graalvm.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + false + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + dev.oreslang.launcher.OresMain + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + dev.oreslang.launcher.OresMain + + + + + + + + native-thread-unix + + + unix + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + build-native-thread-carriers + generate-resources + exec + + sh + + ${project.basedir}/scripts/build-native-thread.sh + + + + + + + + + + + native-isolate + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + build-polyglot-isolate + package + exec + + ${env.JAVA_HOME}/bin/native-image + runtime + ${native.image.optimization} -p %classpath --shared --features=com.oracle.svm.truffle.PolyglotIsolateGuestFeature -H:APIFunctionPrefix=truffle_isolate_ -H:+CopyLanguageResources -H:+UnlockExperimentalVMOptions -H:-InitializeVM -H:-UnlockExperimentalVMOptions -Dgraalvm.locatorDisabled=true -o ${project.build.directory}/oresvm + + + + + + + + + + native-aot + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + build-native-aot + package + exec + + ${env.JAVA_HOME}/bin/native-image + runtime + ${native.image.optimization} -p %classpath -H:+ReportExceptionStackTraces -Dgraalvm.locatorDisabled=true -Dtruffle.UseFallbackRuntime=true --no-fallback -m dev.oreslang/dev.oreslang.launcher.OresMain -o ${project.build.directory}/ores-aot + + + + + + + + + + native-hybrid + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + build-native-hybrid + package + exec + + ${env.JAVA_HOME}/bin/native-image + runtime + ${native.image.optimization} -p %classpath -H:+ReportExceptionStackTraces -Dgraalvm.locatorDisabled=true --no-fallback -m dev.oreslang/dev.oreslang.launcher.OresMain -o ${project.build.directory}/ores-hybrid + + + + + + + + + native-size + + -Os + + + + diff --git a/rust-toolchain.toml b/rust-toolchain.toml deleted file mode 100644 index c3f67b67..00000000 --- a/rust-toolchain.toml +++ /dev/null @@ -1,4 +0,0 @@ -[toolchain] -channel = "1.98.1" -profile = "minimal" -components = ["rustfmt", "clippy"] diff --git a/scripts/build-native-thread.sh b/scripts/build-native-thread.sh new file mode 100755 index 00000000..d7d79b3a --- /dev/null +++ b/scripts/build-native-thread.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env sh +set -eu + +if [ -z "${JAVA_HOME:-}" ]; then + java_bin="$(command -v java || true)" + if [ -z "$java_bin" ]; then + echo "JAVA_HOME is not set and java is not on PATH" >&2 + exit 1 + fi + if command -v realpath >/dev/null 2>&1; then + java_bin="$(realpath "$java_bin")" + fi + JAVA_HOME="$(cd "$(dirname "$java_bin")/.." && pwd)" +fi + +os="$(uname -s)" +case "$os" in + Linux) + jni_os="linux" + output="target/native/liboresthread.so" + shared_flags="-shared" + linker_hardening="-Wl,-z,relro,-z,now" + ;; + Darwin) + jni_os="darwin" + output="target/native/liboresthread.dylib" + shared_flags="-dynamiclib" + linker_hardening="" + ;; + *) + echo "native Oreslang carrier build currently supports Linux and macOS; got $os" >&2 + exit 1 + ;; +esac + +cc_bin="${CC:-cc}" +mkdir -p target/native + +"$cc_bin" \ + -std=c11 \ + -O2 \ + -fPIC \ + -pthread \ + -Wall \ + -Wextra \ + -Werror \ + -fstack-protector-strong \ + -fno-omit-frame-pointer \ + $shared_flags \ + $linker_hardening \ + -I"$JAVA_HOME/include" \ + -I"$JAVA_HOME/include/$jni_os" \ + src/main/c/oresthread.c \ + -o "$output" + +echo "built $output" diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 0018991e..00000000 --- a/src/lib.rs +++ /dev/null @@ -1,221 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::{ - collections::BTreeSet, - sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, - }, -}; -use tokio::sync::RwLock; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DeviceState { - pub device_id: String, - pub backend: String, - pub architecture: String, - pub supported_isolation: Vec, - pub total_vram_bytes: u64, - pub free_vram_bytes: u64, - pub available_lanes: u32, - pub queue_depth: u32, - pub healthy: bool, -} - -impl DeviceState { - pub fn validate(&self) -> Result<(), String> { - validate_id(&self.device_id, "device_id")?; - if !matches!( - self.backend.as_str(), - "cuda" | "rocm" | "metal" | "vulkan" | "mock" - ) { - return Err(format!("unsupported backend {}", self.backend)); - } - validate_id(&self.architecture, "architecture")?; - if self.supported_isolation.is_empty() - || self.supported_isolation.iter().any(|value| { - !matches!( - value.as_str(), - "shared" | "sandbox" | "partitioned" | "dedicated" - ) - }) - { - return Err("invalid supported_isolation".into()); - } - if self.total_vram_bytes == 0 || self.free_vram_bytes > self.total_vram_bytes { - return Err("invalid VRAM accounting".into()); - } - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NodeSnapshot { - pub node_id: String, - pub region: String, - pub accepting_invocations: bool, - pub active_invocations: u64, - pub resident_artifacts: Vec, - pub devices: Vec, -} - -#[derive(Clone)] -pub struct NodeState { - node_id: String, - region: String, - accepting: Arc, - active_invocations: Arc, - resident_artifacts: Arc>>, - devices: Arc>>, -} - -impl NodeState { - pub fn new(node_id: impl Into, region: impl Into) -> Result { - let node_id = node_id.into(); - let region = region.into(); - validate_id(&node_id, "node_id")?; - validate_id(®ion, "region")?; - Ok(Self { - node_id, - region, - accepting: Arc::new(AtomicBool::new(true)), - active_invocations: Arc::new(AtomicU64::new(0)), - resident_artifacts: Default::default(), - devices: Default::default(), - }) - } - - pub async fn set_devices(&self, mut devices: Vec) -> Result<(), String> { - let mut ids = BTreeSet::new(); - for device in &devices { - device.validate()?; - if !ids.insert(device.device_id.clone()) { - return Err(format!("duplicate device id {}", device.device_id)); - } - } - devices.sort_by(|a, b| a.device_id.cmp(&b.device_id)); - *self.devices.write().await = devices; - Ok(()) - } - - pub async fn mark_resident(&self, digest: impl Into) -> Result { - let digest = digest.into(); - validate_digest(&digest)?; - Ok(self.resident_artifacts.write().await.insert(digest)) - } - - pub async fn evict(&self, digest: &str) -> Result { - validate_digest(digest)?; - Ok(self.resident_artifacts.write().await.remove(digest)) - } - - pub fn drain(&self) { - self.accepting.store(false, Ordering::Release); - } - - pub fn resume(&self) { - self.accepting.store(true, Ordering::Release); - } - - pub fn begin_invocation(&self) -> Option { - if !self.accepting.load(Ordering::Acquire) { - return None; - } - self.active_invocations - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { - current.checked_add(1) - }) - .ok()?; - - // Close the race where drain() happens between the first accepting check and the - // increment. A post-drain request rolls its count back and never executes. - if !self.accepting.load(Ordering::Acquire) { - self.active_invocations.fetch_sub(1, Ordering::AcqRel); - return None; - } - Some(InvocationGuard { - active: self.active_invocations.clone(), - }) - } - - pub async fn snapshot(&self) -> NodeSnapshot { - NodeSnapshot { - node_id: self.node_id.clone(), - region: self.region.clone(), - accepting_invocations: self.accepting.load(Ordering::Acquire), - active_invocations: self.active_invocations.load(Ordering::Acquire), - resident_artifacts: self - .resident_artifacts - .read() - .await - .iter() - .cloned() - .collect(), - devices: self.devices.read().await.clone(), - } - } -} - -fn validate_id(value: &str, field: &str) -> Result<(), String> { - if value.is_empty() || value.len() > 256 || value.bytes().any(|b| b.is_ascii_control()) { - return Err(format!("invalid {field}")); - } - Ok(()) -} - -fn validate_digest(value: &str) -> Result<(), String> { - if value.len() != 71 - || !value.starts_with("sha256:") - || !value[7..] - .bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) - { - return Err("resident artifact digest must be lowercase sha256:<64 hex>".into()); - } - Ok(()) -} - -pub struct InvocationGuard { - active: Arc, -} - -impl Drop for InvocationGuard { - fn drop(&mut self) { - self.active.fetch_sub(1, Ordering::AcqRel); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn drain_blocks_new_but_preserves_inflight_count() { - let node = NodeState::new("n1", "local").unwrap(); - let guard = node.begin_invocation().unwrap(); - node.drain(); - assert!(node.begin_invocation().is_none()); - assert_eq!(node.snapshot().await.active_invocations, 1); - drop(guard); - assert_eq!(node.snapshot().await.active_invocations, 0); - } - - #[tokio::test] - async fn rejects_invalid_device_accounting() { - let node = NodeState::new("n1", "local").unwrap(); - let result = node - .set_devices(vec![DeviceState { - device_id: "gpu0".into(), - backend: "cuda".into(), - architecture: "sm_90".into(), - supported_isolation: vec!["sandbox".into()], - total_vram_bytes: 10, - free_vram_bytes: 11, - available_lanes: 1, - queue_depth: 0, - healthy: true, - }]) - .await; - assert!(result.is_err()); - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 851b112b..00000000 --- a/src/main.rs +++ /dev/null @@ -1,477 +0,0 @@ -use async_trait::async_trait; -use axum::{ - body::Bytes, - extract::{DefaultBodyLimit, Path, State}, - http::{HeaderMap, HeaderValue, StatusCode}, - response::{IntoResponse, Response}, - routing::{get, post}, - Json, Router, -}; -use litegraph_gpu_host::{BackendKind, DeviceDescriptor, GpuCommand, GpuHost, MockBackend}; -use litegraph_modeld::{BatchExecutor, ModelDaemon, ModelError, ModelManifest}; -use litegraph_node::{DeviceState, NodeState}; -use litegraph_runtime::{ - GpuClient, GuestEngine, HostApi, InvocationRequest, InvocationResponse, InvocationRuntime, - RuntimeError, -}; -use std::{ - env, - net::SocketAddr, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, - }, -}; - -const DEFAULT_MAX_BODY_BYTES: usize = 64 * 1024 * 1024; -const MAX_DEADLINE_MS: u64 = 300_000; - -#[async_trait] -trait InvocationService: Send + Sync { - async fn invoke( - &self, - function: &str, - request: InvocationRequest, - ) -> Result; - fn mode(&self) -> &'static str; -} - -#[derive(Clone)] -struct AppState { - node: NodeState, - service: Option>, - node_token: Arc, -} - -struct HostBatchExecutor { - host: Arc>, - device_id: String, -} - -#[async_trait] -impl BatchExecutor for HostBatchExecutor { - async fn execute_batch( - &self, - model: &ModelManifest, - inputs: Vec>, - ) -> Vec, ModelError>> { - let mut outputs = Vec::with_capacity(inputs.len()); - for input in inputs { - let result = self - .host - .execute( - &self.device_id, - model.workspace_bytes, - GpuCommand { - executable: model.digest.clone(), - output_capacity: input.len(), - inputs: vec![input], - deadline_unix_ms: None, - }, - ) - .await - .map(|receipt| receipt.output) - .map_err(|error| ModelError::Execution(error.to_string())); - outputs.push(result); - } - outputs - } -} - -struct ModelGpuClient { - daemon: Arc>, - invocation_counter: AtomicU64, -} - -#[async_trait] -impl GpuClient for ModelGpuClient { - async fn open_model(&self, _tenant_id: &str, model: &str) -> Result { - if model != "default" { - return Err(RuntimeError::Accelerator("unknown model".into())); - } - let key = "default:1".to_string(); - self.daemon - .get(&key) - .await - .map_err(|error| RuntimeError::Accelerator(error.to_string()))?; - Ok(key) - } - - async fn infer( - &self, - tenant_id: &str, - model_key: &str, - input: Vec, - ) -> Result, RuntimeError> { - let model = self - .daemon - .get(model_key) - .await - .map_err(|error| RuntimeError::Accelerator(error.to_string()))?; - let invocation_id = format!( - "node-{}", - self.invocation_counter.fetch_add(1, Ordering::Relaxed) - ); - let batch_key = format!("bytes-{}", input.len()); - model - .infer_batched(invocation_id, tenant_id, batch_key, input) - .await - .map_err(|error| RuntimeError::Accelerator(error.to_string())) - } -} - -#[derive(Default)] -struct NodeGuest; - -#[async_trait] -impl GuestEngine for NodeGuest { - async fn execute( - &self, - request: &InvocationRequest, - host: &HostApi, - ) -> Result { - let model = host.open_model("default").await?; - let payload = host.infer(model, request.payload.clone()).await?; - Ok(InvocationResponse { payload }) - } -} - -struct MockExecutionService { - runtime: InvocationRuntime, -} - -impl MockExecutionService { - async fn build(vram_bytes: u64) -> Result<(Self, String), String> { - let host = Arc::new( - GpuHost::new(MockBackend { - descriptors: vec![DeviceDescriptor { - device_id: "mock0".into(), - backend: BackendKind::Mock, - total_vram_bytes: vram_bytes, - lane_count: 8, - supports_partitioning: false, - healthy: true, - }], - }) - .map_err(|error| error.to_string())?, - ); - let executor = HostBatchExecutor { - host, - device_id: "mock0".into(), - }; - let daemon = Arc::new(ModelDaemon::new(executor)); - let digest = format!("sha256:{}", "0".repeat(64)); - daemon - .load(ModelManifest { - name: "default".into(), - version: "1".into(), - digest: digest.clone(), - weight_bytes: 1024, - workspace_bytes: 1024, - max_batch: 32, - max_delay_us: 500, - max_input_bytes: DEFAULT_MAX_BODY_BYTES, - queue_capacity: 1024, - allow_cross_tenant_batching: false, - }) - .await - .map_err(|error| error.to_string())?; - let gpu = ModelGpuClient { - daemon, - invocation_counter: AtomicU64::new(1), - }; - Ok(( - Self { - runtime: InvocationRuntime::new(gpu, NodeGuest), - }, - digest, - )) - } -} - -#[async_trait] -impl InvocationService for MockExecutionService { - async fn invoke( - &self, - function: &str, - request: InvocationRequest, - ) -> Result { - if function != "default" { - return Err(RuntimeError::Guest("unknown mock function".into())); - } - self.runtime.invoke(request).await - } - - fn mode(&self) -> &'static str { - "runtime-stack/mock" - } -} - -#[tokio::main] -async fn main() { - let node_id = env::var("LITEGRAPH_NODE_ID").unwrap_or_else(|_| "local-node".into()); - let region = env::var("LITEGRAPH_REGION").unwrap_or_else(|_| "local".into()); - let bind = env::var("LITEGRAPH_NODE_ADDR").unwrap_or_else(|_| "127.0.0.1:0".into()); - let node_token: Arc = env::var("LITEGRAPH_NODE_TOKEN") - .expect("LITEGRAPH_NODE_TOKEN must be configured") - .into(); - if node_token.len() < 32 { - panic!("LITEGRAPH_NODE_TOKEN must be at least 32 bytes"); - } - - let node = NodeState::new(node_id, region).expect("valid node identity"); - let backend = env::var("LITEGRAPH_EXECUTION_BACKEND").unwrap_or_else(|_| "disabled".into()); - let service: Option> = match backend.as_str() { - "disabled" => None, - "mock" => { - let vram = env::var("LITEGRAPH_MOCK_GPU_VRAM_BYTES") - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|value| *value >= 1024 * 1024) - .unwrap_or(24 * 1024 * 1024 * 1024); - let (service, digest) = MockExecutionService::build(vram) - .await - .expect("initialize explicit mock execution stack"); - node.set_devices(vec![DeviceState { - device_id: "mock0".into(), - backend: "mock".into(), - architecture: "mock".into(), - supported_isolation: vec!["shared".into(), "sandbox".into()], - total_vram_bytes: vram, - free_vram_bytes: vram, - available_lanes: 8, - queue_depth: 0, - healthy: true, - }]) - .await - .expect("valid mock device state"); - node.mark_resident(digest) - .await - .expect("valid mock resident digest"); - Some(Arc::new(service)) - } - other => panic!("unsupported LITEGRAPH_EXECUTION_BACKEND={other:?}"), - }; - - let state = Arc::new(AppState { - node, - service, - node_token, - }); - let max_body = env::var("LITEGRAPH_NODE_MAX_BODY_BYTES") - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|value| (1..=1024 * 1024 * 1024).contains(value)) - .unwrap_or(DEFAULT_MAX_BODY_BYTES); - - let app = Router::new() - .route("/healthz", get(|| async { "ok" })) - .route("/v1/node", get(snapshot)) - .route("/v1/node/drain", post(drain)) - .route("/v1/node/resume", post(resume)) - .route("/v1/invoke/{function}", post(invoke)) - .layer(DefaultBodyLimit::max(max_body)) - .with_state(state); - - let listener = tokio::net::TcpListener::bind(&bind) - .await - .expect("bind litegraph-node"); - let addr: SocketAddr = listener.local_addr().expect("local addr"); - eprintln!("litegraph-node listening on {addr}; execution_backend={backend}"); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown()) - .await - .expect("serve litegraph-node"); -} - -async fn snapshot( - State(state): State>, - headers: HeaderMap, -) -> Result, StatusCode> { - authorize(&headers, &state.node_token)?; - Ok(Json(state.node.snapshot().await)) -} - -async fn drain( - State(state): State>, - headers: HeaderMap, -) -> Result<&'static str, StatusCode> { - authorize(&headers, &state.node_token)?; - state.node.drain(); - Ok("draining") -} - -async fn resume( - State(state): State>, - headers: HeaderMap, -) -> Result<&'static str, StatusCode> { - authorize(&headers, &state.node_token)?; - state.node.resume(); - Ok("accepting") -} - -async fn invoke( - Path(function): Path, - State(state): State>, - headers: HeaderMap, - body: Bytes, -) -> Response { - if authorize(&headers, &state.node_token).is_err() { - return StatusCode::UNAUTHORIZED.into_response(); - } - if !valid_function(&function) { - return (StatusCode::BAD_REQUEST, "invalid function id").into_response(); - } - let Some(tenant_id) = - header_str(&headers, "x-litegraph-tenant-id").filter(|value| valid_id(value)) - else { - return (StatusCode::BAD_REQUEST, "invalid tenant id").into_response(); - }; - let Some(invocation_id) = - header_str(&headers, "x-litegraph-invocation-id").filter(|value| valid_id(value)) - else { - return (StatusCode::BAD_REQUEST, "invalid invocation id").into_response(); - }; - let deadline_ms = match header_str(&headers, "x-litegraph-deadline-ms") { - Some(value) => match value.parse::() { - Ok(value) if (1..=MAX_DEADLINE_MS).contains(&value) => value, - _ => return (StatusCode::BAD_REQUEST, "invalid deadline").into_response(), - }, - None => 30_000, - }; - - let Some(service) = state.service.as_ref() else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - "execution backend not configured", - ) - .into_response(); - }; - let Some(_guard) = state.node.begin_invocation() else { - return (StatusCode::SERVICE_UNAVAILABLE, "node draining").into_response(); - }; - - let result = service - .invoke( - &function, - InvocationRequest { - invocation_id: invocation_id.to_owned(), - tenant_id: tenant_id.to_owned(), - payload: body.to_vec(), - deadline_ms_from_now: Some(deadline_ms), - }, - ) - .await; - - match result { - Ok(mut response) => { - let mut out_headers = HeaderMap::new(); - out_headers.insert("x-litegraph-queue-depth", HeaderValue::from_static("0")); - out_headers.insert( - "x-litegraph-execution", - HeaderValue::from_static(service.mode()), - ); - // InvocationResponse zeroizes any payload it still owns on Drop. - // Transfer the allocation once into the HTTP body instead of cloning - // tenant output and leaving an extra sensitive copy in memory. - let payload = std::mem::take(&mut response.payload); - (out_headers, Bytes::from(payload)).into_response() - } - Err(error) => runtime_error_response(error), - } -} - -fn runtime_error_response(error: RuntimeError) -> Response { - let (status, public_message) = runtime_error_parts(&error); - eprintln!( - "litegraph-node invocation failed: class={}", - runtime_error_class(&error) - ); - (status, public_message).into_response() -} - -fn runtime_error_parts(error: &RuntimeError) -> (StatusCode, &'static str) { - match error { - RuntimeError::PayloadTooLarge { .. } => { - (StatusCode::PAYLOAD_TOO_LARGE, "payload too large") - } - RuntimeError::AcceleratorInputTooLarge { .. } => { - (StatusCode::PAYLOAD_TOO_LARGE, "accelerator input too large") - } - RuntimeError::InvalidInvocation(_) => (StatusCode::BAD_REQUEST, "invalid invocation"), - RuntimeError::InvalidCapability(_) => (StatusCode::BAD_REQUEST, "invalid capability"), - RuntimeError::DeadlineExceeded => (StatusCode::GATEWAY_TIMEOUT, "deadline exceeded"), - RuntimeError::Cancelled => (StatusCode::REQUEST_TIMEOUT, "request cancelled"), - RuntimeError::Accelerator(_) => (StatusCode::BAD_GATEWAY, "accelerator execution failed"), - RuntimeError::ResponseTooLarge { .. } => { - (StatusCode::INTERNAL_SERVER_ERROR, "response too large") - } - RuntimeError::CapabilityLimitExceeded => ( - StatusCode::INTERNAL_SERVER_ERROR, - "capability limit exceeded", - ), - RuntimeError::CapabilityMemoryExceeded => ( - StatusCode::INTERNAL_SERVER_ERROR, - "capability memory limit exceeded", - ), - RuntimeError::Guest(_) => (StatusCode::INTERNAL_SERVER_ERROR, "guest execution failed"), - } -} - -fn runtime_error_class(error: &RuntimeError) -> &'static str { - match error { - RuntimeError::PayloadTooLarge { .. } => "payload_too_large", - RuntimeError::ResponseTooLarge { .. } => "response_too_large", - RuntimeError::AcceleratorInputTooLarge { .. } => "accelerator_input_too_large", - RuntimeError::CapabilityLimitExceeded => "capability_limit", - RuntimeError::CapabilityMemoryExceeded => "capability_memory", - RuntimeError::Cancelled => "cancelled", - RuntimeError::DeadlineExceeded => "deadline", - RuntimeError::InvalidInvocation(_) => "invalid_invocation", - RuntimeError::InvalidCapability(_) => "invalid_capability", - RuntimeError::Accelerator(_) => "accelerator", - RuntimeError::Guest(_) => "guest", - } -} - -fn authorize(headers: &HeaderMap, expected: &str) -> Result<(), StatusCode> { - let value = header_str(headers, "authorization").ok_or(StatusCode::UNAUTHORIZED)?; - let token = value - .strip_prefix("Bearer ") - .ok_or(StatusCode::UNAUTHORIZED)?; - if constant_time_eq(token.as_bytes(), expected.as_bytes()) { - Ok(()) - } else { - Err(StatusCode::UNAUTHORIZED) - } -} - -fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { - headers.get(name)?.to_str().ok() -} - -fn valid_id(value: &str) -> bool { - !value.is_empty() && value.len() <= 256 && !value.bytes().any(|b| b.is_ascii_control()) -} - -fn valid_function(value: &str) -> bool { - valid_id(value) - && value - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) -} - -fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { - if left.len() != right.len() { - return false; - } - let mut difference = 0u8; - for (&a, &b) in left.iter().zip(right) { - difference |= a ^ b; - } - difference == 0 -} - -async fn shutdown() { - let _ = tokio::signal::ctrl_c().await; -} diff --git a/src/main/c/oresthread.c b/src/main/c/oresthread.c new file mode 100644 index 00000000..ac3b1ee4 --- /dev/null +++ b/src/main/c/oresthread.c @@ -0,0 +1,389 @@ +#define _GNU_SOURCE +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(__APPLE__) +#include +#include +#include +#endif + +typedef struct ores_carrier_pool ores_carrier_pool; +static uint64_t pthread_cpu_time_nanos(pthread_t pthread); + +typedef struct { + ores_carrier_pool *pool; + int slot; + char *name; +} ores_carrier_arg; + +struct ores_carrier_pool { + JavaVM *jvm; + jobject executor; + jmethodID carrier_loop; + pthread_t *threads; + ores_carrier_arg *args; + int max_threads; + int desired_threads; + int started; + int shutdown; + int attach_ready_count; + int attach_failures; + pthread_mutex_t mutex; + pthread_cond_t condition; +}; + +static void throw_illegal_state(JNIEnv *env, const char *message) { + jclass cls = (*env)->FindClass(env, "java/lang/IllegalStateException"); + if (cls != NULL) (*env)->ThrowNew(env, cls, message); +} + +static char *copy_thread_name(const char *prefix, int slot) { + size_t prefix_len = strlen(prefix); + size_t size = prefix_len + 32; + char *name = (char *)calloc(size, 1); + if (name == NULL) return NULL; + snprintf(name, size, "%s%d", prefix, slot + 1); + return name; +} + +static void *carrier_main(void *raw) { + ores_carrier_arg *arg = (ores_carrier_arg *)raw; + ores_carrier_pool *pool = arg->pool; + + pthread_mutex_lock(&pool->mutex); + while (!pool->started && !pool->shutdown) { + pthread_cond_wait(&pool->condition, &pool->mutex); + } + int should_stop = pool->shutdown; + pthread_mutex_unlock(&pool->mutex); + if (should_stop) return NULL; + + JNIEnv *env = NULL; + JavaVMAttachArgs attach; + memset(&attach, 0, sizeof(attach)); + attach.version = JNI_VERSION_1_8; + attach.name = arg->name; + attach.group = NULL; + + jint status = (*pool->jvm)->AttachCurrentThreadAsDaemon( + pool->jvm, (void **)&env, &attach); + + pthread_mutex_lock(&pool->mutex); + pool->attach_ready_count++; + if (status != JNI_OK || env == NULL) pool->attach_failures++; + pthread_cond_broadcast(&pool->condition); + pthread_mutex_unlock(&pool->mutex); + if (status != JNI_OK || env == NULL) return NULL; + + (*env)->CallVoidMethod(env, pool->executor, pool->carrier_loop, (jint)arg->slot); + + if ((*env)->ExceptionCheck(env)) { + // Surface an unexpected executor-boundary failure before detaching. + // Actor turn failures should normally be contained in Java. + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + } + + (*pool->jvm)->DetachCurrentThread(pool->jvm); + return NULL; +} + +static void free_pool(JNIEnv *env, ores_carrier_pool *pool) { + if (pool == NULL) return; + if (pool->executor != NULL) (*env)->DeleteGlobalRef(env, pool->executor); + if (pool->args != NULL) { + for (int i = 0; i < pool->max_threads; i++) free(pool->args[i].name); + } + free(pool->args); + free(pool->threads); + pthread_cond_destroy(&pool->condition); + pthread_mutex_destroy(&pool->mutex); + free(pool); +} + +JNIEXPORT jlong JNICALL +Java_dev_oreslang_runtime_NativeCarrierExecutor_nativeCreate( + JNIEnv *env, + jclass cls, + jobject executor, + jint max_threads, + jint desired_threads, + jstring thread_prefix, + jlong stack_bytes) { + (void)cls; + if (executor == NULL || thread_prefix == NULL) { + throw_illegal_state(env, "native carrier executor/prefix cannot be null"); + return 0; + } + if (max_threads <= 0 || desired_threads <= 0 || desired_threads > max_threads) { + throw_illegal_state(env, "invalid native carrier thread counts"); + return 0; + } + if (stack_bytes < 262144) { + throw_illegal_state(env, "native carrier stack size must be at least 262144 bytes"); + return 0; + } + + ores_carrier_pool *pool = (ores_carrier_pool *)calloc(1, sizeof(*pool)); + if (pool == NULL) { + throw_illegal_state(env, "failed to allocate native carrier pool"); + return 0; + } + pool->max_threads = (int)max_threads; + pool->desired_threads = (int)desired_threads; + pthread_mutex_init(&pool->mutex, NULL); + pthread_cond_init(&pool->condition, NULL); + + if ((*env)->GetJavaVM(env, &pool->jvm) != JNI_OK || pool->jvm == NULL) { + free_pool(env, pool); + throw_illegal_state(env, "JNI GetJavaVM failed"); + return 0; + } + + pool->executor = (*env)->NewGlobalRef(env, executor); + if (pool->executor == NULL) { + free_pool(env, pool); + throw_illegal_state(env, "failed to root native carrier executor"); + return 0; + } + + jclass executor_class = (*env)->GetObjectClass(env, executor); + if (executor_class == NULL) { + free_pool(env, pool); + return 0; + } + pool->carrier_loop = (*env)->GetMethodID(env, executor_class, "nativeCarrierLoop", "(I)V"); + (*env)->DeleteLocalRef(env, executor_class); + if (pool->carrier_loop == NULL) { + free_pool(env, pool); + throw_illegal_state(env, "nativeCarrierLoop(int) JNI callback is missing"); + return 0; + } + + const char *prefix = (*env)->GetStringUTFChars(env, thread_prefix, NULL); + if (prefix == NULL) { + free_pool(env, pool); + return 0; + } + + pool->threads = (pthread_t *)calloc((size_t)max_threads, sizeof(pthread_t)); + pool->args = (ores_carrier_arg *)calloc((size_t)max_threads, sizeof(ores_carrier_arg)); + if (pool->threads == NULL || pool->args == NULL) { + (*env)->ReleaseStringUTFChars(env, thread_prefix, prefix); + free_pool(env, pool); + throw_illegal_state(env, "failed to allocate native carrier slots"); + return 0; + } + + pthread_attr_t attr; + if (pthread_attr_init(&attr) != 0) { + (*env)->ReleaseStringUTFChars(env, thread_prefix, prefix); + free_pool(env, pool); + throw_illegal_state(env, "pthread_attr_init failed for native carrier"); + return 0; + } + if (pthread_attr_setstacksize(&attr, (size_t)stack_bytes) != 0) { + pthread_attr_destroy(&attr); + (*env)->ReleaseStringUTFChars(env, thread_prefix, prefix); + free_pool(env, pool); + throw_illegal_state(env, "failed to configure native carrier stack size"); + return 0; + } + + int created = 0; + for (int i = 0; i < max_threads; i++) { + pool->args[i].pool = pool; + pool->args[i].slot = i; + pool->args[i].name = copy_thread_name(prefix, i); + if (pool->args[i].name == NULL + || pthread_create(&pool->threads[i], &attr, carrier_main, &pool->args[i]) != 0) { + pthread_mutex_lock(&pool->mutex); + pool->shutdown = 1; + pool->started = 1; + pthread_cond_broadcast(&pool->condition); + pthread_mutex_unlock(&pool->mutex); + for (int j = 0; j < created; j++) pthread_join(pool->threads[j], NULL); + pthread_attr_destroy(&attr); + (*env)->ReleaseStringUTFChars(env, thread_prefix, prefix); + free_pool(env, pool); + throw_illegal_state(env, "pthread_create failed for Oreslang carrier"); + return 0; + } + created++; + } + + pthread_attr_destroy(&attr); + (*env)->ReleaseStringUTFChars(env, thread_prefix, prefix); + return (jlong)(intptr_t)pool; +} + +JNIEXPORT void JNICALL +Java_dev_oreslang_runtime_NativeCarrierExecutor_nativeStart( + JNIEnv *env, jclass cls, jlong handle) { + (void)cls; + ores_carrier_pool *pool = (ores_carrier_pool *)(intptr_t)handle; + if (pool == NULL) return; + pthread_mutex_lock(&pool->mutex); + pool->started = 1; + pthread_cond_broadcast(&pool->condition); + while (!pool->shutdown && pool->attach_ready_count < pool->max_threads) { + pthread_cond_wait(&pool->condition, &pool->mutex); + } + int failures = pool->attach_failures; + pthread_mutex_unlock(&pool->mutex); + if (failures != 0) { + throw_illegal_state(env, "one or more native carrier pthreads failed to attach to the JVM"); + } +} + +JNIEXPORT void JNICALL +Java_dev_oreslang_runtime_NativeCarrierExecutor_nativeSetDesired( + JNIEnv *env, jclass cls, jlong handle, jint desired_threads) { + (void)cls; + ores_carrier_pool *pool = (ores_carrier_pool *)(intptr_t)handle; + if (pool == NULL) return; + if (desired_threads <= 0 || desired_threads > pool->max_threads) { + throw_illegal_state(env, "native desired carrier count is out of bounds"); + return; + } + pthread_mutex_lock(&pool->mutex); + pool->desired_threads = (int)desired_threads; + pthread_cond_broadcast(&pool->condition); + pthread_mutex_unlock(&pool->mutex); +} + +JNIEXPORT void JNICALL +Java_dev_oreslang_runtime_NativeCarrierExecutor_nativeAwaitEnabled( + JNIEnv *env, jclass cls, jlong handle, jint slot) { + (void)env; + (void)cls; + ores_carrier_pool *pool = (ores_carrier_pool *)(intptr_t)handle; + if (pool == NULL) return; + pthread_mutex_lock(&pool->mutex); + while (!pool->shutdown && slot >= pool->desired_threads) { + pthread_cond_wait(&pool->condition, &pool->mutex); + } + pthread_mutex_unlock(&pool->mutex); +} + +static void *carrier_reaper_main(void *raw) { + ores_carrier_pool *pool = (ores_carrier_pool *)raw; + + // Joining happens off the runtime/control-plane caller. A carrier that is + // still inside non-cooperative guest code may delay reclamation of this + // retired pool, but can no longer block ActorRuntime.close()/shutdownNow(). + for (int i = 0; i < pool->max_threads; i++) { + pthread_join(pool->threads[i], NULL); + } + + JNIEnv *env = NULL; + JavaVM *jvm = pool->jvm; + JavaVMAttachArgs attach; + memset(&attach, 0, sizeof(attach)); + attach.version = JNI_VERSION_1_8; + attach.name = "ores-carrier-reaper"; + attach.group = NULL; + + jint status = (*jvm)->AttachCurrentThreadAsDaemon( + jvm, (void **)&env, &attach); + if (status == JNI_OK && env != NULL) { + free_pool(env, pool); + (*jvm)->DetachCurrentThread(jvm); + } + // If the VM is already tearing down and attachment fails, deliberately + // leak only the retired native pool metadata rather than touching JNI with + // an invalid environment. Process teardown will reclaim it. + return NULL; +} + +JNIEXPORT void JNICALL +Java_dev_oreslang_runtime_NativeCarrierExecutor_nativeShutdown( + JNIEnv *env, jclass cls, jlong handle) { + (void)env; + (void)cls; + ores_carrier_pool *pool = (ores_carrier_pool *)(intptr_t)handle; + if (pool == NULL) return; + + pthread_mutex_lock(&pool->mutex); + if (!pool->shutdown) { + pool->shutdown = 1; + pool->started = 1; + pthread_cond_broadcast(&pool->condition); + } + pthread_mutex_unlock(&pool->mutex); + + pthread_t reaper; + if (pthread_create(&reaper, NULL, carrier_reaper_main, pool) == 0) { + pthread_detach(reaper); + return; + } + + /* + * Resource exhaustion must not force the caller back into unbounded joins. + * Leave this retired pool rooted until process teardown. Actor admission is + * already closed and every parked/cooperative carrier has been woken. + */ +} + +JNIEXPORT jlong JNICALL +Java_dev_oreslang_runtime_NativeCarrierExecutor_nativeCurrentThreadId( + JNIEnv *env, jclass cls) { + (void)env; + (void)cls; + return (jlong)(uintptr_t)pthread_self(); +} + +JNIEXPORT jlong JNICALL +Java_dev_oreslang_runtime_NativeCarrierExecutor_nativeCurrentThreadCpuNanos( + JNIEnv *env, jclass cls) { + (void)env; + (void)cls; + return (jlong)pthread_cpu_time_nanos(pthread_self()); +} + +JNIEXPORT jlong JNICALL +Java_dev_oreslang_runtime_NativeCarrierExecutor_nativeCarrierCpuTimeNanos( + JNIEnv *env, jclass cls, jlong handle, jint slot) { + (void)env; + (void)cls; + ores_carrier_pool *pool = (ores_carrier_pool *)(intptr_t)handle; + if (pool == NULL || slot < 0 || slot >= pool->max_threads) return 0; + return (jlong)pthread_cpu_time_nanos(pool->threads[slot]); +} + +static uint64_t pthread_cpu_time_nanos(pthread_t pthread) { +#if defined(__APPLE__) + mach_port_t mach_thread = pthread_mach_thread_np(pthread); + thread_basic_info_data_t info; + mach_msg_type_number_t count = THREAD_BASIC_INFO_COUNT; + kern_return_t status = thread_info( + mach_thread, + THREAD_BASIC_INFO, + (thread_info_t)&info, + &count); + if (status != KERN_SUCCESS) return 0; + uint64_t user = (uint64_t)info.user_time.seconds * 1000000000ULL + + (uint64_t)info.user_time.microseconds * 1000ULL; + uint64_t system = (uint64_t)info.system_time.seconds * 1000000000ULL + + (uint64_t)info.system_time.microseconds * 1000ULL; + return user + system; +#elif defined(CLOCK_THREAD_CPUTIME_ID) + clockid_t clock_id; + if (pthread_getcpuclockid(pthread, &clock_id) != 0) return 0; + struct timespec ts; + if (clock_gettime(clock_id, &ts) != 0) return 0; + return (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec; +#else + (void)pthread; + return 0; +#endif +} diff --git a/src/main/java/dev/oreslang/OresLanguage.java b/src/main/java/dev/oreslang/OresLanguage.java new file mode 100644 index 00000000..3838769a --- /dev/null +++ b/src/main/java/dev/oreslang/OresLanguage.java @@ -0,0 +1,81 @@ +package dev.oreslang; + +import com.oracle.truffle.api.CallTarget; +import com.oracle.truffle.api.RootCallTarget; +import com.oracle.truffle.api.TruffleLanguage; +import dev.oreslang.ast.Ast; +import dev.oreslang.compiler.OresCompiler; +import dev.oreslang.nodes.OresEvalRootNode; +import dev.oreslang.nodes.OresInteropRootNode; +import dev.oreslang.runtime.ActorRuntime; +import dev.oreslang.runtime.AsyncRuntime; +import dev.oreslang.runtime.OresContext; +import org.graalvm.polyglot.SandboxPolicy; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; + +@TruffleLanguage.Registration( + id = OresLanguage.ID, + name = "Oreslang", + version = "0.1.0", + defaultMimeType = OresLanguage.MIME_TYPE, + characterMimeTypes = OresLanguage.MIME_TYPE, + contextPolicy = TruffleLanguage.ContextPolicy.EXCLUSIVE, + sandbox = SandboxPolicy.UNTRUSTED, + website = "https://github.com/ores-truffle-oreslang/oreslang-source.java") +public final class OresLanguage extends TruffleLanguage { + public static final String ID = "ores"; + public static final String MIME_TYPE = "application/x-oreslang"; + + @Override + protected OresContext createContext(Env env) { + return new OresContext(this, env); + } + + /** + * Host-owned actor dispatcher and async workers may enter the context. + * Guest source still has no raw thread-creation authority; that remains controlled by + * IsolatePolicy and the Polyglot Context builder. + * + * Strict/adversarial contexts serialize actor guest turns in OresContext. + * Non-adversarial contexts may execute independent actor turns concurrently. + */ + @Override + protected boolean isThreadAccessAllowed(Thread thread, boolean singleThreaded) { + return singleThreaded + || ActorRuntime.isActorCarrierThread() + || AsyncRuntime.isAsyncCarrierThread(); + } + + @Override + protected void initializeMultiThreading(OresContext context) { + // All mutable language state used by actor turns is context-owned, + // actor-owned, immutable, or explicitly synchronized. + } + + @Override + protected void disposeContext(OresContext context) { + context.close(); + } + + @Override + protected CallTarget parse(ParsingRequest request) { + var source = request.getSource(); + String text = source.getCharacters().toString(); + Ast.Program program = OresCompiler.parseAndTypeCheck(text); + String codeUnitId = source.getPath(); + if (codeUnitId == null || codeUnitId.isBlank()) { + codeUnitId = source.getName(); + } else { + try { + codeUnitId = Path.of(codeUnitId).toAbsolutePath().normalize().toString().replace('\\', '/'); + } catch (InvalidPathException invalidPath) { + throw new IllegalArgumentException("invalid Oreslang source path identity", invalidPath); + } + } + if (codeUnitId == null || codeUnitId.isBlank()) codeUnitId = ""; + RootCallTarget evaluator = new OresEvalRootNode(this, program, codeUnitId).getCallTarget(); + return new OresInteropRootNode(this, evaluator).getCallTarget(); + } +} diff --git a/src/main/java/dev/oreslang/ast/AnnotationExpander.java b/src/main/java/dev/oreslang/ast/AnnotationExpander.java new file mode 100644 index 00000000..ad2bc97a --- /dev/null +++ b/src/main/java/dev/oreslang/ast/AnnotationExpander.java @@ -0,0 +1,248 @@ +package dev.oreslang.ast; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Compile-time expansion for language-defined annotations/attributes. + * + * The source parser preserves annotations in the AST. This pass turns + * annotations with code-generation semantics into ordinary AST nodes before + * type/ownership checking and execution, keeping the runtime reflection-free. + */ +public final class AnnotationExpander { + public static final String FROM_JSON = "FromJson"; + + /** Internal-only markers. '$' cannot be written as an Oreslang identifier. */ + public static final String GENERATED_FROM_JSON_GETTER = "$generated.fromJson.getter"; + public static final String GENERATED_FROM_JSON_SETTER = "$generated.fromJson.setter"; + + public record FromJsonBinding( + String jsonKey, + String fieldName, + String getterName, + String setterName, + Ast.TypeRef type) { } + + private AnnotationExpander() { } + + public static Ast.Program expand(Ast.Program program) { + List modules = new ArrayList<>(program.modules().size()); + for (Ast.ModuleDecl module : program.modules()) modules.add(expandModule(module)); + return new Ast.Program(program.namespace(), program.imports(), modules); + } + + public static boolean isGeneratedFromJsonSetter(Ast.MethodDecl method) { + return hasAnnotation(method.annotations(), GENERATED_FROM_JSON_SETTER); + } + + public static boolean isGeneratedFromJsonGetter(Ast.MethodDecl method) { + return hasAnnotation(method.annotations(), GENERATED_FROM_JSON_GETTER); + } + + /** + * Stable metadata consumed by JSON codecs. The codec can look up an + * incoming JSON key, then invoke the named generated setter. This keeps + * deserialization dispatch explicit and reflection-free. + */ + public static List fromJsonBindings(Ast.ClassDecl klass) { + List result = new ArrayList<>(); + Map keys = new LinkedHashMap<>(); + for (Ast.FieldDecl field : klass.fields()) { + String jsonKey = fromJsonKey(field); + if (jsonKey == null) continue; + String previous = keys.putIfAbsent(jsonKey, field.name()); + if (previous != null && !previous.equals(field.name())) { + throw new IllegalArgumentException("duplicate @FromJson key '" + jsonKey + "' on fields '" + + previous + "' and '" + field.name() + "' in class " + klass.name()); + } + String suffix = accessorSuffix(field.name()); + result.add(new FromJsonBinding( + jsonKey, + field.name(), + "get" + suffix, + "set" + suffix, + field.type())); + } + return List.copyOf(result); + } + + /** + * Returns the JSON key for a field or null when the field is not annotated. + * Annotation shape is validated here so every compiler consumer sees one + * canonical interpretation. + */ + public static String fromJsonKey(Ast.FieldDecl field) { + Ast.Annotation found = null; + for (Ast.Annotation annotation : field.annotations()) { + if (!annotation.name().equals(FROM_JSON)) continue; + if (found != null) { + throw new IllegalArgumentException("field '" + field.name() + "' has duplicate @FromJson annotations"); + } + found = annotation; + } + if (found == null) return null; + if (found.arguments().size() != 1 || !found.arguments().getFirst().isStringLiteral()) { + throw new IllegalArgumentException("@FromJson on field '" + field.name() + "' requires exactly one string key"); + } + String key = found.arguments().getFirst().stringLiteralValue(); + if (key.isBlank()) throw new IllegalArgumentException("@FromJson key for field '" + field.name() + "' cannot be blank"); + return key; + } + + private static Ast.ModuleDecl expandModule(Ast.ModuleDecl module) { + List declarations = new ArrayList<>(module.declarations().size()); + for (Ast.Decl declaration : module.declarations()) { + if (declaration instanceof Ast.ClassDecl klass) { + declarations.add(expandClass(klass)); + } else { + if (declaration instanceof Ast.FieldDecl field && fromJsonKey(field) != null) { + throw new IllegalArgumentException("@FromJson is only valid on class fields, not module binding '" + field.name() + "'"); + } + if (declaration instanceof Ast.FunctionDecl function && hasAnnotation(function.annotations(), FROM_JSON)) { + throw new IllegalArgumentException("@FromJson is only valid on class fields, not callable '" + function.name() + "'"); + } + declarations.add(declaration); + } + } + return new Ast.ModuleDecl(module.name(), module.annotations(), declarations); + } + + private static Ast.ClassDecl expandClass(Ast.ClassDecl klass) { + List methods = new ArrayList<>(klass.methods()); + Map signatures = new HashMap<>(); + for (Ast.MethodDecl method : methods) { + if (hasAnnotation(method.annotations(), FROM_JSON)) { + throw new IllegalArgumentException("@FromJson is only valid on class fields, not method '" + klass.name() + "." + method.name() + "'"); + } + signatures.put(signature(method.name(), method.arity()), method); + } + + Map jsonKeys = new LinkedHashMap<>(); + for (Ast.FieldDecl field : klass.fields()) { + String jsonKey = fromJsonKey(field); + if (jsonKey == null) continue; + if (klass.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException("@FromJson is not valid on actor state field '" + + klass.name() + "." + field.name() + "'"); + } + if (field.type() == null) { + throw new IllegalArgumentException("@FromJson field '" + klass.name() + "." + field.name() + + "' requires an explicit field type"); + } + + String previous = jsonKeys.putIfAbsent(jsonKey, field.name()); + if (previous != null && !previous.equals(field.name())) { + throw new IllegalArgumentException("duplicate @FromJson key '" + jsonKey + "' on fields '" + + previous + "' and '" + field.name() + "' in class " + klass.name()); + } + if (field.bindingKind() != Ast.BindingKind.LET) { + throw new IllegalArgumentException("@FromJson field '" + klass.name() + "." + field.name() + + "' must be mutable; use 'let " + field.type().name() + " " + field.name() + + "' or the shorthand '" + field.name() + ": " + field.type().name() + "'"); + } + + String suffix = accessorSuffix(field.name()); + addGeneratedAccessor(klass, methods, signatures, getter(field, jsonKey, "get" + suffix), true); + addGeneratedAccessor(klass, methods, signatures, setter(field, jsonKey, "set" + suffix), false); + } + + return new Ast.ClassDecl( + klass.name(), + klass.isAbstract(), + klass.actorKind(), + klass.genericParameters(), + klass.parents(), + klass.interfaces(), + klass.fields(), + methods); + } + + private static void addGeneratedAccessor( + Ast.ClassDecl klass, + List methods, + Map signatures, + Ast.MethodDecl generated, + boolean getter) { + String key = signature(generated.name(), generated.arity()); + Ast.MethodDecl existing = signatures.get(key); + if (existing != null) { + boolean sameGeneratedKind = getter + ? isGeneratedFromJsonGetter(existing) + : isGeneratedFromJsonSetter(existing); + if (sameGeneratedKind) return; // idempotent expansion + throw new IllegalArgumentException("@FromJson generated accessor '" + klass.name() + "." + + generated.name() + "' collides with an existing method of arity " + generated.arity()); + } + methods.add(generated); + signatures.put(key, generated); + } + + private static Ast.MethodDecl getter(Ast.FieldDecl field, String jsonKey, String name) { + Ast.Annotation marker = new Ast.Annotation( + GENERATED_FROM_JSON_GETTER, + List.of(Ast.TypeRef.stringLiteral(jsonKey))); + return new Ast.MethodDecl( + name, + Ast.Visibility.PUBLIC, + false, + false, + false, + null, + List.of(), + List.of(), + field.type(), + List.of(marker), + List.of(new Ast.ReturnStmt(new Ast.MemberExpr(new Ast.NameExpr("self"), field.name())))); + } + + private static Ast.MethodDecl setter(Ast.FieldDecl field, String jsonKey, String name) { + Ast.Annotation marker = new Ast.Annotation( + GENERATED_FROM_JSON_SETTER, + List.of(Ast.TypeRef.stringLiteral(jsonKey))); + return new Ast.MethodDecl( + name, + Ast.Visibility.PUBLIC, + false, + false, + false, + null, + List.of(), + List.of(new Ast.Param(field.type(), "value")), + Ast.TypeRef.simple("void"), + List.of(marker), + List.of( + new Ast.ExprStmt(new Ast.AssignExpr( + new Ast.MemberExpr(new Ast.NameExpr("self"), field.name()), + new Ast.NameExpr("value"))), + new Ast.ReturnStmt(null))); + } + + private static String accessorSuffix(String fieldName) { + StringBuilder result = new StringBuilder(fieldName.length()); + boolean uppercase = true; + for (int i = 0; i < fieldName.length(); i++) { + char c = fieldName.charAt(i); + if (c == '_') { + uppercase = true; + continue; + } + result.append(uppercase ? Character.toUpperCase(c) : c); + uppercase = false; + } + if (result.isEmpty()) throw new IllegalArgumentException("cannot generate accessor for empty field name"); + return result.toString(); + } + + private static String signature(String name, int arity) { + return name + "/" + arity; + } + + private static boolean hasAnnotation(List annotations, String name) { + for (Ast.Annotation annotation : annotations) if (annotation.name().equals(name)) return true; + return false; + } +} diff --git a/src/main/java/dev/oreslang/ast/Ast.java b/src/main/java/dev/oreslang/ast/Ast.java new file mode 100644 index 00000000..b1a5e1d8 --- /dev/null +++ b/src/main/java/dev/oreslang/ast/Ast.java @@ -0,0 +1,547 @@ +package dev.oreslang.ast; + +import java.util.List; + +public final class Ast { + private Ast() { } + + public record Program(String namespace, List imports, List modules) { + public Program { + imports = List.copyOf(imports); + modules = List.copyOf(modules); + } + public Program(List imports, List modules) { this(null, imports, modules); } + public Program(List modules) { this(null, List.of(), modules); } + } + + public enum ImportKind { MODULE, ACTOR, CLASS, FUNCTION, INTERFACE, TRAIT, STRUCT, TYPE, TYPES, ALL } + + public record ImportDecl( + ImportKind kind, + List names, + boolean wildcard, + String namespace, + String path) { + public ImportDecl { names = List.copyOf(names); } + } + + public record ModuleDecl(String name, List annotations, List declarations) { + public ModuleDecl { + annotations = List.copyOf(annotations); + declarations = List.copyOf(declarations); + } + public ModuleDecl(String name, List declarations) { this(name, List.of(), declarations); } + } + + public sealed interface Decl permits FunctionDecl, ClassDecl, InterfaceDecl, FieldDecl, TypeAliasDecl { } + + public enum Visibility { PRIVATE, PUBLIC } + public enum CallableKind { FNC, ROUTINE } + public enum ActorKind { NONE, PRIVATE, SHARED } + + public record Annotation(String name, List arguments) { + public Annotation { arguments = List.copyOf(arguments); } + } + + public record TypeRef(String name, List arguments, boolean inferArguments) { + public TypeRef { arguments = List.copyOf(arguments); } + public static TypeRef simple(String name) { return new TypeRef(name, List.of(), false); } + public static TypeRef inferred() { return new TypeRef("$infer$", List.of(), false); } + public static TypeRef borrowed(TypeRef target, boolean mutable) { + return new TypeRef(mutable ? "$borrow_mut$" : "$borrow$", List.of(target), false); + } + public boolean isBorrow() { return name.equals("$borrow$") || name.equals("$borrow_mut$"); } + public boolean mutableBorrow() { return name.equals("$borrow_mut$"); } + public TypeRef borrowedTarget() { + if (!isBorrow() || arguments.size() != 1) throw new IllegalStateException("not a borrow type"); + return arguments.getFirst(); + } + public static TypeRef functionType(List parameters, TypeRef result) { + java.util.ArrayList all = new java.util.ArrayList<>(parameters); + all.add(result); + return new TypeRef("Fnc", all, false); + } + public static TypeRef stringLiteral(String value) { return new TypeRef("$string$" + value, List.of(), false); } + public boolean isStringLiteral() { return name.startsWith("$string$"); } + public String stringLiteralValue() { return name.substring("$string$".length()); } + + public static TypeRef union(List options) { + java.util.ArrayList flattened = new java.util.ArrayList<>(); + for (TypeRef option : options) { + if (option.isUnion()) { + for (TypeRef nested : option.arguments()) if (!flattened.contains(nested)) flattened.add(nested); + } else if (!flattened.contains(option)) flattened.add(option); + } + if (flattened.isEmpty()) throw new IllegalArgumentException("union type requires at least one member"); + if (flattened.size() == 1) return flattened.getFirst(); + flattened.sort(java.util.Comparator.comparing(TypeRef::toString)); + return new TypeRef("$union$", List.copyOf(flattened), false); + } + public boolean isUnion() { return name.equals("$union$"); } + + public static TypeRef tupleType(List elements) { + return new TypeRef("$tuple$", List.copyOf(elements), false); + } + public boolean isTupleType() { return name.equals("$tuple$"); } + + public static TypeRef recordType(java.util.Map members) { + java.util.ArrayList fields = new java.util.ArrayList<>(members.size()); + java.util.ArrayList> entries = new java.util.ArrayList<>(members.entrySet()); + entries.sort(java.util.Map.Entry.comparingByKey()); + for (java.util.Map.Entry entry : entries) { + if (entry.getKey() == null || entry.getKey().isBlank()) { + throw new IllegalArgumentException("record type field name cannot be blank"); + } + fields.add(new TypeRef("$field$" + entry.getKey(), List.of(entry.getValue()), false)); + } + return new TypeRef("$record$", List.copyOf(fields), false); + } + public boolean isRecordType() { return name.equals("$record$"); } + public java.util.Map recordMembers() { + if (!isRecordType()) throw new IllegalStateException("not a record type"); + java.util.LinkedHashMap members = new java.util.LinkedHashMap<>(); + for (TypeRef field : arguments) { + if (!field.name().startsWith("$field$") || field.arguments().size() != 1 || field.inferArguments()) { + throw new IllegalStateException("malformed record type field"); + } + String fieldName = field.name().substring("$field$".length()); + if (members.putIfAbsent(fieldName, field.arguments().getFirst()) != null) { + throw new IllegalStateException("duplicate record type field " + fieldName); + } + } + return java.util.Collections.unmodifiableMap(members); + } + } + + public record Param(TypeRef type, String name, boolean structural, boolean mutable) { + public Param(TypeRef type, String name) { this(type, name, false, false); } + public Param(TypeRef type, String name, boolean structural) { this(type, name, structural, false); } + } + + public record FunctionDecl( + String name, + CallableKind kind, + Visibility visibility, + boolean async, + boolean nonLexical, + ActorKind actorKind, + List genericParameters, + List parameters, + TypeRef returnType, + List annotations, + List body) implements Decl { + public FunctionDecl { + genericParameters = List.copyOf(genericParameters); + parameters = List.copyOf(parameters); + annotations = List.copyOf(annotations); + body = List.copyOf(body); + } + public FunctionDecl(String name, CallableKind kind, Visibility visibility, boolean async, + ActorKind actorKind, List genericParameters, List parameters, + TypeRef returnType, List annotations, List body) { + this(name, kind, visibility, async, false, actorKind, genericParameters, parameters, returnType, annotations, body); + } + public FunctionDecl(String name, CallableKind kind, Visibility visibility, boolean async, + List genericParameters, List parameters, TypeRef returnType, + List annotations, List body) { + this(name, kind, visibility, async, false, ActorKind.NONE, genericParameters, parameters, returnType, annotations, body); + } + public FunctionDecl(String name, Visibility visibility, boolean async, List genericParameters, + List parameters, TypeRef returnType, List annotations, List body) { + this(name, CallableKind.FNC, visibility, async, false, ActorKind.NONE, genericParameters, parameters, returnType, annotations, body); + } + } + + public record ClassDecl( + String name, + boolean isAbstract, + ActorKind actorKind, + List genericParameters, + List parents, + List interfaces, + List fields, + List methods) implements Decl { + public ClassDecl { + genericParameters = List.copyOf(genericParameters); + parents = List.copyOf(parents); + interfaces = List.copyOf(interfaces); + fields = List.copyOf(fields); + methods = List.copyOf(methods); + } + public ClassDecl(String name, boolean isAbstract, List genericParameters, + List parents, List interfaces, + List fields, List methods) { + this(name, isAbstract, ActorKind.NONE, genericParameters, parents, interfaces, fields, methods); + } + public ClassDecl(String name, boolean isAbstract, List genericParameters, + List fields, List methods) { + this(name, isAbstract, ActorKind.NONE, genericParameters, List.of(), List.of(), fields, methods); + } + } + + public sealed interface InterfaceMember permits InterfaceFunctionDecl, InterfaceFieldDecl { } + + public record InterfaceFunctionDecl( + String name, + List genericParameters, + List parameters, + TypeRef returnType) implements InterfaceMember { + public InterfaceFunctionDecl { + genericParameters = List.copyOf(genericParameters); + parameters = List.copyOf(parameters); + } + } + + public record InterfaceFieldDecl(String name, TypeRef type) implements InterfaceMember { } + + public record InterfaceDecl( + String name, + Visibility visibility, + List genericParameters, + List parents, + List members) implements Decl { + public InterfaceDecl { + genericParameters = List.copyOf(genericParameters); + parents = List.copyOf(parents); + members = List.copyOf(members); + } + public InterfaceDecl(String name, List genericParameters, List members) { + this(name, Visibility.PRIVATE, genericParameters, List.of(), members); + } + } + + public record FieldDecl( + String name, + Visibility visibility, + BindingKind bindingKind, + TypeRef type, + List annotations, + Expr initializer) implements Decl { + public FieldDecl { annotations = List.copyOf(annotations); } + public FieldDecl(String name, Visibility visibility, BindingKind bindingKind, TypeRef type, Expr initializer) { + this(name, visibility, bindingKind, type, List.of(), initializer); + } + } + + public record MethodDecl( + String name, + Visibility visibility, + boolean isStatic, + boolean isAbstract, + boolean async, + TypeRef explicitReceiverType, + List genericParameters, + List parameters, + TypeRef returnType, + List annotations, + List body) { + public MethodDecl { + genericParameters = List.copyOf(genericParameters); + parameters = List.copyOf(parameters); + annotations = List.copyOf(annotations); + body = List.copyOf(body); + } + public int arity() { return parameters.size(); } + } + + public record TypeAliasDecl(String name, List genericParameters, TypeRef target) implements Decl { + public TypeAliasDecl { genericParameters = List.copyOf(genericParameters); } + } + + public enum BindingKind { CONST, VAL, LET } + + public sealed interface Stmt permits BindingStmt, DestructureStmt, ReturnStmt, ExprStmt, DeferStmt, + BlockStmt, BreakStmt, ContinueStmt, IfStmt, MatchStmt, SwitchStmt, TryStmt, + ForOfStmt, ForOfDestructureStmt, ForStmt, LoopStmt, SelectStmt { } + + public record BindingStmt(BindingKind kind, TypeRef declaredType, String name, Expr initializer) implements Stmt { } + public record DestructureBinding(BindingKind kind, String name) { + public DestructureBinding { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("destructure binding name cannot be blank"); + } + } + + public static DestructureBinding discard() { + return new DestructureBinding(BindingKind.VAL, "_"); + } + + public boolean isDiscard() { + return "_".equals(name); + } + } + + public enum DestructureKind { SEQUENCE, OBJECT } + + public record DestructureStmt(DestructureKind kind, List bindings, Expr initializer) implements Stmt { + public DestructureStmt { bindings = List.copyOf(bindings); } + public DestructureStmt(List bindings, Expr initializer) { + this(DestructureKind.SEQUENCE, bindings, initializer); + } + } + + public record ReturnStmt(Expr value) implements Stmt { } + public record ExprStmt(Expr expression) implements Stmt { } + public record DeferStmt(Expr expression) implements Stmt { } + + public record BlockStmt(List body) implements Stmt { + public BlockStmt { body = List.copyOf(body); } + } + public record BreakStmt() implements Stmt { } + public record ContinueStmt() implements Stmt { } + + public record IfBranch(Expr condition, List body) { + public IfBranch { body = List.copyOf(body); } + } + + public record IfStmt(List branches, List elseBody) implements Stmt { + public IfStmt { + branches = List.copyOf(branches); + elseBody = List.copyOf(elseBody); + } + } + + /** + * Oreslang patterns are language-level values for static analysis and native lowering. + * They deliberately do not encode JVM Class/instanceof semantics. + */ + public sealed interface Pattern permits WildcardPattern, LiteralPattern, BindingPattern, + TypePattern, ConstructorPattern { } + + public record WildcardPattern() implements Pattern { } + public record LiteralPattern(Object value) implements Pattern { } + public record BindingPattern(String name) implements Pattern { } + public record TypePattern(TypeRef type, String binding) implements Pattern { } + public record ConstructorPattern(String constructor, List arguments) implements Pattern { + public ConstructorPattern { arguments = List.copyOf(arguments); } + } + + public record MatchArm(Pattern pattern, Expr guard, List body) { + public MatchArm { body = List.copyOf(body); } + } + + /** + * ordered=false is the normal proof-checked form: arm predicates must be disjoint. + * ordered=true ("match first") is an explicit priority/first-match escape hatch. + */ + public record MatchStmt(Expr subject, boolean ordered, List arms) implements Stmt { + public MatchStmt { arms = List.copyOf(arms); } + } + + public record SwitchCase(List constants, List body) { + public SwitchCase { + constants = List.copyOf(constants); + body = List.copyOf(body); + } + } + + public record SwitchStmt(Expr subject, List cases, List defaultBody) implements Stmt { + public SwitchStmt { + cases = List.copyOf(cases); + defaultBody = List.copyOf(defaultBody); + } + } + + public record TryStmt(List body, String errorName, List catchBody, List finallyBody) implements Stmt { + public TryStmt { + body = List.copyOf(body); + catchBody = List.copyOf(catchBody); + finallyBody = List.copyOf(finallyBody); + } + } + + public record ForOfStmt(BindingKind bindingKind, String bindingName, Expr iterable, List body) implements Stmt { + public ForOfStmt { body = List.copyOf(body); } + } + + public record ForOfDestructureStmt( + List bindings, + Expr iterable, + List body) implements Stmt { + public ForOfDestructureStmt { + bindings = List.copyOf(bindings); + body = List.copyOf(body); + if (bindings.isEmpty()) { + throw new IllegalArgumentException("for-of destructure pattern cannot be empty"); + } + } + } + + public record ForStmt(Stmt initializer, Expr condition, Expr update, List body) implements Stmt { + public ForStmt { body = List.copyOf(body); } + } + + public record LoopStmt(List body) implements Stmt { + public LoopStmt { body = List.copyOf(body); } + } + + public enum ChannelOperation { READ, WRITE, DEFAULT } + public enum WaitMode { BLOCKING, NONBLOCKING, IMMEDIATE } + public enum SelectPolicy { FAIR, PRIORITY, RANDOM } + + public record SelectArm( + ChannelOperation operation, + Expr channel, + Expr value, + BindingKind bindingKind, + String bindingName, + List body) { + public SelectArm { + body = List.copyOf(body); + if (operation == ChannelOperation.DEFAULT) { + if (channel != null || value != null || bindingKind != null || bindingName != null) { + throw new IllegalArgumentException("default select arm cannot carry channel/value/binding metadata"); + } + } else { + if (channel == null) throw new IllegalArgumentException("channel select arm requires a channel"); + if (operation == ChannelOperation.READ && value != null) { + throw new IllegalArgumentException("read select arm cannot carry a write value"); + } + if (operation == ChannelOperation.WRITE && value == null) { + throw new IllegalArgumentException("write select arm requires a value"); + } + if ((bindingKind == null) != (bindingName == null)) { + throw new IllegalArgumentException("select binding kind/name must appear together"); + } + if (operation == ChannelOperation.WRITE && bindingName != null) { + throw new IllegalArgumentException("write select arm cannot bind a read value"); + } + } + } + } + + public record SelectStmt( + WaitMode mode, + SelectPolicy policy, + List arms) implements Stmt { + public SelectStmt { + arms = List.copyOf(arms); + if (arms.isEmpty()) throw new IllegalArgumentException("select requires at least one arm"); + long defaults = arms.stream().filter(arm -> arm.operation() == ChannelOperation.DEFAULT).count(); + if (defaults > 1) throw new IllegalArgumentException("select permits at most one default arm"); + } + } + + public sealed interface Expr permits LiteralExpr, NameExpr, BinaryExpr, UnaryExpr, AssignExpr, ConditionalExpr, + TypeTestExpr, PatternTestExpr, CastExpr, + CallExpr, MemberExpr, IndexExpr, NewExpr, AwaitExpr, ChannelOpExpr, DynamicSelectExpr, + ListExpr, TupleExpr, ObjectExpr, LambdaExpr { } + + public record LiteralExpr(Object value) implements Expr { } + public record Imaginary(double coefficient) { } + public record NameExpr(String name) implements Expr { } + public record BinaryExpr(String operator, Expr left, Expr right) implements Expr { } + public record UnaryExpr(String operator, Expr operand) implements Expr { } + public record AssignExpr(Expr target, Expr value) implements Expr { } + public record ConditionalExpr(Expr condition, Expr whenTrue, Expr whenFalse) implements Expr { } + + /** "value is Type [binding]" -- a nominal/refinement test, not general pattern matching. */ + public record TypeTestExpr(Expr value, TypeRef targetType, String binding) implements Expr { } + + /** "value matches Pattern" -- full pattern predicate, with bindings scoped by the enclosing condition. */ + public record PatternTestExpr(Expr value, Pattern pattern) implements Expr { } + + public enum CastMode { CHECKED, OPTIONAL } + + /** "value as Type" or "value as? Type". */ + public record CastExpr(Expr value, TypeRef targetType, CastMode mode) implements Expr { } + + public record CallExpr( + Expr callee, + List typeArguments, + boolean typeArgumentsPresent, + List arguments) implements Expr { + public CallExpr { + typeArguments = List.copyOf(typeArguments); + arguments = List.copyOf(arguments); + if (!typeArgumentsPresent && !typeArguments.isEmpty()) { + throw new IllegalArgumentException("call type arguments require an explicit <...> marker"); + } + } + public CallExpr(Expr callee, List arguments) { + this(callee, List.of(), false, arguments); + } + public CallExpr(Expr callee, List typeArguments, List arguments) { + this(callee, typeArguments, true, arguments); + } + } + + public record MemberExpr(Expr receiver, String member) implements Expr { } + public record IndexExpr(Expr receiver, Expr index) implements Expr { } + + public record NewExpr(TypeRef type, List arguments) implements Expr { + public NewExpr { arguments = List.copyOf(arguments); } + } + + public record AwaitExpr(Expr expression) implements Expr { } + + public record ChannelOpExpr( + ChannelOperation operation, + WaitMode mode, + Expr channel, + Expr value) implements Expr { + public ChannelOpExpr { + if (operation == ChannelOperation.DEFAULT) { + throw new IllegalArgumentException("default is not a standalone channel operation"); + } + if (channel == null) throw new IllegalArgumentException("channel operation requires a channel"); + if (operation == ChannelOperation.READ && value != null) { + throw new IllegalArgumentException("readch cannot carry a write value"); + } + if (operation == ChannelOperation.WRITE && value == null) { + throw new IllegalArgumentException("writech requires a value"); + } + } + } + + /** + * Dynamic select operates on a runtime SelectSet or iterable/map of + * SelectCase values. Static select remains a statement so branch control + * flow (return/break/continue) is checked in its enclosing callable. + */ + public record DynamicSelectExpr( + WaitMode mode, + SelectPolicy policy, + Expr cases) implements Expr { + public DynamicSelectExpr { + if (cases == null) throw new IllegalArgumentException("dynamic select requires a case collection"); + } + } + + public record ListExpr(List elements) implements Expr { + public ListExpr { elements = List.copyOf(elements); } + } + + public record TupleExpr(List elements) implements Expr { + public TupleExpr { elements = List.copyOf(elements); } + } + + public record ObjectField(String name, Expr dynamicName, Expr value) { + public ObjectField { + if ((name == null) == (dynamicName == null)) { + throw new IllegalArgumentException("object field must have exactly one static or dynamic key"); + } + } + public static ObjectField named(String name, Expr value) { + return new ObjectField(java.util.Objects.requireNonNull(name, "name"), null, value); + } + public static ObjectField dynamic(Expr key, Expr value) { + return new ObjectField(null, java.util.Objects.requireNonNull(key, "key"), value); + } + public boolean isDynamic() { return dynamicName != null; } + } + + public record ObjectExpr(List fields) implements Expr { + public ObjectExpr { fields = List.copyOf(fields); } + } + + public record LambdaExpr(List parameters, Expr expressionBody, List blockBody, boolean nonLexical) implements Expr { + public LambdaExpr { + parameters = List.copyOf(parameters); + blockBody = blockBody == null ? null : List.copyOf(blockBody); + } + public LambdaExpr(List parameters, Expr expressionBody, List blockBody) { + this(parameters, expressionBody, blockBody, false); + } + } +} diff --git a/src/main/java/dev/oreslang/compiler/BuildOptions.java b/src/main/java/dev/oreslang/compiler/BuildOptions.java new file mode 100644 index 00000000..ee355dd0 --- /dev/null +++ b/src/main/java/dev/oreslang/compiler/BuildOptions.java @@ -0,0 +1,115 @@ +package dev.oreslang.compiler; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Closed-world build configuration used by Oreslang's build optimizer. + * + * Build defines are intentionally explicit compiler inputs. Guest code does not + * read the compiler process environment directly; build tools translate + * environment variables and CLI --define switches into this immutable object. + */ +public record BuildOptions( + Map defines, + Set entryPoints, + boolean preservePublicApi) { + + public static final String DEFINES_ENV = "ORESLANG_BUILD_DEFINES"; + public static final String DEFINE_ENV_PREFIX = "ORESLANG_DEFINE_"; + + public BuildOptions { + defines = Map.copyOf(defines == null ? Map.of() : defines); + entryPoints = Set.copyOf(entryPoints == null ? Set.of() : entryPoints); + for (String name : defines.keySet()) validateName(name); + for (String entryPoint : entryPoints) validateName(entryPoint); + } + + public static BuildOptions executable() { + return executable(Map.of()); + } + + public static BuildOptions executable(Map defines) { + return new BuildOptions(defines, Set.of("main"), false); + } + + public static BuildOptions library(Map defines) { + return new BuildOptions(defines, Set.of(), true); + } + + /** + * Merge build defines from the process environment and repeated CLI + * --define=name=value switches. CLI values win over environment values. + * + * Supported environment forms: + * ORESLANG_BUILD_DEFINES=use_a=true,backend=native + * ORESLANG_DEFINE_USE_A=true + */ + public static Map mergeDefines( + Map environment, + List cliDefines) { + LinkedHashMap merged = new LinkedHashMap<>(); + Map env = environment == null ? Map.of() : environment; + + String aggregate = env.get(DEFINES_ENV); + if (aggregate != null && !aggregate.isBlank()) { + addAssignments(merged, aggregate, ",", DEFINES_ENV); + } + + env.entrySet().stream() + .filter(entry -> entry.getKey().startsWith(DEFINE_ENV_PREFIX)) + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> { + String name = entry.getKey().substring(DEFINE_ENV_PREFIX.length()); + validateName(name); + merged.put(name, entry.getValue()); + }); + + if (cliDefines != null) { + for (String assignment : cliDefines) { + addAssignment(merged, assignment, "--define"); + } + } + return Map.copyOf(merged); + } + + private static void addAssignments( + Map target, + String assignments, + String delimiter, + String source) { + for (String assignment : assignments.split(delimiter)) { + if (!assignment.isBlank()) addAssignment(target, assignment.trim(), source); + } + } + + private static void addAssignment( + Map target, + String assignment, + String source) { + int equals = assignment.indexOf('='); + if (equals <= 0) { + throw new IllegalArgumentException( + source + " build define must use name=value: '" + assignment + "'"); + } + String name = assignment.substring(0, equals).trim(); + String value = assignment.substring(equals + 1).trim(); + validateName(name); + target.put(name, value); + } + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("build define/entry-point name cannot be blank"); + } + String[] segments = name.split("\\.", -1); + for (String segment : segments) { + if (!segment.matches("[A-Za-z_][A-Za-z0-9_]*")) { + throw new IllegalArgumentException("invalid build symbol name '" + name + "'"); + } + } + } +} diff --git a/src/main/java/dev/oreslang/compiler/ImportGraph.java b/src/main/java/dev/oreslang/compiler/ImportGraph.java new file mode 100644 index 00000000..01f20d33 --- /dev/null +++ b/src/main/java/dev/oreslang/compiler/ImportGraph.java @@ -0,0 +1,289 @@ +package dev.oreslang.compiler; + +import dev.oreslang.ast.Ast; +import dev.oreslang.imports.ImportRules; + +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Cross-file import graph/link validation. + * + * Import cycles are legal. Strongly connected components are initialization + * barriers: every unit in the component must be loaded/linked before any init + * hook in that component may execute. + */ +final class ImportGraph { + private ImportGraph() { } + + static String resolveImportUnitId(String unitId, Ast.ImportDecl imported, Set available) { + return resolveImportUnitId(unitId, imported, available, Map.of()); + } + + static String resolveImportUnitId( + String unitId, + Ast.ImportDecl imported, + Set available, + Map> importResolutions) { + ImportRules.validate(imported); + if (ImportRules.isJavaPath(imported.path())) return null; + + String normalizedUnitId = normalizeUnitId(unitId); + String resolved = importResolutions + .getOrDefault(normalizedUnitId, Map.of()) + .get(imported.path()); + if (resolved != null) { + String normalizedResolved = normalizeUnitId(resolved); + if (!available.contains(normalizedResolved)) { + throw new IllegalArgumentException( + "resolved import '" + imported.path() + "' from '" + unitId + + "' points to source unit that was not supplied: '" + normalizedResolved + "'"); + } + return normalizedResolved; + } + + String raw = imported.path().replace('\\', '/'); + Path parent = Path.of(unitId).getParent(); + Path candidatePath = raw.startsWith(".") + ? (parent == null ? Path.of(raw) : parent.resolve(raw)).normalize() + : Path.of(raw).normalize(); + String candidate = normalizeUnitId(candidatePath.toString()); + if (!available.contains(candidate) && !candidate.endsWith(".ores") && !candidate.endsWith(".java")) { + if (available.contains(candidate + ".ores")) candidate += ".ores"; + else if (available.contains(candidate + ".java")) candidate += ".java"; + } + if (available.contains(candidate)) return candidate; + if (raw.startsWith(".")) { + throw new IllegalArgumentException("relative import '" + imported.path() + "' from '" + unitId + + "' does not resolve to a supplied Oreslang/mixed source unit"); + } + return null; + } + + static Map> resolveDependencies( + Map programs, + Set available) { + return resolveDependencies(programs, available, Map.of()); + } + + static Map> resolveDependencies( + Map programs, + Set available, + Map> importResolutions) { + LinkedHashMap> dependencies = new LinkedHashMap<>(); + for (Map.Entry entry : programs.entrySet()) { + LinkedHashSet resolved = new LinkedHashSet<>(); + for (Ast.ImportDecl imported : entry.getValue().imports()) { + String target = resolveImportUnitId(entry.getKey(), imported, available, importResolutions); + if (target != null) resolved.add(target); + } + dependencies.put(entry.getKey(), Set.copyOf(resolved)); + } + return Map.copyOf(dependencies); + } + + static void validateLinkedImports(Map programs) { + validateLinkedImports(programs, Map.of()); + } + + static void validateLinkedImports( + Map programs, + Map> importResolutions) { + Set available = programs.keySet(); + for (Map.Entry entry : programs.entrySet()) { + String importer = entry.getKey(); + for (Ast.ImportDecl imported : entry.getValue().imports()) { + String targetId = resolveImportUnitId(importer, imported, available, importResolutions); + if (targetId == null) continue; // package resolver owns unresolved non-relative imports. + Ast.Program target = programs.get(targetId); + if (imported.wildcard()) continue; + for (String name : imported.names()) { + int matches = exportedMatches(target, imported.kind(), name); + if (matches == 0) { + throw new IllegalArgumentException("import " + imported.kind().name().toLowerCase() + + " '" + name + "' from '" + imported.path() + "' in '" + importer + + "' does not match an exported declaration in '" + targetId + "'"); + } + if (matches > 1) { + throw new IllegalArgumentException("import " + imported.kind().name().toLowerCase() + + " '" + name + "' from '" + imported.path() + "' in '" + importer + + "' is ambiguous in '" + targetId + "'"); + } + } + } + } + } + + private static int exportedMatches(Ast.Program program, Ast.ImportKind kind, String name) { + int matches = 0; + for (Ast.ModuleDecl module : program.modules()) { + if (kind == Ast.ImportKind.MODULE) { + if (module.name().equals(name)) matches++; + continue; + } + + for (Ast.Decl decl : module.declarations()) { + boolean matched = switch (kind) { + case FUNCTION -> decl instanceof Ast.FunctionDecl fn + && fn.visibility() == Ast.Visibility.PUBLIC + && fn.kind() == Ast.CallableKind.FNC + && fn.actorKind() == Ast.ActorKind.NONE + && fn.genericParameters().isEmpty() + && fn.name().equals(name); + case ACTOR -> decl instanceof Ast.ClassDecl klass + && klass.actorKind() != Ast.ActorKind.NONE + && klass.name().equals(name); + case CLASS -> decl instanceof Ast.ClassDecl klass + && klass.actorKind() == Ast.ActorKind.NONE + && klass.name().equals(name); + case INTERFACE -> decl instanceof Ast.InterfaceDecl iface + && iface.visibility() == Ast.Visibility.PUBLIC + && iface.name().equals(name); + case TYPE -> decl instanceof Ast.TypeAliasDecl alias + && alias.name().equals(name); + case TYPES -> (decl instanceof Ast.InterfaceDecl iface + && iface.visibility() == Ast.Visibility.PUBLIC + && iface.name().equals(name)) + || (decl instanceof Ast.TypeAliasDecl alias + && alias.name().equals(name)); + case TRAIT, STRUCT -> false; + case ALL -> (decl instanceof Ast.FunctionDecl fn + && fn.visibility() == Ast.Visibility.PUBLIC + && fn.name().equals(name)) + || (decl instanceof Ast.ClassDecl klass + && klass.name().equals(name)); + case MODULE -> false; + }; + if (matched) matches++; + } + } + return matches; + } + + /** + * Dependency-first SCC order. Members inside one SCC are lexicographically + * ordered so init order is deterministic even though the cycle itself does + * not define an order. + */ + static List> initializationGroups(Map> dependencies) { + if (dependencies.isEmpty()) return List.of(); + + Tarjan tarjan = new Tarjan(dependencies); + List> components = tarjan.components(); + Map componentOf = new HashMap<>(); + for (int i = 0; i < components.size(); i++) { + for (String unit : components.get(i)) componentOf.put(unit, i); + } + + Map> componentDeps = new LinkedHashMap<>(); + for (int i = 0; i < components.size(); i++) componentDeps.put(i, new LinkedHashSet<>()); + for (Map.Entry> entry : dependencies.entrySet()) { + int from = componentOf.get(entry.getKey()); + for (String dependency : entry.getValue()) { + Integer to = componentOf.get(dependency); + if (to != null && to != from) componentDeps.get(from).add(to); + } + } + + List componentIds = new ArrayList<>(componentDeps.keySet()); + componentIds.sort(Comparator.comparing(i -> components.get(i).getFirst())); + + List> ordered = new ArrayList<>(); + Set visited = new HashSet<>(); + Set visiting = new HashSet<>(); + for (int component : componentIds) { + visitComponent(component, componentDeps, components, visited, visiting, ordered); + } + return List.copyOf(ordered); + } + + private static void visitComponent( + int component, + Map> dependencies, + List> components, + Set visited, + Set visiting, + List> ordered) { + if (visited.contains(component)) return; + if (!visiting.add(component)) { + throw new IllegalStateException("condensed import graph unexpectedly contains a cycle"); + } + List deps = new ArrayList<>(dependencies.getOrDefault(component, Set.of())); + deps.sort(Comparator.comparing(i -> components.get(i).getFirst())); + for (int dependency : deps) { + visitComponent(dependency, dependencies, components, visited, visiting, ordered); + } + visiting.remove(component); + visited.add(component); + ordered.add(components.get(component)); + } + + static String normalizeUnitId(String id) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("source unit id cannot be blank"); + return Path.of(id).normalize().toString().replace('\\', '/'); + } + + private static final class Tarjan { + private final Map> graph; + private final Map index = new HashMap<>(); + private final Map lowLink = new HashMap<>(); + private final ArrayDeque stack = new ArrayDeque<>(); + private final Set onStack = new HashSet<>(); + private final List> components = new ArrayList<>(); + private int nextIndex; + + private Tarjan(Map> graph) { + this.graph = graph; + } + + private List> components() { + List vertices = new ArrayList<>(graph.keySet()); + vertices.sort(String::compareTo); + for (String vertex : vertices) { + if (!index.containsKey(vertex)) strongConnect(vertex); + } + return List.copyOf(components); + } + + private void strongConnect(String vertex) { + int current = nextIndex++; + index.put(vertex, current); + lowLink.put(vertex, current); + stack.push(vertex); + onStack.add(vertex); + + List edges = new ArrayList<>(graph.getOrDefault(vertex, Set.of())); + edges.sort(String::compareTo); + for (String next : edges) { + if (!graph.containsKey(next)) continue; + if (!index.containsKey(next)) { + strongConnect(next); + lowLink.put(vertex, Math.min(lowLink.get(vertex), lowLink.get(next))); + } else if (onStack.contains(next)) { + lowLink.put(vertex, Math.min(lowLink.get(vertex), index.get(next))); + } + } + + if (lowLink.get(vertex).equals(index.get(vertex))) { + List component = new ArrayList<>(); + String member; + do { + member = stack.pop(); + onStack.remove(member); + component.add(member); + } while (!member.equals(vertex)); + component.sort(String::compareTo); + components.add(List.copyOf(component)); + } + } + } +} diff --git a/src/main/java/dev/oreslang/compiler/IncrementalCompiler.java b/src/main/java/dev/oreslang/compiler/IncrementalCompiler.java new file mode 100644 index 00000000..a3989535 --- /dev/null +++ b/src/main/java/dev/oreslang/compiler/IncrementalCompiler.java @@ -0,0 +1,380 @@ +package dev.oreslang.compiler; + +import dev.oreslang.ast.AnnotationExpander; +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Parser; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * File-granular incremental compiler. + * + * Each source file is an independently versioned compilation/code unit. + * Source edits always rebuild their own unit. Importers rebuild only when the + * dependency's exported ABI digest changes, so implementation-only edits stay + * local while public contract changes invalidate the necessary dependents. + */ +public final class IncrementalCompiler { + private final Map cache = new LinkedHashMap<>(); + + public synchronized BuildResult compile(Map sources) { + return compile(sources, Map.of()); + } + + public synchronized BuildResult compile( + Map sources, + Map> importResolutions) { + if (importResolutions == null) throw new IllegalArgumentException("import resolutions cannot be null"); + if (sources.isEmpty()) return new BuildResult(Map.of(), Set.of(), Set.of(), List.of()); + + LinkedHashMap normalized = new LinkedHashMap<>(); + for (Map.Entry entry : sources.entrySet()) { + String id = normalizeUnitId(entry.getKey()); + if (normalized.putIfAbsent(id, entry.getValue()) != null) { + throw new IllegalArgumentException("duplicate source unit '" + id + "'"); + } + } + + Map hashes = new LinkedHashMap<>(); + Map abiHashes = new LinkedHashMap<>(); + Map parsed = new LinkedHashMap<>(); + + // Parse the complete source set before resolving imports. This is the + // first half of cycle tolerance: A may name B while B names A because + // neither unit is recursively compiled while discovering the other. + for (Map.Entry entry : normalized.entrySet()) { + hashes.put(entry.getKey(), digest(entry.getValue())); + Ast.Program program = AnnotationExpander.expand(Parser.parse(entry.getValue())); + parsed.put(entry.getKey(), program); + abiHashes.put(entry.getKey(), abiDigest(program)); + } + + Map> normalizedImportResolutions = + normalizeImportResolutions(importResolutions); + ImportGraph.validateLinkedImports(parsed, normalizedImportResolutions); + Map> dependencies = + ImportGraph.resolveDependencies(parsed, normalized.keySet(), normalizedImportResolutions); + List> initializationGroups = ImportGraph.initializationGroups(dependencies); + + LinkedHashSet dirty = new LinkedHashSet<>(); + LinkedHashSet abiChanged = new LinkedHashSet<>(); + for (String id : normalized.keySet()) { + CompiledUnit previous = cache.get(id); + boolean sourceChanged = previous == null || !previous.sourceDigest().equals(hashes.get(id)); + boolean depsChanged = previous == null || !previous.dependencies().equals(dependencies.get(id)); + if (sourceChanged || depsChanged) dirty.add(id); + if (previous == null || !previous.abiDigest().equals(abiHashes.get(id))) abiChanged.add(id); + } + + /* + * ABI changes invalidate the full reverse-import closure. This remains + * conservative until the cross-unit linker tracks exactly which public + * imported symbols are re-exported by each dependent. Crucially, + * implementation-only source changes do not enter this queue. + */ + Map> reverse = reverseDependencies(dependencies); + ArrayDeque abiQueue = new ArrayDeque<>(abiChanged); + LinkedHashSet abiAffected = new LinkedHashSet<>(abiChanged); + while (!abiQueue.isEmpty()) { + String changed = abiQueue.removeFirst(); + for (String dependent : reverse.getOrDefault(changed, Set.of())) { + dirty.add(dependent); + if (abiAffected.add(dependent)) abiQueue.addLast(dependent); + } + } + + LinkedHashMap next = new LinkedHashMap<>(); + LinkedHashSet rebuilt = new LinkedHashSet<>(); + LinkedHashSet reused = new LinkedHashSet<>(); + + for (String id : normalized.keySet()) { + if (!dirty.contains(id) && cache.containsKey(id)) { + next.put(id, cache.get(id)); + reused.add(id); + continue; + } + + Ast.Program checked = OresCompiler.parseAndTypeCheck(normalized.get(id)); + CompiledUnit unit = new CompiledUnit( + id, + packageId(id, checked), + checked.namespace(), + hashes.get(id), + abiHashes.get(id), + dependencies.get(id), + normalized.get(id), + checked); + next.put(id, unit); + rebuilt.add(id); + } + + cache.keySet().retainAll(normalized.keySet()); + cache.putAll(next); + return new BuildResult( + Map.copyOf(next), + Set.copyOf(rebuilt), + Set.copyOf(reused), + initializationGroups); + } + + public synchronized void clear() { + cache.clear(); + } + + private static String abiDigest(Ast.Program program) { + StringBuilder abi = new StringBuilder("ores-abi-v1\n"); + abi.append("namespace=").append(program.namespace() == null ? "" : program.namespace()).append('\n'); + + for (Ast.ModuleDecl module : program.modules()) { + abi.append("module ").append(module.name()).append('\n'); + for (Ast.Annotation annotation : module.annotations()) { + if (annotation.name().equals("AdheresTo")) { + abi.append(" module-annotation AdheresTo:"); + for (Ast.TypeRef arg : annotation.arguments()) abi.append(typeRef(arg)).append(','); + abi.append('\n'); + } + } + for (Ast.Decl decl : module.declarations()) appendAbi(abi, decl); + } + return digest(abi.toString()); + } + + private static void appendAbi(StringBuilder abi, Ast.Decl decl) { + if (decl instanceof Ast.FunctionDecl fn) { + if (fn.visibility() != Ast.Visibility.PUBLIC) return; + abi.append(fn.actorKind()).append(' ').append(fn.kind()).append(" pub ").append(fn.name()); + appendGenerics(abi, fn.genericParameters()); + appendParams(abi, fn.parameters()); + abi.append("=>").append(typeRef(fn.returnType())).append('\n'); + return; + } + if (decl instanceof Ast.ClassDecl klass) { + abi.append(klass.actorKind()).append(" class ").append(klass.name()); + appendGenerics(abi, klass.genericParameters()); + abi.append(" extends "); + for (Ast.TypeRef parent : klass.parents()) abi.append(typeRef(parent)).append(','); + abi.append(" implements "); + for (Ast.TypeRef iface : klass.interfaces()) abi.append(typeRef(iface)).append(','); + abi.append('\n'); + for (Ast.FieldDecl field : klass.fields()) { + String fromJsonKey = AnnotationExpander.fromJsonKey(field); + if (fromJsonKey != null) { + abi.append(" from-json ") + .append(field.name()).append('=') + .append(fromJsonKey.length()).append(':').append(fromJsonKey) + .append(':').append(field.type() == null ? "" : typeRef(field.type())).append('\n'); + } + if (field.visibility() != Ast.Visibility.PUBLIC) continue; + abi.append(" field ").append(field.bindingKind()).append(' ') + .append(field.type() == null ? "" : typeRef(field.type())) + .append(' ').append(field.name()).append('\n'); + } + for (Ast.MethodDecl method : klass.methods()) { + if (method.visibility() != Ast.Visibility.PUBLIC) continue; + abi.append(method.isStatic() ? " static-fnc " : " method ") + .append(method.name()); + appendGenerics(abi, method.genericParameters()); + appendParams(abi, method.parameters()); + abi.append("=>").append(typeRef(method.returnType())).append('\n'); + } + return; + } + if (decl instanceof Ast.InterfaceDecl iface) { + abi.append("interface ").append(iface.visibility()).append(' ').append(iface.name()); + appendGenerics(abi, iface.genericParameters()); + abi.append(" extends "); + for (Ast.TypeRef parent : iface.parents()) abi.append(typeRef(parent)).append(','); + abi.append('\n'); + for (Ast.InterfaceMember member : iface.members()) { + if (member instanceof Ast.InterfaceFunctionDecl fn) { + abi.append(" iface-fnc ").append(fn.name()); + appendGenerics(abi, fn.genericParameters()); + appendParams(abi, fn.parameters()); + abi.append("=>").append(typeRef(fn.returnType())).append('\n'); + } else if (member instanceof Ast.InterfaceFieldDecl field) { + abi.append(" iface-field ").append(field.name()).append(':').append(typeRef(field.type())).append('\n'); + } + } + return; + } + if (decl instanceof Ast.TypeAliasDecl alias) { + abi.append("type ").append(alias.name()); + appendGenerics(abi, alias.genericParameters()); + abi.append('=').append(typeRef(alias.target())).append('\n'); + return; + } + if (decl instanceof Ast.FieldDecl field && field.visibility() == Ast.Visibility.PUBLIC) { + abi.append("binding ").append(field.bindingKind()).append(' ') + .append(field.type() == null ? "" : typeRef(field.type())) + .append(' ').append(field.name()).append('\n'); + } + } + + private static void appendGenerics(StringBuilder abi, List generics) { + abi.append('<'); + for (String generic : generics) abi.append(generic).append(','); + abi.append('>'); + } + + private static void appendParams(StringBuilder abi, List params) { + abi.append('('); + for (Ast.Param param : params) { + if (param.structural()) abi.append("structural "); + abi.append(typeRef(param.type())).append(','); + } + abi.append(')'); + } + + private static String typeRef(Ast.TypeRef ref) { + if (ref == null) return ""; + if (ref.isStringLiteral()) return "'" + ref.stringLiteralValue() + "'"; + StringBuilder out = new StringBuilder(ref.name()); + if (ref.inferArguments()) return out.append("<>").toString(); + if (!ref.arguments().isEmpty()) { + out.append('<'); + for (Ast.TypeRef arg : ref.arguments()) out.append(typeRef(arg)).append(','); + out.append('>'); + } + return out.toString(); + } + + private static Set resolveDependencies(String unitId, Ast.Program program, Set available) { + LinkedHashSet result = new LinkedHashSet<>(); + Path parent = Path.of(unitId).getParent(); + for (Ast.ImportDecl imported : program.imports()) { + String raw = imported.path().replace('\\', '/'); + Path candidatePath = raw.startsWith(".") + ? (parent == null ? Path.of(raw) : parent.resolve(raw)).normalize() + : Path.of(raw).normalize(); + String candidate = normalizeUnitId(candidatePath.toString()); + if (!available.contains(candidate) && !candidate.endsWith(".ores") && available.contains(candidate + ".ores")) { + candidate += ".ores"; + } + if (available.contains(candidate)) { + result.add(candidate); + } else if (raw.startsWith(".")) { + throw new IllegalArgumentException("relative import '" + imported.path() + "' from '" + unitId + + "' does not resolve to a supplied Oreslang source unit"); + } + } + return Set.copyOf(result); + } + + private static Map> normalizeImportResolutions( + Map> importResolutions) { + LinkedHashMap> normalized = new LinkedHashMap<>(); + for (Map.Entry> importer : importResolutions.entrySet()) { + String importerId = normalizeUnitId(importer.getKey()); + if (importer.getValue() == null) { + throw new IllegalArgumentException("import resolution map cannot be null for '" + importerId + "'"); + } + LinkedHashMap imports = new LinkedHashMap<>(); + for (Map.Entry resolution : importer.getValue().entrySet()) { + if (resolution.getKey() == null || resolution.getKey().isBlank()) { + throw new IllegalArgumentException("resolved import path cannot be blank"); + } + String targetId = normalizeUnitId(resolution.getValue()); + String previous = imports.putIfAbsent(resolution.getKey(), targetId); + if (previous != null && !previous.equals(targetId)) { + throw new IllegalArgumentException( + "conflicting resolved import '" + resolution.getKey() + "' for '" + importerId + "'"); + } + } + normalized.put(importerId, Map.copyOf(imports)); + } + return Map.copyOf(normalized); + } + + private static Map> reverseDependencies(Map> dependencies) { + LinkedHashMap> reverse = new LinkedHashMap<>(); + for (String id : dependencies.keySet()) reverse.put(id, new LinkedHashSet<>()); + for (Map.Entry> entry : dependencies.entrySet()) { + for (String dependency : entry.getValue()) { + reverse.computeIfAbsent(dependency, ignored -> new LinkedHashSet<>()).add(entry.getKey()); + } + } + LinkedHashMap> frozen = new LinkedHashMap<>(); + for (Map.Entry> entry : reverse.entrySet()) frozen.put(entry.getKey(), Set.copyOf(entry.getValue())); + return Map.copyOf(frozen); + } + + private static String packageId(String unitId, Ast.Program program) { + if (program.namespace() != null) return program.namespace(); + String id = unitId; + if (id.endsWith(".ores")) id = id.substring(0, id.length() - ".ores".length()); + return id; + } + + private static String normalizeUnitId(String id) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("source unit id cannot be blank"); + return Path.of(id).normalize().toString().replace('\\', '/'); + } + + private static String digest(String source) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (Exception impossible) { + throw new IllegalStateException(impossible); + } + } + + public record CompiledUnit( + String unitId, + String packageId, + String namespace, + String sourceDigest, + String abiDigest, + Set dependencies, + String sourceText, + Ast.Program program) { + public CompiledUnit { + dependencies = Set.copyOf(dependencies); + } + } + + public record BuildResult( + Map units, + Set rebuiltUnits, + Set reusedUnits, + List> initializationGroups) { + public BuildResult { + units = Map.copyOf(units); + rebuiltUnits = Set.copyOf(rebuiltUnits); + reusedUnits = Set.copyOf(reusedUnits); + initializationGroups = initializationGroups.stream() + .map(List::copyOf) + .toList(); + } + + /** Backward-compatible constructor for callers that do not need lifecycle planning. */ + public BuildResult( + Map units, + Set rebuiltUnits, + Set reusedUnits) { + this(units, rebuiltUnits, reusedUnits, List.of()); + } + + public boolean rebuilt(String unitId) { return rebuiltUnits.contains(normalizeUnitId(unitId)); } + public boolean reused(String unitId) { return reusedUnits.contains(normalizeUnitId(unitId)); } + + /** + * Flattens the dependency-first SCC plan. Units in the same inner list + * form one load barrier: all of them must be linked before the first + * init hook in that group executes. + */ + public List initializationOrder() { + return initializationGroups.stream().flatMap(List::stream).toList(); + } + } +} diff --git a/src/main/java/dev/oreslang/compiler/OresCompiler.java b/src/main/java/dev/oreslang/compiler/OresCompiler.java new file mode 100644 index 00000000..1d50cefe --- /dev/null +++ b/src/main/java/dev/oreslang/compiler/OresCompiler.java @@ -0,0 +1,36 @@ +package dev.oreslang.compiler; + +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Parser; +import dev.oreslang.runtime.CapabilityChecker; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.types.TypeChecker; + +/** Trusted compiler front-end API for build systems and isolate admission. */ +public final class OresCompiler { + private OresCompiler() { } + + public static Ast.Program parseAndTypeCheck(String source) { + return TypeChecker.check(Parser.parse(source)); + } + + /** + * Parse and type-check the complete source first, then perform closed-world + * build optimization. Type errors in code that later becomes unreachable + * are still reported; tree shaking is an optimization, not conditional + * compilation that hides invalid source. + */ + public static TreeShaker.Result compileForBuild(String source, BuildOptions options) { + return TreeShaker.shake(parseAndTypeCheck(source), options); + } + + /** + * Performs syntax, type, and language-capability admission without + * executing guest code. + */ + public static Ast.Program validateForIsolate(String source, IsolatePolicy policy) { + Ast.Program program = parseAndTypeCheck(source); + CapabilityChecker.check(program, policy); + return program; + } +} diff --git a/src/main/java/dev/oreslang/compiler/TreeShaker.java b/src/main/java/dev/oreslang/compiler/TreeShaker.java new file mode 100644 index 00000000..842ae90d --- /dev/null +++ b/src/main/java/dev/oreslang/compiler/TreeShaker.java @@ -0,0 +1,1353 @@ +package dev.oreslang.compiler; + +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Parser; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Closed-world build optimizer. + * + * The pass deliberately runs after semantic checking and before backend + * lowering. It folds build-time constants and branches, then computes + * declaration reachability from executable entry points (or the public API for + * library builds). Function references remain symbolic in the AST, so + * references such as A.foo can keep A.foo alive without retaining B.foo from a + * folded conditional. + */ +public final class TreeShaker { + private static final Object UNKNOWN = new Object(); + + private TreeShaker() { } + + public static Result shake(Ast.Program checkedProgram, BuildOptions options) { + Objects.requireNonNull(checkedProgram, "checkedProgram"); + Objects.requireNonNull(options, "options"); + return new Engine(checkedProgram, options).run(); + } + + public record Result( + Ast.Program program, + Set retainedSymbols, + Set removedSymbols, + Map compileTimeConstants) { + public Result { + retainedSymbols = Set.copyOf(retainedSymbols); + removedSymbols = Set.copyOf(removedSymbols); + compileTimeConstants = Map.copyOf(compileTimeConstants); + } + + public boolean retained(String symbol) { + return retainedSymbols.contains(symbol); + } + + public boolean removed(String symbol) { + return removedSymbols.contains(symbol); + } + } + + private record Symbol(String id, String module, Ast.Decl declaration) { } + + private static final class Engine { + private final Ast.Program input; + private final BuildOptions options; + + private final Map constants = new LinkedHashMap<>(); + private final Map uniqueConstants = new LinkedHashMap<>(); + private final Set ambiguousConstants = new LinkedHashSet<>(); + private final Map constantValues = new LinkedHashMap<>(); + private final Set constantsBeingEvaluated = new LinkedHashSet<>(); + + private final Map inlineFunctions = new LinkedHashMap<>(); + private final Map uniqueInlineFunctions = new LinkedHashMap<>(); + private final Set ambiguousInlineFunctions = new LinkedHashSet<>(); + private final Set functionsBeingInlined = new LinkedHashSet<>(); + + private final Map symbols = new LinkedHashMap<>(); + private final Map uniqueSymbols = new LinkedHashMap<>(); + private final Set ambiguousSymbols = new LinkedHashSet<>(); + private final Set moduleNames = new LinkedHashSet<>(); + + private final LinkedHashSet retained = new LinkedHashSet<>(); + private final LinkedHashSet externalBindings = new LinkedHashSet<>(); + private final ArrayDeque work = new ArrayDeque<>(); + + private Ast.Program rewritten; + + Engine(Ast.Program input, BuildOptions options) { + this.input = input; + this.options = options; + } + + Result run() { + collectConstants(); + collectInlineFunctions(); + applyBuildDefines(); + materializeConstantValues(); + rewritten = rewriteProgram(input); + indexSymbols(rewritten); + seedRoots(); + + while (!work.isEmpty()) { + Symbol symbol = symbols.get(work.removeFirst()); + if (symbol != null) scanDeclaration(symbol.module(), symbol.declaration()); + } + + Ast.Program pruned = pruneProgram(rewritten); + LinkedHashSet removed = new LinkedHashSet<>(symbols.keySet()); + removed.removeAll(retained); + return new Result(pruned, retained, removed, constantValues); + } + + private void collectConstants() { + for (Ast.ModuleDecl module : input.modules()) { + for (Ast.Decl declaration : module.declarations()) { + if (declaration instanceof Ast.FieldDecl field + && field.bindingKind() == Ast.BindingKind.CONST) { + String qualified = qualify(module.name(), field.name()); + constants.put(qualified, field); + String previous = uniqueConstants.putIfAbsent(field.name(), qualified); + if (previous != null && !previous.equals(qualified)) { + uniqueConstants.remove(field.name()); + ambiguousConstants.add(field.name()); + } + } + } + } + } + + private void collectInlineFunctions() { + for (Ast.ModuleDecl module : input.modules()) { + for (Ast.Decl declaration : module.declarations()) { + if (!(declaration instanceof Ast.FunctionDecl function)) continue; + String qualified = qualify(module.name(), function.name()); + inlineFunctions.put(qualified, function); + String previous = uniqueInlineFunctions.putIfAbsent(function.name(), qualified); + if (previous != null && !previous.equals(qualified)) { + uniqueInlineFunctions.remove(function.name()); + ambiguousInlineFunctions.add(function.name()); + } + } + } + } + + private record InlineTarget(String id, Ast.FunctionDecl function) { } + + private InlineTarget resolveInlineTarget( + Ast.Expr callee, + String module, + Map locals) { + if (callee instanceof Ast.NameExpr name) { + if (locals.containsKey(name.name())) return null; + String local = qualify(module, name.name()); + if (inlineFunctions.containsKey(local)) { + return new InlineTarget(local, inlineFunctions.get(local)); + } + if (ambiguousInlineFunctions.contains(name.name())) return null; + String qualified = uniqueInlineFunctions.get(name.name()); + return qualified == null ? null : new InlineTarget(qualified, inlineFunctions.get(qualified)); + } + if (callee instanceof Ast.MemberExpr member + && member.receiver() instanceof Ast.NameExpr namespace + && !locals.containsKey(namespace.name())) { + String qualified = qualify(namespace.name(), member.member()); + Ast.FunctionDecl function = inlineFunctions.get(qualified); + return function == null ? null : new InlineTarget(qualified, function); + } + return null; + } + + private boolean canInlineConstantCall( + InlineTarget target, + Ast.CallExpr call, + List arguments) { + Ast.FunctionDecl function = target.function(); + if (call.typeArgumentsPresent() + || !function.genericParameters().isEmpty() + || function.actorKind() != Ast.ActorKind.NONE + || function.async() + || function.parameters().size() != arguments.size() + || function.body().size() != 1 + || !(function.body().getFirst() instanceof Ast.ReturnStmt returned) + || returned.value() == null + || containsLambda(returned.value()) + || functionsBeingInlined.contains(target.id())) { + return false; + } + return arguments.stream().allMatch(Ast.LiteralExpr.class::isInstance); + } + + private boolean containsLambda(Ast.Expr expression) { + if (expression == null) return false; + if (expression instanceof Ast.LambdaExpr) return true; + if (expression instanceof Ast.TypeTestExpr test) return containsLambda(test.value()); + if (expression instanceof Ast.PatternTestExpr test) return containsLambda(test.value()); + if (expression instanceof Ast.CastExpr cast) return containsLambda(cast.value()); + if (expression instanceof Ast.BinaryExpr binary) { + return containsLambda(binary.left()) || containsLambda(binary.right()); + } + if (expression instanceof Ast.UnaryExpr unary) return containsLambda(unary.operand()); + if (expression instanceof Ast.AssignExpr assignment) { + return containsLambda(assignment.target()) || containsLambda(assignment.value()); + } + if (expression instanceof Ast.ConditionalExpr conditional) { + return containsLambda(conditional.condition()) + || containsLambda(conditional.whenTrue()) + || containsLambda(conditional.whenFalse()); + } + if (expression instanceof Ast.CallExpr call) { + if (containsLambda(call.callee())) return true; + return call.arguments().stream().anyMatch(this::containsLambda); + } + if (expression instanceof Ast.MemberExpr member) return containsLambda(member.receiver()); + if (expression instanceof Ast.IndexExpr indexed) { + return containsLambda(indexed.receiver()) || containsLambda(indexed.index()); + } + if (expression instanceof Ast.NewExpr created) { + return created.arguments().stream().anyMatch(this::containsLambda); + } + if (expression instanceof Ast.AwaitExpr awaited) return containsLambda(awaited.expression()); + if (expression instanceof Ast.ListExpr list) { + return list.elements().stream().anyMatch(this::containsLambda); + } + if (expression instanceof Ast.TupleExpr tuple) { + return tuple.elements().stream().anyMatch(this::containsLambda); + } + if (expression instanceof Ast.ObjectExpr object) { + for (Ast.ObjectField field : object.fields()) { + if (field.isDynamic() && containsLambda(field.dynamicName())) return true; + if (containsLambda(field.value())) return true; + } + } + return false; + } + + private Ast.Expr inlineConstantCall( + InlineTarget target, + List arguments) { + Ast.FunctionDecl function = target.function(); + Ast.ReturnStmt returned = (Ast.ReturnStmt) function.body().getFirst(); + LinkedHashMap substitutions = new LinkedHashMap<>(); + for (int i = 0; i < function.parameters().size(); i++) { + substitutions.put(function.parameters().get(i).name(), arguments.get(i)); + } + + functionsBeingInlined.add(target.id()); + try { + Ast.Expr substituted = substitute(returned.value(), substitutions, Set.of()); + return rewriteExpression(substituted, moduleOf(target.id()), Map.of()); + } finally { + functionsBeingInlined.remove(target.id()); + } + } + + private Ast.Expr substitute( + Ast.Expr expression, + Map substitutions, + Set shadowed) { + if (expression == null || expression instanceof Ast.LiteralExpr) return expression; + if (expression instanceof Ast.NameExpr name) { + if (!shadowed.contains(name.name()) && substitutions.containsKey(name.name())) { + return substitutions.get(name.name()); + } + return expression; + } + if (expression instanceof Ast.TypeTestExpr test) { + return new Ast.TypeTestExpr( + substitute(test.value(), substitutions, shadowed), + test.targetType(), + test.binding()); + } + if (expression instanceof Ast.PatternTestExpr test) { + return new Ast.PatternTestExpr( + substitute(test.value(), substitutions, shadowed), + test.pattern()); + } + if (expression instanceof Ast.CastExpr cast) { + return new Ast.CastExpr( + substitute(cast.value(), substitutions, shadowed), + cast.targetType(), + cast.mode()); + } + if (expression instanceof Ast.BinaryExpr binary) { + return new Ast.BinaryExpr( + binary.operator(), + substitute(binary.left(), substitutions, shadowed), + substitute(binary.right(), substitutions, shadowed)); + } + if (expression instanceof Ast.UnaryExpr unary) { + return new Ast.UnaryExpr( + unary.operator(), + substitute(unary.operand(), substitutions, shadowed)); + } + if (expression instanceof Ast.AssignExpr assignment) { + return new Ast.AssignExpr( + substitute(assignment.target(), substitutions, shadowed), + substitute(assignment.value(), substitutions, shadowed)); + } + if (expression instanceof Ast.ConditionalExpr conditional) { + return new Ast.ConditionalExpr( + substitute(conditional.condition(), substitutions, shadowed), + substitute(conditional.whenTrue(), substitutions, shadowed), + substitute(conditional.whenFalse(), substitutions, shadowed)); + } + if (expression instanceof Ast.CallExpr call) { + List arguments = new ArrayList<>(); + for (Ast.Expr argument : call.arguments()) { + arguments.add(substitute(argument, substitutions, shadowed)); + } + return new Ast.CallExpr( + substitute(call.callee(), substitutions, shadowed), + call.typeArguments(), + call.typeArgumentsPresent(), + arguments); + } + if (expression instanceof Ast.MemberExpr member) { + return new Ast.MemberExpr( + substitute(member.receiver(), substitutions, shadowed), + member.member()); + } + if (expression instanceof Ast.IndexExpr indexed) { + return new Ast.IndexExpr( + substitute(indexed.receiver(), substitutions, shadowed), + substitute(indexed.index(), substitutions, shadowed)); + } + if (expression instanceof Ast.NewExpr created) { + List arguments = new ArrayList<>(); + for (Ast.Expr argument : created.arguments()) { + arguments.add(substitute(argument, substitutions, shadowed)); + } + return new Ast.NewExpr(created.type(), arguments); + } + if (expression instanceof Ast.AwaitExpr awaited) { + return new Ast.AwaitExpr(substitute(awaited.expression(), substitutions, shadowed)); + } + if (expression instanceof Ast.ListExpr list) { + List elements = new ArrayList<>(); + for (Ast.Expr element : list.elements()) { + elements.add(substitute(element, substitutions, shadowed)); + } + return new Ast.ListExpr(elements); + } + if (expression instanceof Ast.TupleExpr tuple) { + List elements = new ArrayList<>(); + for (Ast.Expr element : tuple.elements()) { + elements.add(substitute(element, substitutions, shadowed)); + } + return new Ast.TupleExpr(elements); + } + if (expression instanceof Ast.ObjectExpr object) { + List fields = new ArrayList<>(); + for (Ast.ObjectField field : object.fields()) { + fields.add(field.isDynamic() + ? Ast.ObjectField.dynamic( + substitute(field.dynamicName(), substitutions, shadowed), + substitute(field.value(), substitutions, shadowed)) + : Ast.ObjectField.named( + field.name(), + substitute(field.value(), substitutions, shadowed))); + } + return new Ast.ObjectExpr(fields); + } + if (expression instanceof Ast.LambdaExpr lambda) { + LinkedHashSet nestedShadowed = new LinkedHashSet<>(shadowed); + for (Ast.Param parameter : lambda.parameters()) nestedShadowed.add(parameter.name()); + return new Ast.LambdaExpr( + lambda.parameters(), + substitute(lambda.expressionBody(), substitutions, nestedShadowed), + lambda.blockBody(), + lambda.nonLexical()); + } + throw new IllegalStateException( + "unhandled expression during specialization " + expression.getClass().getSimpleName()); + } + + private void applyBuildDefines() { + for (Map.Entry entry : options.defines().entrySet()) { + String qualified = resolveConstantOverride(entry.getKey()); + Ast.FieldDecl field = constants.get(qualified); + Object original = field.initializer() == null + ? UNKNOWN + : evaluate(field.initializer(), moduleOf(qualified), Map.of()); + constantValues.put( + qualified, + parseDefine(entry.getValue(), field.type(), original, entry.getKey())); + } + } + + private void materializeConstantValues() { + for (String qualified : constants.keySet()) constantValue(qualified); + } + + private String resolveConstantOverride(String requested) { + if (constants.containsKey(requested)) return requested; + if (requested.contains(".")) { + throw new IllegalArgumentException( + "build define '" + requested + "' does not name a const declaration"); + } + if (ambiguousConstants.contains(requested)) { + throw new IllegalArgumentException( + "build define '" + requested + "' is ambiguous; use Module." + requested); + } + String qualified = uniqueConstants.get(requested); + if (qualified == null) { + throw new IllegalArgumentException( + "build define '" + requested + "' does not name a const declaration"); + } + return qualified; + } + + private Object constantValue(String qualified) { + Object existing = constantValues.get(qualified); + if (existing != null) return existing; + Ast.FieldDecl field = constants.get(qualified); + if (field == null || field.initializer() == null) return UNKNOWN; + if (!constantsBeingEvaluated.add(qualified)) { + throw new IllegalArgumentException( + "cyclic compile-time const dependency involving '" + qualified + "'"); + } + try { + Object value = evaluate(field.initializer(), moduleOf(qualified), Map.of()); + if (value != UNKNOWN) constantValues.put(qualified, value); + return value; + } finally { + constantsBeingEvaluated.remove(qualified); + } + } + + private Object evaluate(Ast.Expr expression, String module, Map locals) { + if (expression == null) return UNKNOWN; + if (expression instanceof Ast.LiteralExpr literal) return literal.value(); + if (expression instanceof Ast.NameExpr name) { + if (locals.containsKey(name.name())) return locals.get(name.name()); + String key = resolveConstantReference(name.name(), module); + return key == null ? UNKNOWN : constantValue(key); + } + if (expression instanceof Ast.MemberExpr member + && member.receiver() instanceof Ast.NameExpr namespace) { + String key = qualify(namespace.name(), member.member()); + if (constants.containsKey(key)) return constantValue(key); + return UNKNOWN; + } + if (expression instanceof Ast.UnaryExpr unary) { + Object operand = evaluate(unary.operand(), module, locals); + if (operand == UNKNOWN) return UNKNOWN; + if (unary.operator().equals("!") && operand instanceof Boolean value) return !value; + if (unary.operator().equals("+") && operand instanceof Number) return operand; + if (unary.operator().equals("-") && operand instanceof Long value) return -value; + if (unary.operator().equals("-") && operand instanceof Double value) return -value; + return UNKNOWN; + } + if (expression instanceof Ast.BinaryExpr binary) { + Object left = evaluate(binary.left(), module, locals); + if (binary.operator().equals("&&") && left instanceof Boolean value && !value) return false; + if (binary.operator().equals("||") && left instanceof Boolean value && value) return true; + Object right = evaluate(binary.right(), module, locals); + if (left == UNKNOWN || right == UNKNOWN) return UNKNOWN; + return switch (binary.operator()) { + case "&&" -> left instanceof Boolean l && right instanceof Boolean r ? l && r : UNKNOWN; + case "||" -> left instanceof Boolean l && right instanceof Boolean r ? l || r : UNKNOWN; + case "==" -> Objects.equals(left, right); + case "!=" -> !Objects.equals(left, right); + case "<" -> compare(left, right, c -> c < 0); + case "<=" -> compare(left, right, c -> c <= 0); + case ">" -> compare(left, right, c -> c > 0); + case ">=" -> compare(left, right, c -> c >= 0); + default -> UNKNOWN; + }; + } + if (expression instanceof Ast.ConditionalExpr conditional) { + Object condition = evaluate(conditional.condition(), module, locals); + if (condition instanceof Boolean value) { + return evaluate(value ? conditional.whenTrue() : conditional.whenFalse(), module, locals); + } + } + return UNKNOWN; + } + + private Object compare(Object left, Object right, java.util.function.IntPredicate predicate) { + if (left instanceof Number l && right instanceof Number r) { + return predicate.test(Double.compare(l.doubleValue(), r.doubleValue())); + } + if (left instanceof String l && right instanceof String r) { + return predicate.test(l.compareTo(r)); + } + return UNKNOWN; + } + + private Ast.Program rewriteProgram(Ast.Program program) { + List modules = new ArrayList<>(); + for (Ast.ModuleDecl module : program.modules()) { + List declarations = new ArrayList<>(); + for (Ast.Decl declaration : module.declarations()) { + declarations.add(rewriteDeclaration(module.name(), declaration)); + } + modules.add(new Ast.ModuleDecl(module.name(), module.annotations(), declarations)); + } + return new Ast.Program(program.namespace(), program.imports(), modules); + } + + private Ast.Decl rewriteDeclaration(String module, Ast.Decl declaration) { + if (declaration instanceof Ast.FunctionDecl function) { + LinkedHashMap locals = new LinkedHashMap<>(); + for (Ast.Param parameter : function.parameters()) locals.put(parameter.name(), UNKNOWN); + return new Ast.FunctionDecl( + function.name(), + function.kind(), + function.visibility(), + function.async(), + function.nonLexical(), + function.actorKind(), + function.genericParameters(), + function.parameters(), + function.returnType(), + function.annotations(), + rewriteStatements(function.body(), module, locals)); + } + if (declaration instanceof Ast.FieldDecl field) { + Ast.Expr initializer = rewriteExpression(field.initializer(), module, Map.of()); + if (field.bindingKind() == Ast.BindingKind.CONST) { + String key = qualify(module, field.name()); + Object value = constantValues.get(key); + if (value != null && value != UNKNOWN) initializer = new Ast.LiteralExpr(value); + } + return new Ast.FieldDecl( + field.name(), + field.visibility(), + field.bindingKind(), + field.type(), + initializer); + } + if (declaration instanceof Ast.ClassDecl klass) { + List fields = new ArrayList<>(); + for (Ast.FieldDecl field : klass.fields()) { + fields.add((Ast.FieldDecl) rewriteDeclaration(module, field)); + } + List methods = new ArrayList<>(); + for (Ast.MethodDecl method : klass.methods()) { + LinkedHashMap locals = new LinkedHashMap<>(); + locals.put("self", UNKNOWN); + for (Ast.Param parameter : method.parameters()) locals.put(parameter.name(), UNKNOWN); + methods.add(new Ast.MethodDecl( + method.name(), + method.visibility(), + method.isStatic(), + method.isAbstract(), + method.async(), + method.explicitReceiverType(), + method.genericParameters(), + method.parameters(), + method.returnType(), + method.annotations(), + rewriteStatements(method.body(), module, locals))); + } + return new Ast.ClassDecl( + klass.name(), + klass.isAbstract(), + klass.actorKind(), + klass.genericParameters(), + klass.parents(), + klass.interfaces(), + fields, + methods); + } + return declaration; + } + + private List rewriteStatements( + List statements, + String module, + LinkedHashMap locals) { + List output = new ArrayList<>(); + for (Ast.Stmt statement : statements) { + output.addAll(rewriteStatement(statement, module, locals)); + } + return List.copyOf(output); + } + + private List rewriteStatement( + Ast.Stmt statement, + String module, + LinkedHashMap locals) { + if (statement instanceof Ast.BindingStmt binding) { + Ast.Expr initializer = rewriteExpression(binding.initializer(), module, locals); + Object value = binding.kind() == Ast.BindingKind.LET + ? UNKNOWN + : evaluate(initializer, module, locals); + locals.put(binding.name(), value); + return List.of(new Ast.BindingStmt( + binding.kind(), binding.declaredType(), binding.name(), initializer)); + } + if (statement instanceof Ast.DestructureStmt destructure) { + Ast.Expr initializer = rewriteExpression(destructure.initializer(), module, locals); + for (Ast.DestructureBinding binding : destructure.bindings()) { + if (!binding.isDiscard()) locals.put(binding.name(), UNKNOWN); + } + return List.of(new Ast.DestructureStmt( + destructure.kind(), destructure.bindings(), initializer)); + } + if (statement instanceof Ast.ReturnStmt returned) { + return List.of(new Ast.ReturnStmt( + rewriteExpression(returned.value(), module, locals))); + } + if (statement instanceof Ast.ExprStmt expression) { + return List.of(new Ast.ExprStmt( + rewriteExpression(expression.expression(), module, locals))); + } + if (statement instanceof Ast.DeferStmt deferred) { + return List.of(new Ast.DeferStmt( + rewriteExpression(deferred.expression(), module, locals))); + } + if (statement instanceof Ast.BlockStmt block) { + return List.of(new Ast.BlockStmt( + rewriteStatements(block.body(), module, new LinkedHashMap<>(locals)))); + } + if (statement instanceof Ast.BreakStmt || statement instanceof Ast.ContinueStmt) { + return List.of(statement); + } + if (statement instanceof Ast.LoopStmt loop) { + return List.of(new Ast.LoopStmt( + rewriteStatements(loop.body(), module, new LinkedHashMap<>(locals)))); + } + if (statement instanceof Ast.IfStmt conditional) { + List branches = new ArrayList<>(); + List elseBody = rewriteStatements( + conditional.elseBody(), module, new LinkedHashMap<>(locals)); + for (Ast.IfBranch branch : conditional.branches()) { + Ast.Expr condition = rewriteExpression(branch.condition(), module, locals); + Object known = evaluate(condition, module, locals); + List body = rewriteStatements( + branch.body(), module, new LinkedHashMap<>(locals)); + if (known instanceof Boolean value) { + if (!value) continue; + if (branches.isEmpty()) { + // A selected if/elseif body is still a lexical scope. + // Never flatten it into the parent statement list: + // doing so would leak bindings and change defer/lifetime timing. + return List.of(new Ast.BlockStmt(body)); + } + elseBody = body; + break; + } + branches.add(new Ast.IfBranch(condition, body)); + } + if (branches.isEmpty()) { + // The else arm has the same lexical-scope semantics as any + // other conditional body, even when the condition folds. + return elseBody.isEmpty() + ? List.of() + : List.of(new Ast.BlockStmt(elseBody)); + } + return List.of(new Ast.IfStmt(branches, elseBody)); + } + if (statement instanceof Ast.MatchStmt matched) { + Ast.Expr subject = rewriteExpression(matched.subject(), module, locals); + List arms = new ArrayList<>(); + for (Ast.MatchArm arm : matched.arms()) { + LinkedHashMap armLocals = new LinkedHashMap<>(locals); + addPatternLocals(arm.pattern(), armLocals); + arms.add(new Ast.MatchArm( + arm.pattern(), + rewriteExpression(arm.guard(), module, armLocals), + rewriteStatements(arm.body(), module, armLocals))); + } + return List.of(new Ast.MatchStmt(subject, matched.ordered(), arms)); + } + if (statement instanceof Ast.SwitchStmt switched) { + Ast.Expr subject = rewriteExpression(switched.subject(), module, locals); + List cases = new ArrayList<>(); + for (Ast.SwitchCase arm : switched.cases()) { + List constants = new ArrayList<>(); + for (Ast.Expr constant : arm.constants()) { + constants.add(rewriteExpression(constant, module, locals)); + } + cases.add(new Ast.SwitchCase( + constants, + rewriteStatements(arm.body(), module, new LinkedHashMap<>(locals)))); + } + return List.of(new Ast.SwitchStmt( + subject, + cases, + rewriteStatements(switched.defaultBody(), module, new LinkedHashMap<>(locals)))); + } + if (statement instanceof Ast.SelectStmt selected) { + List arms = new ArrayList<>(); + for (Ast.SelectArm arm : selected.arms()) { + LinkedHashMap armLocals = + new LinkedHashMap<>(locals); + if (arm.bindingName() != null) { + armLocals.put(arm.bindingName(), UNKNOWN); + } + arms.add(new Ast.SelectArm( + arm.operation(), + rewriteExpression(arm.channel(), module, locals), + rewriteExpression(arm.value(), module, locals), + arm.bindingKind(), + arm.bindingName(), + rewriteStatements(arm.body(), module, armLocals))); + } + return List.of(new Ast.SelectStmt( + selected.mode(), + selected.policy(), + arms)); + } + if (statement instanceof Ast.TryStmt tried) { + LinkedHashMap catchLocals = new LinkedHashMap<>(locals); + if (tried.errorName() != null) catchLocals.put(tried.errorName(), UNKNOWN); + return List.of(new Ast.TryStmt( + rewriteStatements(tried.body(), module, new LinkedHashMap<>(locals)), + tried.errorName(), + rewriteStatements(tried.catchBody(), module, catchLocals), + rewriteStatements(tried.finallyBody(), module, new LinkedHashMap<>(locals)))); + } + if (statement instanceof Ast.ForOfDestructureStmt loop) { + Ast.Expr iterable = rewriteExpression(loop.iterable(), module, locals); + LinkedHashMap bodyLocals = new LinkedHashMap<>(locals); + for (Ast.DestructureBinding binding : loop.bindings()) { + if (!binding.isDiscard()) bodyLocals.put(binding.name(), UNKNOWN); + } + return List.of(new Ast.ForOfDestructureStmt( + loop.bindings(), + iterable, + rewriteStatements(loop.body(), module, bodyLocals))); + } + if (statement instanceof Ast.ForOfStmt loop) { + Ast.Expr iterable = rewriteExpression(loop.iterable(), module, locals); + LinkedHashMap bodyLocals = new LinkedHashMap<>(locals); + bodyLocals.put(loop.bindingName(), UNKNOWN); + return List.of(new Ast.ForOfStmt( + loop.bindingKind(), + loop.bindingName(), + iterable, + rewriteStatements(loop.body(), module, bodyLocals))); + } + if (statement instanceof Ast.ForStmt loop) { + LinkedHashMap loopLocals = new LinkedHashMap<>(locals); + Ast.Stmt initializer = null; + if (loop.initializer() != null) { + List initializers = rewriteStatement(loop.initializer(), module, loopLocals); + if (initializers.size() != 1) { + throw new IllegalStateException("for initializer cannot expand during tree shaking"); + } + initializer = initializers.getFirst(); + } + return List.of(new Ast.ForStmt( + initializer, + rewriteExpression(loop.condition(), module, loopLocals), + rewriteExpression(loop.update(), module, loopLocals), + rewriteStatements(loop.body(), module, new LinkedHashMap<>(loopLocals)))); + } + throw new IllegalStateException("unhandled statement " + statement.getClass().getSimpleName()); + } + + private Ast.Expr rewriteExpression( + Ast.Expr expression, + String module, + Map locals) { + if (expression == null) return null; + Object constant = evaluate(expression, module, locals); + if (constant != UNKNOWN) return new Ast.LiteralExpr(constant); + + if (expression instanceof Ast.LiteralExpr || expression instanceof Ast.NameExpr) return expression; + if (expression instanceof Ast.TypeTestExpr test) { + return new Ast.TypeTestExpr( + rewriteExpression(test.value(), module, locals), + test.targetType(), + test.binding()); + } + if (expression instanceof Ast.PatternTestExpr test) { + return new Ast.PatternTestExpr( + rewriteExpression(test.value(), module, locals), + test.pattern()); + } + if (expression instanceof Ast.CastExpr cast) { + return new Ast.CastExpr( + rewriteExpression(cast.value(), module, locals), + cast.targetType(), + cast.mode()); + } + if (expression instanceof Ast.BinaryExpr binary) { + Ast.Expr left = rewriteExpression(binary.left(), module, locals); + Ast.Expr right = rewriteExpression(binary.right(), module, locals); + Ast.BinaryExpr rewritten = new Ast.BinaryExpr(binary.operator(), left, right); + Object value = evaluate(rewritten, module, locals); + return value == UNKNOWN ? rewritten : new Ast.LiteralExpr(value); + } + if (expression instanceof Ast.UnaryExpr unary) { + Ast.UnaryExpr rewritten = new Ast.UnaryExpr( + unary.operator(), rewriteExpression(unary.operand(), module, locals)); + Object value = evaluate(rewritten, module, locals); + return value == UNKNOWN ? rewritten : new Ast.LiteralExpr(value); + } + if (expression instanceof Ast.AssignExpr assignment) { + return new Ast.AssignExpr( + rewriteExpression(assignment.target(), module, locals), + rewriteExpression(assignment.value(), module, locals)); + } + if (expression instanceof Ast.ConditionalExpr conditional) { + Ast.Expr condition = rewriteExpression(conditional.condition(), module, locals); + Object known = evaluate(condition, module, locals); + if (known instanceof Boolean value) { + return rewriteExpression( + value ? conditional.whenTrue() : conditional.whenFalse(), + module, + locals); + } + return new Ast.ConditionalExpr( + condition, + rewriteExpression(conditional.whenTrue(), module, locals), + rewriteExpression(conditional.whenFalse(), module, locals)); + } + if (expression instanceof Ast.CallExpr call) { + Ast.Expr callee = rewriteExpression(call.callee(), module, locals); + List arguments = new ArrayList<>(); + for (Ast.Expr argument : call.arguments()) { + arguments.add(rewriteExpression(argument, module, locals)); + } + Ast.CallExpr rewritten = new Ast.CallExpr( + callee, + call.typeArguments(), + call.typeArgumentsPresent(), + arguments); + + InlineTarget target = resolveInlineTarget(callee, module, locals); + if (target != null && canInlineConstantCall(target, rewritten, arguments)) { + return inlineConstantCall(target, arguments); + } + return rewritten; + } + if (expression instanceof Ast.MemberExpr member) { + return new Ast.MemberExpr( + rewriteExpression(member.receiver(), module, locals), + member.member()); + } + if (expression instanceof Ast.IndexExpr indexed) { + return new Ast.IndexExpr( + rewriteExpression(indexed.receiver(), module, locals), + rewriteExpression(indexed.index(), module, locals)); + } + if (expression instanceof Ast.NewExpr created) { + List arguments = new ArrayList<>(); + for (Ast.Expr argument : created.arguments()) { + arguments.add(rewriteExpression(argument, module, locals)); + } + return new Ast.NewExpr(created.type(), arguments); + } + if (expression instanceof Ast.AwaitExpr awaited) { + return new Ast.AwaitExpr(rewriteExpression(awaited.expression(), module, locals)); + } + if (expression instanceof Ast.ChannelOpExpr operation) { + return new Ast.ChannelOpExpr( + operation.operation(), + operation.mode(), + rewriteExpression(operation.channel(), module, locals), + rewriteExpression(operation.value(), module, locals)); + } + if (expression instanceof Ast.DynamicSelectExpr selected) { + return new Ast.DynamicSelectExpr( + selected.mode(), + selected.policy(), + rewriteExpression(selected.cases(), module, locals)); + } + if (expression instanceof Ast.ListExpr list) { + List elements = new ArrayList<>(); + for (Ast.Expr element : list.elements()) { + elements.add(rewriteExpression(element, module, locals)); + } + return new Ast.ListExpr(elements); + } + if (expression instanceof Ast.TupleExpr tuple) { + List elements = new ArrayList<>(); + for (Ast.Expr element : tuple.elements()) { + elements.add(rewriteExpression(element, module, locals)); + } + return new Ast.TupleExpr(elements); + } + if (expression instanceof Ast.ObjectExpr object) { + List fields = new ArrayList<>(); + for (Ast.ObjectField field : object.fields()) { + fields.add(field.isDynamic() + ? Ast.ObjectField.dynamic( + rewriteExpression(field.dynamicName(), module, locals), + rewriteExpression(field.value(), module, locals)) + : Ast.ObjectField.named( + field.name(), + rewriteExpression(field.value(), module, locals))); + } + return new Ast.ObjectExpr(fields); + } + if (expression instanceof Ast.LambdaExpr lambda) { + LinkedHashMap lambdaLocals = new LinkedHashMap<>(locals); + for (Ast.Param parameter : lambda.parameters()) lambdaLocals.put(parameter.name(), UNKNOWN); + return new Ast.LambdaExpr( + lambda.parameters(), + rewriteExpression(lambda.expressionBody(), module, lambdaLocals), + lambda.blockBody() == null + ? null + : rewriteStatements(lambda.blockBody(), module, lambdaLocals), + lambda.nonLexical()); + } + throw new IllegalStateException("unhandled expression " + expression.getClass().getSimpleName()); + } + + private void indexSymbols(Ast.Program program) { + for (Ast.ModuleDecl module : program.modules()) { + moduleNames.add(module.name()); + for (Ast.Decl declaration : module.declarations()) { + String name = declarationName(declaration); + if (name == null) continue; + String id = qualify(module.name(), name); + Symbol symbol = new Symbol(id, module.name(), declaration); + symbols.put(id, symbol); + String previous = uniqueSymbols.putIfAbsent(name, id); + if (previous != null && !previous.equals(id)) { + uniqueSymbols.remove(name); + ambiguousSymbols.add(name); + } + } + } + } + + private void seedRoots() { + if (options.preservePublicApi()) { + for (Symbol symbol : symbols.values()) { + if (isPublicApi(symbol.declaration())) mark(symbol.id()); + } + } + + for (String requested : options.entryPoints()) { + String root = resolveSymbol(requested, true); + if (root == null) { + throw new IllegalArgumentException( + "build entry point '" + requested + "' was not found"); + } + mark(root); + } + } + + private boolean isPublicApi(Ast.Decl declaration) { + if (declaration instanceof Ast.FunctionDecl fn) { + return fn.visibility() == Ast.Visibility.PUBLIC; + } + if (declaration instanceof Ast.FieldDecl field) { + return field.visibility() == Ast.Visibility.PUBLIC; + } + if (declaration instanceof Ast.InterfaceDecl iface) { + return iface.visibility() == Ast.Visibility.PUBLIC; + } + // Classes and aliases currently have no declaration-level visibility. + return declaration instanceof Ast.ClassDecl || declaration instanceof Ast.TypeAliasDecl; + } + + private void mark(String id) { + if (id != null && symbols.containsKey(id) && retained.add(id)) work.addLast(id); + } + + private void markRuntimeModuleMembers(String moduleName) { + for (Ast.ModuleDecl module : input.modules()) { + if (!module.name().equals(moduleName)) continue; + for (Ast.Decl declaration : module.declarations()) { + boolean exposed = declaration instanceof Ast.ClassDecl + || declaration instanceof Ast.FunctionDecl fn + && fn.visibility() == Ast.Visibility.PUBLIC + || declaration instanceof Ast.FieldDecl field + && field.visibility() == Ast.Visibility.PUBLIC; + if (!exposed) continue; + String name = declarationName(declaration); + if (name != null) mark(qualify(module.name(), name)); + } + return; + } + } + + private String resolveSymbol(String name, boolean preferRoot) { + if (symbols.containsKey(name)) return name; + if (!name.contains(".") && preferRoot) { + String root = qualify(Parser.ROOT_MODULE, name); + if (symbols.containsKey(root)) return root; + } + if (ambiguousSymbols.contains(name)) { + if (preferRoot) { + String root = qualify(Parser.ROOT_MODULE, name); + if (symbols.containsKey(root)) return root; + } + return null; + } + return uniqueSymbols.get(name); + } + + private void scanDeclaration(String module, Ast.Decl declaration) { + if (declaration instanceof Ast.FunctionDecl function) { + scanType(function.returnType()); + for (Ast.Param parameter : function.parameters()) scanType(parameter.type()); + for (Ast.Annotation annotation : function.annotations()) scanAnnotation(annotation); + LinkedHashSet locals = new LinkedHashSet<>(); + for (Ast.Param parameter : function.parameters()) locals.add(parameter.name()); + scanStatements(module, function.body(), locals); + return; + } + if (declaration instanceof Ast.FieldDecl field) { + scanType(field.type()); + scanExpression(module, field.initializer(), Set.of()); + return; + } + if (declaration instanceof Ast.ClassDecl klass) { + for (Ast.TypeRef parent : klass.parents()) scanType(parent); + for (Ast.TypeRef iface : klass.interfaces()) scanType(iface); + for (Ast.FieldDecl field : klass.fields()) { + scanType(field.type()); + scanExpression(module, field.initializer(), Set.of("self")); + } + for (Ast.MethodDecl method : klass.methods()) { + scanType(method.explicitReceiverType()); + scanType(method.returnType()); + for (Ast.Param parameter : method.parameters()) scanType(parameter.type()); + for (Ast.Annotation annotation : method.annotations()) scanAnnotation(annotation); + LinkedHashSet locals = new LinkedHashSet<>(); + locals.add("self"); + for (Ast.Param parameter : method.parameters()) locals.add(parameter.name()); + scanStatements(module, method.body(), locals); + } + return; + } + if (declaration instanceof Ast.InterfaceDecl iface) { + for (Ast.TypeRef parent : iface.parents()) scanType(parent); + for (Ast.InterfaceMember member : iface.members()) { + if (member instanceof Ast.InterfaceFunctionDecl fn) { + scanType(fn.returnType()); + for (Ast.Param parameter : fn.parameters()) scanType(parameter.type()); + } else if (member instanceof Ast.InterfaceFieldDecl field) { + scanType(field.type()); + } + } + return; + } + if (declaration instanceof Ast.TypeAliasDecl alias) { + scanType(alias.target()); + } + } + + private void scanAnnotation(Ast.Annotation annotation) { + for (Ast.TypeRef argument : annotation.arguments()) scanType(argument); + } + + private void scanType(Ast.TypeRef type) { + if (type == null) return; + if (!type.name().startsWith("$")) { + String internal = resolveSymbol(type.name(), false); + if (internal != null) mark(internal); + else externalBindings.add(firstSegment(type.name())); + } + for (Ast.TypeRef argument : type.arguments()) scanType(argument); + } + + private void scanStatements(String module, List statements, LinkedHashSet locals) { + for (Ast.Stmt statement : statements) { + if (statement instanceof Ast.BindingStmt binding) { + scanType(binding.declaredType()); + scanExpression(module, binding.initializer(), locals); + locals.add(binding.name()); + } else if (statement instanceof Ast.DestructureStmt destructure) { + scanExpression(module, destructure.initializer(), locals); + for (Ast.DestructureBinding binding : destructure.bindings()) { + if (!binding.isDiscard()) locals.add(binding.name()); + } + } else if (statement instanceof Ast.ReturnStmt returned) { + scanExpression(module, returned.value(), locals); + } else if (statement instanceof Ast.ExprStmt expression) { + scanExpression(module, expression.expression(), locals); + } else if (statement instanceof Ast.DeferStmt deferred) { + scanExpression(module, deferred.expression(), locals); + } else if (statement instanceof Ast.BlockStmt block) { + scanStatements(module, block.body(), new LinkedHashSet<>(locals)); + } else if (statement instanceof Ast.LoopStmt loop) { + scanStatements(module, loop.body(), new LinkedHashSet<>(locals)); + } else if (statement instanceof Ast.IfStmt conditional) { + for (Ast.IfBranch branch : conditional.branches()) { + scanExpression(module, branch.condition(), locals); + scanStatements(module, branch.body(), new LinkedHashSet<>(locals)); + } + scanStatements(module, conditional.elseBody(), new LinkedHashSet<>(locals)); + } else if (statement instanceof Ast.MatchStmt matched) { + scanExpression(module, matched.subject(), locals); + for (Ast.MatchArm arm : matched.arms()) { + LinkedHashSet armLocals = new LinkedHashSet<>(locals); + addPatternLocalNames(arm.pattern(), armLocals); + scanPatternTypes(arm.pattern()); + scanExpression(module, arm.guard(), armLocals); + scanStatements(module, arm.body(), armLocals); + } + } else if (statement instanceof Ast.SwitchStmt switched) { + scanExpression(module, switched.subject(), locals); + for (Ast.SwitchCase arm : switched.cases()) { + for (Ast.Expr constant : arm.constants()) scanExpression(module, constant, locals); + scanStatements(module, arm.body(), new LinkedHashSet<>(locals)); + } + scanStatements(module, switched.defaultBody(), new LinkedHashSet<>(locals)); + } else if (statement instanceof Ast.SelectStmt selected) { + for (Ast.SelectArm arm : selected.arms()) { + scanExpression(module, arm.channel(), locals); + scanExpression(module, arm.value(), locals); + LinkedHashSet armLocals = + new LinkedHashSet<>(locals); + if (arm.bindingName() != null) { + armLocals.add(arm.bindingName()); + } + scanStatements(module, arm.body(), armLocals); + } + } else if (statement instanceof Ast.TryStmt tried) { + scanStatements(module, tried.body(), new LinkedHashSet<>(locals)); + LinkedHashSet catchLocals = new LinkedHashSet<>(locals); + if (tried.errorName() != null) catchLocals.add(tried.errorName()); + scanStatements(module, tried.catchBody(), catchLocals); + scanStatements(module, tried.finallyBody(), new LinkedHashSet<>(locals)); + } else if (statement instanceof Ast.ForOfDestructureStmt loop) { + scanExpression(module, loop.iterable(), locals); + LinkedHashSet bodyLocals = new LinkedHashSet<>(locals); + for (Ast.DestructureBinding binding : loop.bindings()) { + if (!binding.isDiscard()) bodyLocals.add(binding.name()); + } + scanStatements(module, loop.body(), bodyLocals); + } else if (statement instanceof Ast.ForOfStmt loop) { + scanExpression(module, loop.iterable(), locals); + LinkedHashSet bodyLocals = new LinkedHashSet<>(locals); + bodyLocals.add(loop.bindingName()); + scanStatements(module, loop.body(), bodyLocals); + } else if (statement instanceof Ast.ForStmt loop) { + LinkedHashSet loopLocals = new LinkedHashSet<>(locals); + if (loop.initializer() != null) { + scanStatements(module, List.of(loop.initializer()), loopLocals); + } + scanExpression(module, loop.condition(), loopLocals); + scanExpression(module, loop.update(), loopLocals); + scanStatements(module, loop.body(), new LinkedHashSet<>(loopLocals)); + } + } + } + + private void scanExpression(String module, Ast.Expr expression, Set locals) { + if (expression == null || expression instanceof Ast.LiteralExpr) return; + if (expression instanceof Ast.NameExpr name) { + if (locals.contains(name.name())) return; + + // Reifying a module namespace (for example `val api = service`) + // allows later member selection through a local alias, where + // this pass can no longer recover the original module name. + // Retain the module's runtime-visible surface conservatively. + if (moduleNames.contains(name.name())) { + markRuntimeModuleMembers(name.name()); + return; + } + + String internal = resolveSymbol(name.name(), false); + if (internal != null) mark(internal); + else externalBindings.add(name.name()); + return; + } + if (expression instanceof Ast.MemberExpr member) { + if (member.receiver() instanceof Ast.NameExpr namespace + && !locals.contains(namespace.name())) { + String qualified = qualify(namespace.name(), member.member()); + if (symbols.containsKey(qualified)) { + mark(qualified); + return; + } + if (!moduleNames.contains(namespace.name())) { + externalBindings.add(namespace.name()); + } + } + scanExpression(module, member.receiver(), locals); + return; + } + if (expression instanceof Ast.TypeTestExpr test) { + scanExpression(module, test.value(), locals); + scanType(test.targetType()); + } else if (expression instanceof Ast.PatternTestExpr test) { + scanExpression(module, test.value(), locals); + scanPatternTypes(test.pattern()); + } else if (expression instanceof Ast.CastExpr cast) { + scanExpression(module, cast.value(), locals); + scanType(cast.targetType()); + } else if (expression instanceof Ast.BinaryExpr binary) { + scanExpression(module, binary.left(), locals); + scanExpression(module, binary.right(), locals); + } else if (expression instanceof Ast.UnaryExpr unary) { + scanExpression(module, unary.operand(), locals); + } else if (expression instanceof Ast.AssignExpr assignment) { + scanExpression(module, assignment.target(), locals); + scanExpression(module, assignment.value(), locals); + } else if (expression instanceof Ast.ConditionalExpr conditional) { + scanExpression(module, conditional.condition(), locals); + scanExpression(module, conditional.whenTrue(), locals); + scanExpression(module, conditional.whenFalse(), locals); + } else if (expression instanceof Ast.CallExpr call) { + scanExpression(module, call.callee(), locals); + for (Ast.TypeRef type : call.typeArguments()) scanType(type); + for (Ast.Expr argument : call.arguments()) scanExpression(module, argument, locals); + } else if (expression instanceof Ast.IndexExpr indexed) { + scanExpression(module, indexed.receiver(), locals); + scanExpression(module, indexed.index(), locals); + } else if (expression instanceof Ast.NewExpr created) { + scanType(created.type()); + for (Ast.Expr argument : created.arguments()) scanExpression(module, argument, locals); + } else if (expression instanceof Ast.AwaitExpr awaited) { + scanExpression(module, awaited.expression(), locals); + } + else if (expression instanceof Ast.ChannelOpExpr operation) { + scanExpression(module, operation.channel(), locals); + scanExpression(module, operation.value(), locals); + } else if (expression instanceof Ast.DynamicSelectExpr selected) { + scanExpression(module, selected.cases(), locals); + } else if (expression instanceof Ast.ListExpr list) { + for (Ast.Expr element : list.elements()) scanExpression(module, element, locals); + } else if (expression instanceof Ast.TupleExpr tuple) { + for (Ast.Expr element : tuple.elements()) scanExpression(module, element, locals); + } else if (expression instanceof Ast.ObjectExpr object) { + for (Ast.ObjectField field : object.fields()) { + if (field.isDynamic()) scanExpression(module, field.dynamicName(), locals); + scanExpression(module, field.value(), locals); + } + } else if (expression instanceof Ast.LambdaExpr lambda) { + LinkedHashSet nested = new LinkedHashSet<>(locals); + for (Ast.Param parameter : lambda.parameters()) { + scanType(parameter.type()); + nested.add(parameter.name()); + } + scanExpression(module, lambda.expressionBody(), nested); + if (lambda.blockBody() != null) scanStatements(module, lambda.blockBody(), nested); + } + } + + private void addPatternLocals(Ast.Pattern pattern, Map locals) { + if (pattern instanceof Ast.BindingPattern binding) { + locals.put(binding.name(), UNKNOWN); + } else if (pattern instanceof Ast.TypePattern typed && typed.binding() != null) { + locals.put(typed.binding(), UNKNOWN); + } else if (pattern instanceof Ast.ConstructorPattern constructor) { + for (Ast.Pattern nested : constructor.arguments()) addPatternLocals(nested, locals); + } + } + + private void addPatternLocalNames(Ast.Pattern pattern, Set locals) { + if (pattern instanceof Ast.BindingPattern binding) { + locals.add(binding.name()); + } else if (pattern instanceof Ast.TypePattern typed && typed.binding() != null) { + locals.add(typed.binding()); + } else if (pattern instanceof Ast.ConstructorPattern constructor) { + for (Ast.Pattern nested : constructor.arguments()) addPatternLocalNames(nested, locals); + } + } + + private void scanPatternTypes(Ast.Pattern pattern) { + if (pattern instanceof Ast.TypePattern typed) { + scanType(typed.type()); + } else if (pattern instanceof Ast.ConstructorPattern constructor) { + for (Ast.Pattern nested : constructor.arguments()) scanPatternTypes(nested); + } + } + + private Ast.Program pruneProgram(Ast.Program program) { + List modules = new ArrayList<>(); + for (Ast.ModuleDecl module : program.modules()) { + List declarations = new ArrayList<>(); + for (Ast.Decl declaration : module.declarations()) { + String name = declarationName(declaration); + if (name != null && retained.contains(qualify(module.name(), name))) { + declarations.add(declaration); + } + } + if (!declarations.isEmpty()) { + modules.add(new Ast.ModuleDecl(module.name(), module.annotations(), declarations)); + } + } + + List imports = new ArrayList<>(); + for (Ast.ImportDecl imported : program.imports()) { + if (importIsUsed(imported)) imports.add(imported); + } + return new Ast.Program(program.namespace(), imports, modules); + } + + private boolean importIsUsed(Ast.ImportDecl imported) { + if (imported.namespace() != null && !imported.namespace().isBlank()) { + return externalBindings.contains(imported.namespace()); + } + for (String name : imported.names()) { + if (externalBindings.contains(name)) return true; + } + return false; + } + + private String resolveConstantReference(String name, String module) { + String local = qualify(module, name); + if (constants.containsKey(local)) return local; + if (ambiguousConstants.contains(name)) return null; + return uniqueConstants.get(name); + } + + private Object parseDefine( + String raw, + Ast.TypeRef declaredType, + Object original, + String displayName) { + String type = declaredType == null ? null : declaredType.name(); + try { + if ("bool".equals(type) || original instanceof Boolean) { + if (raw.equalsIgnoreCase("true")) return Boolean.TRUE; + if (raw.equalsIgnoreCase("false")) return Boolean.FALSE; + throw new IllegalArgumentException("expected true or false"); + } + if ("int".equals(type) || "uint".equals(type) || original instanceof Long) { + return Long.parseLong(raw.replace("_", "")); + } + if ("float".equals(type) || "decimal".equals(type) || original instanceof Double) { + return Double.parseDouble(raw.replace("_", "")); + } + if ("String".equals(type) || original instanceof String || type == null) { + if (raw.length() >= 2 + && ((raw.startsWith("\"") && raw.endsWith("\"")) + || (raw.startsWith("'") && raw.endsWith("'")))) { + return raw.substring(1, raw.length() - 1); + } + return raw; + } + } catch (RuntimeException error) { + throw new IllegalArgumentException( + "invalid value for build define '" + displayName + "': " + error.getMessage(), + error); + } + throw new IllegalArgumentException( + "build define '" + displayName + "' has unsupported const type '" + type + "'"); + } + + private static String declarationName(Ast.Decl declaration) { + if (declaration instanceof Ast.FunctionDecl function) return function.name(); + if (declaration instanceof Ast.ClassDecl klass) return klass.name(); + if (declaration instanceof Ast.InterfaceDecl iface) return iface.name(); + if (declaration instanceof Ast.FieldDecl field) return field.name(); + if (declaration instanceof Ast.TypeAliasDecl alias) return alias.name(); + return null; + } + + private static String qualify(String module, String name) { + return module + "." + name; + } + + private static String moduleOf(String qualified) { + int dot = qualified.indexOf('.'); + return dot < 0 ? Parser.ROOT_MODULE : qualified.substring(0, dot); + } + + private static String firstSegment(String name) { + int dot = name.indexOf('.'); + return dot < 0 ? name : name.substring(0, dot); + } + } +} diff --git a/src/main/java/dev/oreslang/config/OresProjectConfig.java b/src/main/java/dev/oreslang/config/OresProjectConfig.java new file mode 100644 index 00000000..8c9bb7c7 --- /dev/null +++ b/src/main/java/dev/oreslang/config/OresProjectConfig.java @@ -0,0 +1,266 @@ +package dev.oreslang.config; + +import dev.oreslang.imports.ImportRules; +import org.tomlj.Toml; +import org.tomlj.TomlArray; +import org.tomlj.TomlParseResult; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Project-level Oreslang compiler configuration. + * + *

The nearest .oreslangc.cfg.toml found by walking upward from the source + * file (or current working directory) owns the project. Manifest paths are + * resolved relative to [project].root. Ambient ORESLANG_PATH entries are + * process-relative and are searched after project-local roots.

+ */ +public final class OresProjectConfig { + public static final String MANIFEST_NAME = ".oreslangc.cfg.toml"; + public static final String ENV_ORESLANG_PATH = "ORESLANG_PATH"; + public static final String SCHEMA_VERSION = "1"; + + private final Path manifestPath; + private final Path projectRoot; + private final String projectName; + private final String projectVersion; + private final List sourceRoots; + private final List importPaths; + private final List environmentPaths; + private final Path mainEntrypoint; + + private OresProjectConfig( + Path manifestPath, + Path projectRoot, + String projectName, + String projectVersion, + List sourceRoots, + List importPaths, + List environmentPaths, + Path mainEntrypoint) { + this.manifestPath = manifestPath; + this.projectRoot = projectRoot; + this.projectName = projectName; + this.projectVersion = projectVersion; + this.sourceRoots = List.copyOf(sourceRoots); + this.importPaths = List.copyOf(importPaths); + this.environmentPaths = List.copyOf(environmentPaths); + this.mainEntrypoint = mainEntrypoint; + } + + public static OresProjectConfig discover(Path start, Map environment) throws IOException { + if (start == null) throw new IllegalArgumentException("config discovery start path cannot be null"); + if (environment == null) throw new IllegalArgumentException("environment cannot be null"); + + Path searchFrom = directoryFor(start); + for (Path cursor = searchFrom; cursor != null; cursor = cursor.getParent()) { + Path candidate = cursor.resolve(MANIFEST_NAME); + if (Files.isRegularFile(candidate)) return load(candidate, environment); + } + + Path root = searchFrom.toAbsolutePath().normalize(); + return new OresProjectConfig( + null, + root, + null, + null, + List.of(root), + List.of(), + parseEnvironmentPaths(environment), + null); + } + + public static OresProjectConfig load(Path manifest, Map environment) throws IOException { + if (manifest == null) throw new IllegalArgumentException("manifest path cannot be null"); + if (environment == null) throw new IllegalArgumentException("environment cannot be null"); + + Path absoluteManifest = manifest.toAbsolutePath().normalize(); + if (!Files.isRegularFile(absoluteManifest)) { + throw new IllegalArgumentException("not an Oreslang project manifest: " + absoluteManifest); + } + + TomlParseResult toml = Toml.parse(absoluteManifest); + if (!toml.errors().isEmpty()) { + throw new IllegalArgumentException( + "invalid " + MANIFEST_NAME + ": " + toml.errors().getFirst()); + } + + String schemaVersion = toml.getString("schema_version"); + if (schemaVersion == null || !SCHEMA_VERSION.equals(schemaVersion)) { + throw new IllegalArgumentException( + MANIFEST_NAME + " requires schema_version = \"" + SCHEMA_VERSION + "\""); + } + + Path manifestDir = absoluteManifest.getParent(); + String rootText = defaultString(toml.getString("project.root"), "."); + Path projectRoot = resolvePath(manifestDir, rootText); + + List sourceRootValues = stringArray(toml, "source.roots"); + if (sourceRootValues.isEmpty()) sourceRootValues = List.of("."); + List sourceRoots = resolvePaths(projectRoot, sourceRootValues); + List importPaths = resolvePaths(projectRoot, stringArray(toml, "source.import_paths")); + + String mainText = toml.getString("entrypoints.main"); + Path mainEntrypoint = mainText == null ? null : resolvePath(projectRoot, mainText); + + return new OresProjectConfig( + absoluteManifest, + projectRoot, + toml.getString("project.name"), + toml.getString("project.version"), + sourceRoots, + importPaths, + parseEnvironmentPaths(environment), + mainEntrypoint); + } + + public Optional manifestPath() { return Optional.ofNullable(manifestPath); } + public Path projectRoot() { return projectRoot; } + public Optional projectName() { return Optional.ofNullable(projectName); } + public Optional projectVersion() { return Optional.ofNullable(projectVersion); } + public List sourceRoots() { return sourceRoots; } + public List importPaths() { return importPaths; } + public List environmentPaths() { return environmentPaths; } + public Optional mainEntrypoint() { return Optional.ofNullable(mainEntrypoint); } + + /** + * Ordered import search roots. Project-local paths intentionally win over + * ambient ORESLANG_PATH entries for deterministic/reproducible builds. + */ + public List searchRoots() { + LinkedHashSet ordered = new LinkedHashSet<>(); + ordered.addAll(sourceRoots); + ordered.addAll(importPaths); + ordered.addAll(environmentPaths); + return List.copyOf(ordered); + } + + /** + * Resolve one Oreslang filesystem import. + * + *
    + *
  • java: imports are not filesystem imports.
  • + *
  • ./ and ../ imports resolve only relative to the importing file.
  • + *
  • absolute imports resolve directly.
  • + *
  • bare imports search source.roots, source.import_paths, then ORESLANG_PATH.
  • + *
+ */ + public Optional resolveImport(Path importer, String importPath) { + if (importer == null) throw new IllegalArgumentException("importer path cannot be null"); + if (importPath == null || importPath.isBlank()) { + throw new IllegalArgumentException("import path cannot be blank"); + } + if (ImportRules.isJavaPath(importPath)) return Optional.empty(); + + String unix = importPath.replace('\\', '/'); + Path raw = Path.of(unix); + if (raw.isAbsolute()) return existingSource(raw); + + if (unix.startsWith(".")) { + Path parent = importer.toAbsolutePath().normalize().getParent(); + if (parent == null) parent = Path.of("").toAbsolutePath().normalize(); + return existingSource(parent.resolve(raw).normalize()); + } + + for (Path root : searchRoots()) { + Path normalizedRoot = root.toAbsolutePath().normalize(); + Path candidate = normalizedRoot.resolve(raw).normalize(); + if (!candidate.startsWith(normalizedRoot)) { + throw new IllegalArgumentException( + "bare import '" + importPath + "' escapes configured search root '" + normalizedRoot + "'"); + } + Optional resolved = existingSource(candidate); + if (resolved.isPresent()) return resolved; + } + return Optional.empty(); + } + + private static Optional existingSource(Path candidate) { + Path normalized = candidate.toAbsolutePath().normalize(); + if (Files.isRegularFile(normalized)) return Optional.of(normalized); + + String text = normalized.toString().toLowerCase(); + if (text.endsWith(".ores") || text.endsWith(".java")) return Optional.empty(); + + Path ores = Path.of(normalized.toString() + ".ores"); + if (Files.isRegularFile(ores)) return Optional.of(ores.toAbsolutePath().normalize()); + + Path java = Path.of(normalized.toString() + ".java"); + if (Files.isRegularFile(java)) return Optional.of(java.toAbsolutePath().normalize()); + + return Optional.empty(); + } + + private static Path directoryFor(Path start) { + Path absolute = start.toAbsolutePath().normalize(); + if (Files.isDirectory(absolute)) return absolute; + Path parent = absolute.getParent(); + return parent == null ? Path.of("").toAbsolutePath().normalize() : parent; + } + + private static List parseEnvironmentPaths(Map environment) { + String raw = environment.get(ENV_ORESLANG_PATH); + if (raw == null || raw.isBlank()) return List.of(); + + LinkedHashSet paths = new LinkedHashSet<>(); + String[] parts = raw.split(java.util.regex.Pattern.quote(File.pathSeparator), -1); + Path cwd = Path.of("").toAbsolutePath().normalize(); + for (String part : parts) { + String trimmed = part.trim(); + // Unlike PATH, an empty entry never means the current directory. + // That avoids accidental dependency injection via leading/trailing + // separators or "::". + if (trimmed.isEmpty()) continue; + Path path = Path.of(trimmed); + if (!path.isAbsolute()) path = cwd.resolve(path); + paths.add(path.toAbsolutePath().normalize()); + } + return List.copyOf(paths); + } + + private static List resolvePaths(Path base, List values) { + ArrayList paths = new ArrayList<>(values.size()); + LinkedHashSet seen = new LinkedHashSet<>(); + for (String value : values) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("manifest path list entries cannot be blank"); + } + Path path = resolvePath(base, value); + if (seen.add(path)) paths.add(path); + } + return List.copyOf(paths); + } + + private static Path resolvePath(Path base, String value) { + Path path = Path.of(value); + if (!path.isAbsolute()) path = base.resolve(path); + return path.toAbsolutePath().normalize(); + } + + private static List stringArray(TomlParseResult toml, String key) { + TomlArray array = toml.getArray(key); + if (array == null) return List.of(); + + ArrayList result = new ArrayList<>((int) array.size()); + for (int i = 0; i < array.size(); i++) { + Object value = array.get(i); + if (!(value instanceof String text)) { + throw new IllegalArgumentException(MANIFEST_NAME + " key '" + key + "' must be an array of strings"); + } + result.add(text); + } + return List.copyOf(result); + } + + private static String defaultString(String value, String fallback) { + return value == null ? fallback : value; + } +} diff --git a/src/main/java/dev/oreslang/imports/ImportRules.java b/src/main/java/dev/oreslang/imports/ImportRules.java new file mode 100644 index 00000000..fb52b591 --- /dev/null +++ b/src/main/java/dev/oreslang/imports/ImportRules.java @@ -0,0 +1,109 @@ +package dev.oreslang.imports; + +import dev.oreslang.ast.Ast; + +import java.util.List; +import java.util.regex.Pattern; + +/** Shared invariants for source imports and capability-gated Java host imports. */ +public final class ImportRules { + public static final String JAVA_PREFIX = "java:"; + private static final Pattern JAVA_BINARY_NAME = + Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)*"); + + private ImportRules() { } + + public static boolean isJavaPath(String path) { + return path != null && path.startsWith(JAVA_PREFIX); + } + + public static String javaClassName(String path) { + if (!isJavaPath(path)) throw new IllegalArgumentException("not a Java host import path: " + path); + String className = path.substring(JAVA_PREFIX.length()); + if (!JAVA_BINARY_NAME.matcher(className).matches()) { + throw new IllegalArgumentException("invalid Java host class name '" + className + "'"); + } + return className; + } + + public static String javaSimpleName(String path) { + String className = javaClassName(path); + int dot = className.lastIndexOf('.'); + String simple = dot < 0 ? className : className.substring(dot + 1); + int nested = simple.lastIndexOf('$'); + return nested < 0 ? simple : simple.substring(nested + 1); + } + + public static String localName(Ast.ImportDecl imported, String sourceName) { + if (!imported.wildcard() && imported.namespace() != null) { + if (imported.names().size() != 1 || !imported.names().getFirst().equals(sourceName)) { + throw new IllegalArgumentException("named import alias does not match its selected source name"); + } + return imported.namespace(); + } + return sourceName; + } + + public static List exposedBindings(Ast.ImportDecl imported) { + if (imported.wildcard()) return List.of(imported.namespace()); + return imported.names().stream().map(name -> localName(imported, name)).toList(); + } + + public static boolean isTypeOnlyKind(Ast.ImportKind kind) { + return switch (kind) { + case ACTOR, CLASS, INTERFACE, TRAIT, STRUCT, TYPE, TYPES -> true; + case MODULE, FUNCTION, ALL -> false; + }; + } + + public static void validate(Ast.ImportDecl imported) { + if (imported == null) throw new IllegalArgumentException("import declaration cannot be null"); + if (imported.path() == null || imported.path().isBlank()) { + throw new IllegalArgumentException("import path cannot be empty"); + } + if (imported.path().length() > 4096 || imported.path().chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("import path contains invalid control characters or is too long"); + } + + if (imported.wildcard()) { + if (imported.namespace() == null || imported.namespace().isBlank()) { + throw new IllegalArgumentException("wildcard imports require a namespace alias"); + } + if (!imported.names().isEmpty()) { + throw new IllegalArgumentException("wildcard imports cannot also contain named selections"); + } + } else { + if (imported.kind() == Ast.ImportKind.ALL || imported.names().isEmpty()) { + throw new IllegalArgumentException("named import must select at least one name"); + } + if (imported.namespace() != null) { + if (imported.namespace().isBlank()) { + throw new IllegalArgumentException("named import alias cannot be blank"); + } + if (imported.names().size() != 1) { + throw new IllegalArgumentException("named import aliases require exactly one selected name"); + } + } + } + + if (!isJavaPath(imported.path())) return; + + String simpleName = javaSimpleName(imported.path()); + if (imported.kind() != Ast.ImportKind.CLASS + && imported.kind() != Ast.ImportKind.FUNCTION + && imported.kind() != Ast.ImportKind.ALL) { + throw new IllegalArgumentException( + "Java host imports support only class, fnc, or * selectors; got " + + imported.kind().name().toLowerCase()); + } + if (imported.kind() == Ast.ImportKind.CLASS) { + if (imported.wildcard() || imported.names().size() != 1) { + throw new IllegalArgumentException("Java class imports must select exactly one class name"); + } + if (!imported.names().getFirst().equals(simpleName)) { + throw new IllegalArgumentException("Java class import name '" + imported.names().getFirst() + + "' must match host class simple name '" + simpleName + "'"); + } + } + } +} diff --git a/src/main/java/dev/oreslang/interop/MixedInteropBridge.java b/src/main/java/dev/oreslang/interop/MixedInteropBridge.java new file mode 100644 index 00000000..8c298407 --- /dev/null +++ b/src/main/java/dev/oreslang/interop/MixedInteropBridge.java @@ -0,0 +1,48 @@ +package dev.oreslang.interop; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Host-side bridge used by generated Java facades in mixed source files. */ +public final class MixedInteropBridge { + @FunctionalInterface + public interface Invoker { + Object invoke(String functionName, Object[] arguments); + } + + public interface Scope extends AutoCloseable { + @Override void close(); + } + + private static final ThreadLocal> CURRENT = new ThreadLocal<>(); + + private MixedInteropBridge() { } + + public static Scope open(Map invokers) { + Map next = Map.copyOf(new LinkedHashMap<>(invokers)); + Map previous = CURRENT.get(); + CURRENT.set(next); + return () -> { + if (previous == null) CURRENT.remove(); + else CURRENT.set(previous); + }; + } + + public static Object invoke(String unitId, String functionName, Object... arguments) { + if (unitId == null || unitId.isBlank()) { + throw new IllegalArgumentException("mixed bridge unit id cannot be blank"); + } + if (functionName == null || functionName.isBlank()) { + throw new IllegalArgumentException("mixed bridge function name cannot be blank"); + } + Map scope = CURRENT.get(); + if (scope == null) { + throw new IllegalStateException("Oreslang mixed bridge is not active on this thread"); + } + Invoker invoker = scope.get(unitId); + if (invoker == null) { + throw new IllegalStateException("no linked Oreslang unit for mixed bridge path: " + unitId); + } + return invoker.invoke(functionName, arguments == null ? new Object[0] : arguments.clone()); + } +} diff --git a/src/main/java/dev/oreslang/interop/MixedJavaCompiler.java b/src/main/java/dev/oreslang/interop/MixedJavaCompiler.java new file mode 100644 index 00000000..11705078 --- /dev/null +++ b/src/main/java/dev/oreslang/interop/MixedJavaCompiler.java @@ -0,0 +1,250 @@ +package dev.oreslang.interop; + +import dev.oreslang.ast.Ast; +import dev.oreslang.imports.ImportRules; + +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** Compiles trusted Java source islands and generated Java-to-Ores facades in JIT/source mode. */ +public final class MixedJavaCompiler { + private static final Set JAVA_KEYWORDS = Set.of( + "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const", + "continue", "default", "do", "double", "else", "enum", "extends", "final", "finally", "float", + "for", "goto", "if", "implements", "import", "instanceof", "int", "interface", "long", "native", + "new", "package", "private", "protected", "public", "return", "short", "static", "strictfp", "super", + "switch", "synchronized", "this", "throw", "throws", "transient", "try", "void", "volatile", "while", + "true", "false", "null", "record", "sealed", "permits", "yield", "var"); + + private MixedJavaCompiler() { } + + public static Compilation compile(List units, Map programs) throws IOException { + List javaUnits = units.stream().filter(MixedSourceUnit::hasJavaSource).toList(); + if (javaUnits.isEmpty()) return Compilation.empty(); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new IllegalStateException("mixed Java/Ores source requires a JDK with javax.tools.JavaCompiler available"); + } + + Path output = Files.createTempDirectory("oreslang-mixed-java-"); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + List sources = new ArrayList<>(); + LinkedHashSet hostClasses = new LinkedHashSet<>(); + LinkedHashMap mainClasses = new LinkedHashMap<>(); + Map facadePackageOwners = new HashMap<>(); + + for (MixedSourceUnit unit : javaUnits) { + for (MixedSourceUnit.JavaSource source : unit.javaSources()) { + sources.add(new StringJavaSource(source.className(), source.sourceText())); + } + for (MixedSourceUnit.JavaBinding binding : unit.javaBindings()) hostClasses.add(binding.binaryName()); + if (unit.primaryLanguage() == MixedSourceUnit.PrimaryLanguage.JAVA) { + mainClasses.put(unit.unitId(), unit.primaryJavaClassName()); + } + if (unit.hasOresSource()) { + String packageName = unit.javaPackage(); + String owner = facadePackageOwners.putIfAbsent(packageName, unit.unitId()); + if (owner != null && !owner.equals(unit.unitId())) { + throw new IllegalArgumentException("multiple mixed source units in Java package '" + packageName + + "' would generate the same Ores bridge class; split them into distinct Java packages"); + } + Ast.Program program = programs.get(unit.unitId()); + if (program == null) throw new IllegalStateException("missing parsed Oreslang program for " + unit.unitId()); + String facadeClass = packageName.isBlank() ? "Ores" : packageName + ".Ores"; + sources.add(new StringJavaSource(facadeClass, generateFacade(unit, program))); + } + } + + try (StandardJavaFileManager manager = compiler.getStandardFileManager(diagnostics, Locale.ROOT, StandardCharsets.UTF_8)) { + List options = new ArrayList<>(); + options.add("--release"); options.add("21"); + options.add("-proc:none"); + options.add("-d"); options.add(output.toString()); + String classPath = System.getProperty("java.class.path"); + if (classPath != null && !classPath.isBlank()) { + options.add("-classpath"); options.add(classPath); + } + Boolean ok = compiler.getTask(null, manager, diagnostics, options, null, sources).call(); + if (!Boolean.TRUE.equals(ok)) throw new IllegalArgumentException(formatDiagnostics(diagnostics)); + } catch (RuntimeException | IOException failure) { + deleteTree(output); + throw failure; + } + + URLClassLoader loader = new URLClassLoader(new URL[]{output.toUri().toURL()}, MixedJavaCompiler.class.getClassLoader()); + try { + for (String className : hostClasses) Class.forName(className, false, loader); + } catch (Throwable failure) { + try { loader.close(); } catch (IOException ignored) { } + deleteTree(output); + throw new IllegalStateException("compiled mixed Java class could not be loaded", failure); + } + return new Compilation(output, loader, Set.copyOf(hostClasses), Map.copyOf(mainClasses)); + } + + private static String generateFacade(MixedSourceUnit unit, Ast.Program program) { + StringBuilder out = new StringBuilder(); + if (!unit.javaPackage().isBlank()) out.append("package ").append(unit.javaPackage()).append(";\n\n"); + out.append("public final class Ores {\n") + .append(" private Ores() {}\n") + .append(" public static Object call(String name, Object... args) {\n") + .append(" return dev.oreslang.interop.MixedInteropBridge.invoke(\"") + .append(javaString(unit.unitId())).append("\", name, args);\n") + .append(" }\n\n"); + + Map importedJavaTypes = importedJavaTypes(program); + Map> publicByName = new LinkedHashMap<>(); + for (Ast.ModuleDecl module : program.modules()) { + for (Ast.Decl declaration : module.declarations()) { + if (declaration instanceof Ast.FunctionDecl fn && fn.visibility() == Ast.Visibility.PUBLIC) { + publicByName.computeIfAbsent(fn.name(), ignored -> new ArrayList<>()).add(fn); + } + } + } + + for (Map.Entry> entry : publicByName.entrySet()) { + if (entry.getValue().size() != 1) continue; + Ast.FunctionDecl fn = entry.getValue().getFirst(); + if (!javaIdentifier(fn.name()) || fn.name().equals("call")) continue; + String returnType = javaReturnType(fn.returnType(), importedJavaTypes); + out.append(" public static ").append(returnType).append(' ').append(fn.name()).append('('); + for (int i = 0; i < fn.parameters().size(); i++) { + if (i > 0) out.append(", "); + out.append("Object p").append(i); + } + out.append(") {\n "); + String call = "call(\"" + javaString(fn.name()) + "\""; + for (int i = 0; i < fn.parameters().size(); i++) call += ", p" + i; + call += ")"; + appendReturn(out, returnType, call); + out.append("\n }\n\n"); + } + out.append("}\n"); + return out.toString(); + } + + private static void appendReturn(StringBuilder out, String returnType, String call) { + switch (returnType) { + case "void" -> out.append(call).append(';'); + case "int" -> out.append("return ((Number) ").append(call).append(").intValue();"); + case "long" -> out.append("return ((Number) ").append(call).append(").longValue();"); + case "double" -> out.append("return ((Number) ").append(call).append(").doubleValue();"); + case "boolean" -> out.append("return (Boolean) ").append(call).append(';'); + case "String" -> out.append("return (String) ").append(call).append(';'); + case "Object" -> out.append("return ").append(call).append(';'); + default -> out.append("return (").append(returnType).append(") ").append(call).append(';'); + } + } + + private static Map importedJavaTypes(Ast.Program program) { + Map result = new HashMap<>(); + for (Ast.ImportDecl imported : program.imports()) { + if (imported.kind() != Ast.ImportKind.CLASS || !ImportRules.isJavaPath(imported.path()) || imported.wildcard()) continue; + String sourceName = imported.names().getFirst(); + result.put(ImportRules.localName(imported, sourceName), ImportRules.javaClassName(imported.path())); + } + return result; + } + + private static String javaReturnType(Ast.TypeRef type, Map importedJavaTypes) { + if (type == null) return "Object"; + return switch (type.name()) { + case "void" -> "void"; + case "int" -> "int"; + case "uint" -> "long"; + case "float", "decimal" -> "double"; + case "bool" -> "boolean"; + case "String" -> "String"; + default -> importedJavaTypes.getOrDefault(type.name(), "Object"); + }; + } + + private static boolean javaIdentifier(String value) { + if (value == null || value.isBlank() || JAVA_KEYWORDS.contains(value)) return false; + if (!Character.isJavaIdentifierStart(value.charAt(0))) return false; + for (int i = 1; i < value.length(); i++) if (!Character.isJavaIdentifierPart(value.charAt(i))) return false; + return true; + } + + private static String javaString(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "\\r").replace("\n", "\\n"); + } + + private static String formatDiagnostics(DiagnosticCollector diagnostics) { + StringBuilder out = new StringBuilder("mixed Java source compilation failed"); + for (Diagnostic diagnostic : diagnostics.getDiagnostics()) { + out.append("\n"); + if (diagnostic.getSource() != null) out.append(diagnostic.getSource().getName()).append(':'); + if (diagnostic.getLineNumber() >= 0) out.append(diagnostic.getLineNumber()).append(':').append(diagnostic.getColumnNumber()).append(':'); + out.append(' ').append(diagnostic.getKind().name().toLowerCase(Locale.ROOT)).append(": ") + .append(diagnostic.getMessage(Locale.ROOT)); + } + return out.toString(); + } + + private static void deleteTree(Path root) { + if (root == null || !Files.exists(root)) return; + try (var stream = Files.walk(root)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { Files.deleteIfExists(path); } catch (IOException ignored) { } + }); + } catch (IOException ignored) { } + } + + private static final class StringJavaSource extends SimpleJavaFileObject { + private final String source; + private StringJavaSource(String binaryName, String source) { + super(URI.create("string:///" + binaryName.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); + this.source = source; + } + @Override public CharSequence getCharContent(boolean ignoreEncodingErrors) { return source; } + } + + public static final class Compilation implements AutoCloseable { + private final Path outputDirectory; + private final URLClassLoader classLoader; + private final Set hostClasses; + private final Map mainClasses; + + private Compilation(Path outputDirectory, URLClassLoader classLoader, Set hostClasses, Map mainClasses) { + this.outputDirectory = outputDirectory; + this.classLoader = classLoader; + this.hostClasses = hostClasses; + this.mainClasses = mainClasses; + } + + private static Compilation empty() { return new Compilation(null, null, Set.of(), Map.of()); } + public ClassLoader classLoader() { return classLoader == null ? MixedJavaCompiler.class.getClassLoader() : classLoader; } + public Set hostClasses() { return hostClasses; } + public String mainClass(String unitId) { return mainClasses.get(unitId); } + + @Override public void close() { + if (classLoader != null) { + try { classLoader.close(); } catch (IOException ignored) { } + } + deleteTree(outputDirectory); + } + } +} diff --git a/src/main/java/dev/oreslang/interop/MixedSourceUnit.java b/src/main/java/dev/oreslang/interop/MixedSourceUnit.java new file mode 100644 index 00000000..40c4cdb6 --- /dev/null +++ b/src/main/java/dev/oreslang/interop/MixedSourceUnit.java @@ -0,0 +1,667 @@ +package dev.oreslang.interop; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Splits an extended source file into its Oreslang and Java views without + * inventing a source-level module/package identity for Oreslang. The canonical + * filesystem path remains the code-unit identity. + * + * In .ores files the two Java forms have deliberately different semantics: + * + * java { ... } declaration island; compiled but never executed merely + * because the declaration exists. + * + * do java { ... } executable island; lowered at that exact statement + * position to a generated java.lang.Runnable whose run() + * method contains the Java statements. + * + * Declaration islands are source-level declarations only. Executable islands + * are statement-level only. This separation prevents an inert declaration from + * accidentally becoming an import-time/init-time side effect. + */ +public final class MixedSourceUnit { + public enum PrimaryLanguage { ORES, JAVA } + public enum IslandKind { DECLARATION, EXECUTION, EMBEDDED_ORES } + + public record Island( + String language, + IslandKind kind, + int keywordStart, + int bodyStart, + int bodyEnd, + int blockEnd, + int sourceBraceDepth, + String body) { } + + public record JavaBinding(String simpleName, String binaryName) { } + public record JavaSource(String className, String sourceText) { } + + private static final Pattern JAVA_PACKAGE = Pattern.compile( + "(?m)^\\s*package\\s+([A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)*)\\s*;"); + private static final Pattern PACKAGE_ANYWHERE = Pattern.compile("(?m)^\\s*package\\s+"); + private static final String GENERATED_DO_PREFIX = "__OresJavaDo"; + + private final String unitId; + private final PrimaryLanguage primaryLanguage; + private final String rawSource; + private final String oresSource; + private final String javaSource; + private final String javaPackage; + private final List foreignIslands; + private final List javaBindings; + private final List javaSources; + + private MixedSourceUnit( + String unitId, + PrimaryLanguage primaryLanguage, + String rawSource, + String oresSource, + String javaSource, + String javaPackage, + List foreignIslands, + List javaBindings, + List javaSources) { + this.unitId = unitId; + this.primaryLanguage = primaryLanguage; + this.rawSource = rawSource; + this.oresSource = oresSource; + this.javaSource = javaSource; + this.javaPackage = javaPackage; + this.foreignIslands = List.copyOf(foreignIslands); + this.javaBindings = List.copyOf(javaBindings); + this.javaSources = List.copyOf(javaSources); + } + + public static MixedSourceUnit parse(Path path, String source) { + Path normalized = path.toAbsolutePath().normalize(); + return parse(normalized.toString().replace('\\', '/'), normalized.getFileName().toString(), source); + } + + public static MixedSourceUnit parse(String unitId, String fileName, String source) { + if (unitId == null || unitId.isBlank()) { + throw new IllegalArgumentException("mixed source unit id cannot be blank"); + } + if (fileName == null || fileName.isBlank()) { + throw new IllegalArgumentException("mixed source filename cannot be blank"); + } + + String raw = source == null ? "" : source; + String lower = fileName.toLowerCase(Locale.ROOT); + PrimaryLanguage primary; + if (lower.endsWith(".ores")) primary = PrimaryLanguage.ORES; + else if (lower.endsWith(".java")) primary = PrimaryLanguage.JAVA; + else throw new IllegalArgumentException("mixed source must use .ores or .java: " + fileName); + + if (primary == PrimaryLanguage.ORES) { + return parseOresPrimary(unitId, raw); + } + return parseJavaPrimary(unitId, fileName, raw); + } + + private static MixedSourceUnit parseOresPrimary(String unitId, String raw) { + String generatedPackage = generatedPackage(unitId); + List islands = findOresJavaIslands(raw); + List javaSources = new ArrayList<>(); + List bindings = new ArrayList<>(); + Map names = new LinkedHashMap<>(); + Map replacements = new LinkedHashMap<>(); + + int executableIndex = 0; + for (Island island : islands) { + if (island.kind() == IslandKind.DECLARATION) { + if (island.sourceBraceDepth() != 0) { + throw new IllegalArgumentException( + "java { ... } is a declaration island and must appear at Oreslang source declaration scope; " + + "use do java { ... } inside executable code"); + } + JavaIslandType type = parseJavaDeclarationIsland(island.body(), generatedPackage); + reserveJavaName(names, type.simpleName(), "Java declaration island"); + javaSources.add(new JavaSource(type.binaryName(), type.sourceText())); + bindings.add(new JavaBinding(type.simpleName(), type.binaryName())); + replacements.put(island, ""); + continue; + } + + if (island.kind() == IslandKind.EXECUTION) { + if (island.sourceBraceDepth() == 0) { + throw new IllegalArgumentException( + "do java { ... } is executable and must appear inside an Oreslang callable/method body"); + } + String simpleName = GENERATED_DO_PREFIX + executableIndex++; + reserveJavaName(names, simpleName, "generated do java runnable"); + String binaryName = generatedPackage + "." + simpleName; + javaSources.add(new JavaSource( + binaryName, + runnableSource(generatedPackage, simpleName, island.body()))); + bindings.add(new JavaBinding(simpleName, binaryName)); + replacements.put(island, "new " + simpleName + "().run();"); + continue; + } + + throw new IllegalStateException("unexpected island kind in .ores source: " + island.kind()); + } + + String ores = replaceOresJavaIslands(raw, islands, replacements); + ores = injectJavaImports(ores, bindings, raw, !islands.isEmpty()); + return new MixedSourceUnit( + unitId, + PrimaryLanguage.ORES, + raw, + ores, + "", + generatedPackage, + islands, + bindings, + javaSources); + } + + private static MixedSourceUnit parseJavaPrimary(String unitId, String fileName, String raw) { + List islands = findSimpleIslands(raw, "ores", IslandKind.EMBEDDED_ORES); + String javaPrimary = maskBlocks(raw, islands); + String pkg = detectJavaPackage(javaPrimary); + List types = topLevelTypes(javaPrimary); + List bindings = new ArrayList<>(); + for (TopLevelType type : types) { + if (!type.isPublic()) continue; + String binary = pkg.isBlank() ? type.name() : pkg + "." + type.name(); + bindings.add(new JavaBinding(type.name(), binary)); + } + + String ores = extractIslandBodies(raw, islands); + ores = injectJavaImports(ores, bindings, raw, !islands.isEmpty()); + + String primaryClass = Path.of(fileName).getFileName().toString(); + primaryClass = primaryClass.substring(0, primaryClass.length() - ".java".length()); + List sources = List.of(new JavaSource( + pkg.isBlank() ? primaryClass : pkg + "." + primaryClass, + javaPrimary)); + + return new MixedSourceUnit( + unitId, + PrimaryLanguage.JAVA, + raw, + ores, + javaPrimary, + pkg, + islands, + bindings, + sources); + } + + public String unitId() { return unitId; } + public PrimaryLanguage primaryLanguage() { return primaryLanguage; } + public String rawSource() { return rawSource; } + public String oresSource() { return oresSource; } + public String javaSource() { return javaSource; } + public String javaPackage() { return javaPackage; } + public List foreignIslands() { return foreignIslands; } + public List javaBindings() { return javaBindings; } + public List javaSources() { return javaSources; } + public boolean hasOresSource() { return primaryLanguage == PrimaryLanguage.ORES || !foreignIslands.isEmpty(); } + public boolean hasJavaSource() { return primaryLanguage == PrimaryLanguage.JAVA || !foreignIslands.isEmpty(); } + public boolean mixed() { return !foreignIslands.isEmpty(); } + + public String primaryJavaClassName() { + if (primaryLanguage != PrimaryLanguage.JAVA) return null; + return javaSources.getFirst().className(); + } + + private static void reserveJavaName(Map names, String simpleName, String owner) { + if (simpleName.startsWith(GENERATED_DO_PREFIX) && !owner.startsWith("generated")) { + throw new IllegalArgumentException( + "Java declaration type prefix '" + GENERATED_DO_PREFIX + "' is reserved for do java lowering"); + } + if (names.putIfAbsent(simpleName, Boolean.TRUE) != null) { + throw new IllegalArgumentException("duplicate generated/mixed Java type '" + simpleName + "'"); + } + } + + private static String injectJavaImports( + String ores, + List bindings, + String raw, + boolean mixed) { + if (ores.isBlank() && bindings.isEmpty()) return ores; + + StringBuilder prefix = new StringBuilder(); + for (JavaBinding binding : bindings) { + prefix.append("import class ").append(binding.simpleName()) + .append(" from \"java:").append(binding.binaryName()).append("\";\n"); + } + if (!prefix.isEmpty()) prefix.append('\n'); + + String result = prefix + ores; + if (mixed) result += "\n// __mixed_source_sha256=" + sha256(raw) + "\n"; + return result; + } + + private static JavaIslandType parseJavaDeclarationIsland(String body, String generatedPackage) { + if (PACKAGE_ANYWHERE.matcher(body).find()) { + throw new IllegalArgumentException( + "java { ... } declaration islands in .ores files cannot declare a package; " + + "Oreslang source identity is filesystem-path based"); + } + + List types = topLevelTypes(body); + if (types.size() != 1) { + throw new IllegalArgumentException( + "each java { ... } declaration island in a .ores file must declare exactly one " + + "top-level Java class/interface/record/enum"); + } + + TopLevelType type = types.getFirst(); + if (type.name().equals("Ores")) { + throw new IllegalArgumentException( + "Java declaration type name 'Ores' is reserved for the generated Oreslang bridge facade"); + } + if (type.name().startsWith(GENERATED_DO_PREFIX)) { + throw new IllegalArgumentException( + "Java declaration type prefix '" + GENERATED_DO_PREFIX + "' is reserved for do java lowering"); + } + + String promoted = body; + if (!type.isPublic()) { + promoted = body.substring(0, type.keywordStart()) + + "public " + + body.substring(type.keywordStart()); + } + + String source = "package " + generatedPackage + ";\n" + promoted; + return new JavaIslandType(type.name(), generatedPackage + "." + type.name(), source); + } + + private static String runnableSource(String generatedPackage, String simpleName, String body) { + if (PACKAGE_ANYWHERE.matcher(body).find()) { + throw new IllegalArgumentException("do java { ... } contains Java statements, not a package declaration"); + } + return "package " + generatedPackage + ";\n" + + "public final class " + simpleName + " implements java.lang.Runnable {\n" + + " @Override public void run() {\n" + + body + + "\n }\n" + + "}\n"; + } + + private static String detectJavaPackage(String javaSource) { + Matcher matcher = JAVA_PACKAGE.matcher(javaSource); + return matcher.find() ? matcher.group(1) : ""; + } + + private static List topLevelTypes(String source) { + List result = new ArrayList<>(); + int depth = 0; + int i = 0; + while (i < source.length()) { + char c = source.charAt(i); + if (c == '/' && i + 1 < source.length() && source.charAt(i + 1) == '/') { + i = skipLineComment(source, i + 2); + continue; + } + if (c == '/' && i + 1 < source.length() && source.charAt(i + 1) == '*') { + i = skipBlockComment(source, i + 2); + continue; + } + if (c == '"' || c == '\'' || c == '`') { + i = skipQuoted(source, i, c); + continue; + } + if (c == '{') { + depth++; + i++; + continue; + } + if (c == '}') { + depth = Math.max(0, depth - 1); + i++; + continue; + } + if (depth == 0 && Character.isJavaIdentifierStart(c)) { + int start = i++; + while (i < source.length() && Character.isJavaIdentifierPart(source.charAt(i))) i++; + String word = source.substring(start, i); + if (word.equals("class") + || word.equals("interface") + || word.equals("record") + || word.equals("enum")) { + int j = skipWhitespaceAndComments(source, i); + if (j < source.length() && Character.isJavaIdentifierStart(source.charAt(j))) { + int nameStart = j++; + while (j < source.length() && Character.isJavaIdentifierPart(source.charAt(j))) j++; + String name = source.substring(nameStart, j); + boolean isPublic = modifierRegionContainsPublic(source, start); + result.add(new TopLevelType(name, start, isPublic)); + } + } + continue; + } + i++; + } + return result; + } + + private static boolean modifierRegionContainsPublic(String source, int keywordStart) { + int start = keywordStart - 1; + while (start >= 0) { + char c = source.charAt(start); + if (c == ';' || c == '}' || c == '{') break; + start--; + } + String prefix = source.substring(start + 1, keywordStart); + return Pattern.compile("\\bpublic\\b").matcher(prefix).find(); + } + + /** + * Ores-primary scanner. Tracks Ores brace depth so declaration islands and + * execution islands cannot silently swap roles. + */ + private static List findOresJavaIslands(String source) { + List islands = new ArrayList<>(); + int braceDepth = 0; + int i = 0; + + while (i < source.length()) { + char c = source.charAt(i); + + if (c == '/' && i + 1 < source.length() && source.charAt(i + 1) == '/') { + i = skipLineComment(source, i + 2); + continue; + } + if (c == '/' && i + 1 < source.length() && source.charAt(i + 1) == '*') { + i = skipBlockComment(source, i + 2); + continue; + } + if (c == '"' || c == '\'' || c == '`') { + i = skipQuoted(source, i, c); + continue; + } + if (c == '{') { + braceDepth++; + i++; + continue; + } + if (c == '}') { + braceDepth = Math.max(0, braceDepth - 1); + i++; + continue; + } + + if (!Character.isJavaIdentifierStart(c)) { + i++; + continue; + } + + int wordStart = i++; + while (i < source.length() && Character.isJavaIdentifierPart(source.charAt(i))) i++; + String word = source.substring(wordStart, i); + + if (word.equals("do")) { + int javaStart = skipWhitespaceAndComments(source, i); + int javaEnd = identifierEnd(source, javaStart); + if (javaEnd > javaStart && source.substring(javaStart, javaEnd).equals("java")) { + int open = skipWhitespaceAndComments(source, javaEnd); + if (open < source.length() && source.charAt(open) == '{') { + int close = matchingBrace(source, open); + int blockEnd = consumeOptionalStatementSemicolon(source, close + 1); + islands.add(new Island( + "java", + IslandKind.EXECUTION, + wordStart, + open + 1, + close, + blockEnd, + braceDepth, + source.substring(open + 1, close))); + i = blockEnd; + continue; + } + } + } + + if (word.equals("java")) { + int open = skipWhitespaceAndComments(source, i); + if (open < source.length() && source.charAt(open) == '{') { + int close = matchingBrace(source, open); + islands.add(new Island( + "java", + IslandKind.DECLARATION, + wordStart, + open + 1, + close, + close + 1, + braceDepth, + source.substring(open + 1, close))); + i = close + 1; + } + } + } + + return islands; + } + + private static List findSimpleIslands( + String source, + String keyword, + IslandKind kind) { + List islands = new ArrayList<>(); + int i = 0; + while (i < source.length()) { + char c = source.charAt(i); + if (c == '/' && i + 1 < source.length() && source.charAt(i + 1) == '/') { + i = skipLineComment(source, i + 2); + continue; + } + if (c == '/' && i + 1 < source.length() && source.charAt(i + 1) == '*') { + i = skipBlockComment(source, i + 2); + continue; + } + if (c == '"' || c == '\'' || c == '`') { + i = skipQuoted(source, i, c); + continue; + } + if (Character.isJavaIdentifierStart(c)) { + int start = i++; + while (i < source.length() && Character.isJavaIdentifierPart(source.charAt(i))) i++; + if (!source.substring(start, i).equals(keyword)) continue; + int open = skipWhitespaceAndComments(source, i); + if (open >= source.length() || source.charAt(open) != '{') continue; + int close = matchingBrace(source, open); + islands.add(new Island( + keyword, + kind, + start, + open + 1, + close, + close + 1, + 0, + source.substring(open + 1, close))); + i = close + 1; + continue; + } + i++; + } + return islands; + } + + private static int identifierEnd(String source, int start) { + if (start >= source.length() || !Character.isJavaIdentifierStart(source.charAt(start))) return start; + int i = start + 1; + while (i < source.length() && Character.isJavaIdentifierPart(source.charAt(i))) i++; + return i; + } + + private static int consumeOptionalStatementSemicolon(String source, int index) { + int i = index; + while (i < source.length() && (source.charAt(i) == ' ' || source.charAt(i) == '\t')) i++; + return i < source.length() && source.charAt(i) == ';' ? i + 1 : index; + } + + private static int matchingBrace(String source, int open) { + int depth = 1; + int i = open + 1; + while (i < source.length()) { + char c = source.charAt(i); + if (c == '/' && i + 1 < source.length() && source.charAt(i + 1) == '/') { + i = skipLineComment(source, i + 2); + continue; + } + if (c == '/' && i + 1 < source.length() && source.charAt(i + 1) == '*') { + i = skipBlockComment(source, i + 2); + continue; + } + if (c == '"' || c == '\'' || c == '`') { + i = skipQuoted(source, i, c); + continue; + } + if (c == '{') depth++; + else if (c == '}' && --depth == 0) return i; + i++; + } + throw new IllegalArgumentException("unterminated mixed-language block"); + } + + private static int skipWhitespaceAndComments(String source, int index) { + int i = index; + while (i < source.length()) { + if (Character.isWhitespace(source.charAt(i))) { + i++; + continue; + } + if (source.charAt(i) == '/' + && i + 1 < source.length() + && source.charAt(i + 1) == '/') { + i = skipLineComment(source, i + 2); + continue; + } + if (source.charAt(i) == '/' + && i + 1 < source.length() + && source.charAt(i + 1) == '*') { + i = skipBlockComment(source, i + 2); + continue; + } + break; + } + return i; + } + + private static int skipLineComment(String source, int i) { + while (i < source.length() && source.charAt(i) != '\n') i++; + return i; + } + + private static int skipBlockComment(String source, int i) { + int depth = 1; + while (i < source.length()) { + if (i + 1 < source.length() + && source.charAt(i) == '/' + && source.charAt(i + 1) == '*') { + depth++; + i += 2; + continue; + } + if (i + 1 < source.length() + && source.charAt(i) == '*' + && source.charAt(i + 1) == '/') { + depth--; + i += 2; + if (depth == 0) return i; + continue; + } + i++; + } + throw new IllegalArgumentException("unterminated block comment in mixed source"); + } + + private static int skipQuoted(String source, int start, char quote) { + if (quote == '"' && start + 2 < source.length() && source.startsWith("\"\"\"", start)) { + int end = source.indexOf("\"\"\"", start + 3); + if (end < 0) { + throw new IllegalArgumentException("unterminated Java text block in mixed source"); + } + return end + 3; + } + + int i = start + 1; + while (i < source.length()) { + char c = source.charAt(i++); + if (c == '\\' && i < source.length()) { + i++; + continue; + } + if (c == quote) return i; + } + throw new IllegalArgumentException("unterminated quoted literal in mixed source"); + } + + private static String maskBlocks(String source, List islands) { + char[] chars = source.toCharArray(); + for (Island island : islands) { + for (int i = island.keywordStart(); i < island.blockEnd(); i++) { + if (chars[i] != '\n' && chars[i] != '\r') chars[i] = ' '; + } + } + return new String(chars); + } + + private static String replaceOresJavaIslands( + String source, + List islands, + Map replacements) { + if (islands.isEmpty()) return source; + + StringBuilder out = new StringBuilder(source.length() + islands.size() * 32); + int cursor = 0; + for (Island island : islands) { + out.append(source, cursor, island.keywordStart()); + String replacement = replacements.getOrDefault(island, ""); + out.append(replacement); + + // Preserve the original number of physical lines after the lowered + // statement so diagnostics for later Ores source remain stable. + for (int i = island.keywordStart(); i < island.blockEnd(); i++) { + char c = source.charAt(i); + if (c == '\n' || c == '\r') out.append(c); + } + cursor = island.blockEnd(); + } + out.append(source, cursor, source.length()); + return out.toString(); + } + + private static String extractIslandBodies(String source, List islands) { + char[] chars = source.toCharArray(); + for (int i = 0; i < chars.length; i++) { + if (chars[i] != '\n' && chars[i] != '\r') chars[i] = ' '; + } + for (Island island : islands) { + source.getChars(island.bodyStart(), island.bodyEnd(), chars, island.bodyStart()); + } + return new String(chars); + } + + private static String generatedPackage(String unitId) { + return "dev.oreslang.mixed.u" + sha256(unitId).substring(0, 16); + } + + private static String sha256(String text) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(text.getBytes(StandardCharsets.UTF_8))); + } catch (Exception failure) { + throw new IllegalStateException("SHA-256 unavailable", failure); + } + } + + private record TopLevelType(String name, int keywordStart, boolean isPublic) { } + private record JavaIslandType(String simpleName, String binaryName, String sourceText) { } +} diff --git a/src/main/java/dev/oreslang/launcher/OresMain.java b/src/main/java/dev/oreslang/launcher/OresMain.java new file mode 100644 index 00000000..ffce85d6 --- /dev/null +++ b/src/main/java/dev/oreslang/launcher/OresMain.java @@ -0,0 +1,149 @@ +package dev.oreslang.launcher; + +import dev.oreslang.compiler.BuildOptions; +import dev.oreslang.compiler.OresCompiler; +import dev.oreslang.compiler.TreeShaker; +import dev.oreslang.config.OresProjectConfig; +import dev.oreslang.runtime.ExecutionProfile; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.runtime.LinkedProgramRunner; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class OresMain { + private static final Pattern POSITIONED_DIAGNOSTIC = Pattern.compile( + "^Oreslang\\s+(?:lexer|parse)\\s+error\\s+at\\s+(\\d+):(\\d+):\\s*(.*)$", + Pattern.CASE_INSENSITIVE); + + private OresMain() { } + + public static void main(String[] args) throws Exception { + boolean strict = false; + boolean checkOnly = false; + boolean buildAnalysis = false; + List buildDefines = new ArrayList<>(); + Set buildEntryPoints = new LinkedHashSet<>(); + String mode = "jit"; + String platform = "server"; + List additionalCapabilities = new ArrayList<>(); + Set allowedHostClasses = new LinkedHashSet<>(); + String filename = null; + + for (String arg : args) { + if (arg.equals("--strict-isolate")) strict = true; + else if (arg.equals("--check")) checkOnly = true; + else if (arg.equals("--build-analysis")) buildAnalysis = true; + else if (arg.startsWith("--define=")) { + String raw = arg.substring("--define=".length()).trim(); + if (raw.isEmpty()) throw new IllegalArgumentException("--define requires name=value"); + buildDefines.add(raw); + } else if (arg.startsWith("--entry=")) { + String raw = arg.substring("--entry=".length()).trim(); + if (raw.isEmpty()) throw new IllegalArgumentException("--entry requires a symbol name"); + buildEntryPoints.add(raw); + } else if (arg.startsWith("--mode=")) mode = arg.substring("--mode=".length()); + else if (arg.startsWith("--platform=")) platform = arg.substring("--platform=".length()); + else if (arg.startsWith("--allow=")) { + String raw = arg.substring("--allow=".length()); + if (!raw.isBlank()) { + for (String value : raw.split(",")) { + additionalCapabilities.add(IsolatePolicy.Capability.valueOf(value.trim().toUpperCase(Locale.ROOT))); + } + } + } else if (arg.startsWith("--allow-host-class=")) { + String raw = arg.substring("--allow-host-class=".length()).trim(); + if (raw.isEmpty()) { + throw new IllegalArgumentException("--allow-host-class requires a fully qualified Java class name"); + } + allowedHostClasses.add(raw); + } else if (arg.startsWith("--")) { + throw new IllegalArgumentException("unknown option: " + arg); + } else if (filename == null) filename = arg; + else throw new IllegalArgumentException("only one .ores or .java source file may be supplied"); + } + + Path path; + if (filename == null) { + OresProjectConfig project = OresProjectConfig.discover( + Path.of("").toAbsolutePath().normalize(), + System.getenv()); + path = project.mainEntrypoint().orElse(null); + if (path == null) { + System.err.println("usage: oreslang-compiler [--check|--build-analysis] [--define=name=value ...] [--entry=symbol ...] [--strict-isolate] [--mode=aot|jit|hybrid] [--platform=server|windows|macos|linux|android|ios] [--allow=CAP,...] [--allow-host-class=java.util.ArrayList ...] [file.ores|file.java]"); + System.err.println("or define [entrypoints].main in " + OresProjectConfig.MANIFEST_NAME); + System.exit(2); + return; + } + filename = path.toString(); + } else { + path = Path.of(filename); + } + if (!Files.isRegularFile(path)) throw new IllegalArgumentException("not a file: " + path); + + if (checkOnly && buildAnalysis) { + throw new IllegalArgumentException("--check and --build-analysis are mutually exclusive"); + } + + if (buildAnalysis) { + if (!filename.endsWith(".ores")) { + throw new IllegalArgumentException("--build-analysis currently requires a .ores source file"); + } + Map defines = BuildOptions.mergeDefines(System.getenv(), buildDefines); + Set entries = buildEntryPoints.isEmpty() ? Set.of("main") : Set.copyOf(buildEntryPoints); + TreeShaker.Result result = OresCompiler.compileForBuild( + Files.readString(path), + new BuildOptions(defines, entries, false)); + + System.out.println("tree-shake retained:"); + result.retainedSymbols().stream().sorted().forEach(symbol -> System.out.println(" + " + symbol)); + System.out.println("tree-shake removed:"); + result.removedSymbols().stream().sorted().forEach(symbol -> System.out.println(" - " + symbol)); + return; + } + + if (!buildDefines.isEmpty() || !buildEntryPoints.isEmpty()) { + throw new IllegalArgumentException("--define/--entry require --build-analysis until the artifact build command is wired"); + } + + if (checkOnly) { + try { + LinkedProgramRunner.validate(path); + } catch (Exception error) { + System.err.println(formatCheckDiagnostic(path, error)); + System.exit(1); + } + return; + } + + ExecutionProfile profile = ExecutionProfile.parse(mode, platform); + IsolatePolicy policy = strict ? IsolatePolicy.strictFaas() : IsolatePolicy.developer(); + if (!additionalCapabilities.isEmpty()) { + policy = policy.withCapabilities(additionalCapabilities.toArray(IsolatePolicy.Capability[]::new)); + } + + LinkedProgramRunner.run(path, policy, profile, allowedHostClasses, System.out, System.err); + } + + static String formatCheckDiagnostic(Path path, Exception error) { + String message = error.getMessage(); + if (message == null || message.isBlank()) message = error.getClass().getSimpleName(); + + Matcher matcher = POSITIONED_DIAGNOSTIC.matcher(message); + if (matcher.matches()) { + return path.toAbsolutePath().normalize() + + ":" + matcher.group(1) + + ":" + matcher.group(2) + + ": error: " + matcher.group(3); + } + + return path.toAbsolutePath().normalize() + ":1:1: error: " + message; + } +} diff --git a/src/main/java/dev/oreslang/nodes/OresEvalRootNode.java b/src/main/java/dev/oreslang/nodes/OresEvalRootNode.java new file mode 100644 index 00000000..cc46afa9 --- /dev/null +++ b/src/main/java/dev/oreslang/nodes/OresEvalRootNode.java @@ -0,0 +1,3265 @@ +package dev.oreslang.nodes; + +import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary; +import com.oracle.truffle.api.frame.VirtualFrame; +import com.oracle.truffle.api.interop.InteropLibrary; +import com.oracle.truffle.api.nodes.RootNode; +import dev.oreslang.OresLanguage; +import dev.oreslang.ast.Ast; +import dev.oreslang.imports.ImportRules; +import dev.oreslang.parser.Parser; +import dev.oreslang.runtime.OresContext; +import dev.oreslang.runtime.CapabilityChecker; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.runtime.OresMutex; +import dev.oreslang.runtime.ActorRuntime; +import dev.oreslang.runtime.AsyncRuntime; +import dev.oreslang.runtime.ChannelRuntime; +import dev.oreslang.runtime.OresFuture; + +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicLong; + +/** Executable Truffle root. Parsing and static checks happen before this node is created. */ +public final class OresEvalRootNode extends RootNode { + public static final String LINK_ONLY_COMMAND = "__ores_internal_link_only__"; + public static final String INIT_ONLY_COMMAND = "__ores_internal_init_only__"; + public static final String MAIN_ONLY_COMMAND = "__ores_internal_main_only__"; + public static final String INVOKE_PUBLIC_COMMAND = "__ores_internal_invoke_public__"; + public static final String REGISTER_IMPORT_COMMAND = "__ores_internal_register_import__"; + + private final Ast.Program program; + private final String codeUnitId; + private volatile Evaluator evaluator; + + public OresEvalRootNode(OresLanguage language, Ast.Program program) { + this(language, program, ""); + } + + public OresEvalRootNode(OresLanguage language, Ast.Program program, String codeUnitId) { + super(language); + this.program = program; + this.codeUnitId = codeUnitId; + } + + @Override public String getName() { return "ores-eval"; } + @Override public boolean isInternal() { return true; } + + @Override + public Object execute(VirtualFrame frame) { + return executeBoundary(OresContext.get(this), frame.getArguments()); + } + + @TruffleBoundary + private Object executeBoundary(OresContext context, Object[] arguments) { + CapabilityChecker.check(program, context.isolatePolicy()); + if (arguments.length == 3 + && REGISTER_IMPORT_COMMAND.equals(arguments[0]) + && arguments[1] instanceof String importPath + && arguments[2] instanceof String targetCodeUnitId) { + context.registerLinkedImportResolution(codeUnitId, importPath, targetCodeUnitId); + return null; + } + + Evaluator current = evaluator(context); + if (isControl(arguments, LINK_ONLY_COMMAND)) { + current.link(); + return null; + } + if (isControl(arguments, INIT_ONLY_COMMAND)) { + current.link(); + return current.initialize(); + } + if (isControl(arguments, MAIN_ONLY_COMMAND)) { + current.link(); + return current.executeMain(new Object[0]); + } + if (arguments.length >= 2 + && INVOKE_PUBLIC_COMMAND.equals(arguments[0]) + && arguments[1] instanceof String functionName) { + current.link(); + return current.invokePublic(functionName, java.util.Arrays.copyOfRange(arguments, 2, arguments.length)); + } + + // Backward-compatible single-source execution. Multi-file hosts use + // link/init/main commands to install a full import graph before init. + current.link(); + current.initialize(); + return current.executeMain(arguments); + } + + private Evaluator evaluator(OresContext context) { + Evaluator current = evaluator; + if (current != null) return current; + synchronized (this) { + current = evaluator; + if (current == null) evaluator = current = new Evaluator(program, context, codeUnitId); + return current; + } + } + + private static boolean isControl(Object[] arguments, String command) { + return arguments.length == 1 && command.equals(arguments[0]); + } + + private static final class Evaluator { + private final Ast.Program program; + private final OresContext context; + private final String codeUnitId; + private final Map functions = new HashMap<>(); + private final Map classes = new HashMap<>(); + private final Map interfaces = new HashMap<>(); + private final Map typeAliases = new HashMap<>(); + private final Map modules = new HashMap<>(); + private final IdentityHashMap methodOwners = new IdentityHashMap<>(); + private final Map namedImports = new HashMap<>(); + private final Map namespaceImports = new HashMap<>(); + private final Map hostClasses = new HashMap<>(); + private final Map hostFunctions = new HashMap<>(); + private final Map hostSymbols = new HashMap<>(); + private final Set ambiguousFunctions = new LinkedHashSet<>(); + private final Set ambiguousClasses = new LinkedHashSet<>(); + private final Set ambiguousInterfaces = new LinkedHashSet<>(); + private final Set ambiguousTypeAliases = new LinkedHashSet<>(); + private final IdentityHashMap staticSelectCursors = + new IdentityHashMap<>(); + private StartupPhase startupPhase = StartupPhase.CREATED; + private static final int TAIL_SAFEPOINT_INTERVAL = 64; + + private enum InvocationKind { + FUNCTION, + FUNCTION_BODY, + METHOD, + STATIC_FUNCTION, + STATIC_FUNCTION_BODY, + INVOKABLE + } + + private record Invocation( + Evaluator owner, + InvocationKind kind, + Object receiver, + Object target, + List arguments) { } + + private record TailCall(Invocation invocation) { } + + private static final class TailCallSignal extends RuntimeException { + private final Invocation invocation; + private TailCallSignal(Invocation invocation) { + super(null, null, false, false); + this.invocation = invocation; + } + } + + + private Evaluator(Ast.Program program, OresContext context, String codeUnitId) { + this.program = program; + this.context = context; + this.codeUnitId = normalizeUnitId(codeUnitId); + indexImports(); + indexDeclarations(); + } + + private void indexImports() { + for (Ast.ImportDecl imported : program.imports()) { + ImportRules.validate(imported); + if (ImportRules.isJavaPath(imported.path())) { + indexHostImport(imported); + continue; + } + + if (imported.wildcard()) { + namespaceImports.put(imported.namespace(), imported); + } else { + for (String sourceName : imported.names()) { + String localName = ImportRules.localName(imported, sourceName); + ImportedBinding previous = namedImports.putIfAbsent( + localName, + new ImportedBinding(imported, sourceName)); + if (previous != null) { + throw new IllegalArgumentException("duplicate import binding " + localName); + } + } + } + } + } + + private void indexHostImport(Ast.ImportDecl imported) { + String className = ImportRules.javaClassName(imported.path()); + HostClassFacade symbol = hostSymbols.computeIfAbsent( + className, + ignored -> new HostClassFacade(className, context.lookupHostSymbol(className), true)); + + if (imported.kind() == Ast.ImportKind.CLASS) { + String sourceName = imported.names().getFirst(); + putHostClass(ImportRules.localName(imported, sourceName), symbol); + return; + } + + if (imported.kind() == Ast.ImportKind.FUNCTION && imported.wildcard()) { + putHostClass(imported.namespace(), new HostClassFacade(className, symbol.symbol(), false)); + return; + } + + if (imported.kind() == Ast.ImportKind.FUNCTION) { + InteropLibrary interop = InteropLibrary.getUncached(symbol.symbol()); + for (String sourceName : imported.names()) { + if (!interop.isMemberInvocable(symbol.symbol(), sourceName)) { + throw new IllegalArgumentException("Java host class '" + className + + "' does not export invocable static member '" + sourceName + "'"); + } + String localName = ImportRules.localName(imported, sourceName); + putHostFunction(localName, args -> { + context.requireCapability( + IsolatePolicy.Capability.JAVA_INTEROP, + "Java host method " + className + "." + sourceName); + return invokeHostMember(symbol.symbol(), sourceName, args); + }); + } + return; + } + + if (imported.kind() == Ast.ImportKind.ALL) { + putHostClass(imported.namespace(), symbol); + return; + } + + throw new IllegalArgumentException("unsupported Java host import kind: " + imported.kind()); + } + + private void putHostClass(String name, HostClassFacade value) { + if (hostClasses.putIfAbsent(name, value) != null || hostFunctions.containsKey(name)) { + throw new IllegalArgumentException("duplicate Java host import binding " + name); + } + } + + private void putHostFunction(String name, Invokable value) { + if (hostFunctions.putIfAbsent(name, value) != null || hostClasses.containsKey(name)) { + throw new IllegalArgumentException("duplicate Java host import binding " + name); + } + } + + private void indexDeclarations() { + for (Ast.ModuleDecl module : program.modules()) { + modules.put(module.name(), module); + for (Ast.Decl decl : module.declarations()) { + if (decl instanceof Ast.FunctionDecl fn) index(functions, ambiguousFunctions, module.name(), fn.name(), fn); + else if (decl instanceof Ast.ClassDecl klass) { + index(classes, ambiguousClasses, module.name(), klass.name(), klass); + for (Ast.MethodDecl method : klass.methods()) methodOwners.put(method, klass); + } else if (decl instanceof Ast.InterfaceDecl iface) { + index(interfaces, ambiguousInterfaces, module.name(), iface.name(), iface); + } else if (decl instanceof Ast.TypeAliasDecl alias) index(typeAliases, ambiguousTypeAliases, module.name(), alias.name(), alias); + } + } + } + + private static void index(Map map, Set ambiguous, String module, String name, T value) { + map.put(module + "." + name, value); + T previous = map.putIfAbsent(name, value); + if (previous != null && previous != value) { + ambiguous.add(name); + map.remove(name); + } + } + + private Ast.FunctionDecl findFunction(String name) { + if (ambiguousFunctions.contains(name)) throw new IllegalArgumentException("ambiguous function " + name + "; qualify it with its module"); + return functions.get(name); + } + + private Ast.ClassDecl findClass(String name) { + if (ambiguousClasses.contains(name)) throw new IllegalArgumentException("ambiguous class " + name + "; qualify it with its module"); + return classes.get(name); + } + + private Ast.InterfaceDecl findInterface(String name) { + if (ambiguousInterfaces.contains(name)) throw new IllegalArgumentException("ambiguous interface " + name + "; qualify it with its module"); + return interfaces.get(name); + } + + private Ast.TypeAliasDecl findTypeAlias(String name) { + if (ambiguousTypeAliases.contains(name)) throw new IllegalArgumentException("ambiguous type alias " + name + "; qualify it with its module"); + return typeAliases.get(name); + } + + private synchronized void link() { + if (startupPhase == StartupPhase.FAILED) { + throw new IllegalStateException("cannot relink failed code unit " + codeUnitId); + } + context.registerLinkedCodeUnit(codeUnitId, this); + if (startupPhase == StartupPhase.CREATED) startupPhase = StartupPhase.LINKED; + } + + private synchronized Object initialize() { + if (startupPhase == StartupPhase.READY) return null; + if (startupPhase == StartupPhase.INITIALIZING) { + throw new IllegalStateException("recursive initialization of code unit " + codeUnitId); + } + if (startupPhase == StartupPhase.FAILED) { + throw new IllegalStateException("initialization previously failed for code unit " + codeUnitId); + } + if (startupPhase == StartupPhase.CREATED) link(); + + startupPhase = StartupPhase.INITIALIZING; + Object last = null; + try { + for (Ast.ModuleDecl module : program.modules()) { + for (Ast.Decl decl : module.declarations()) { + if (decl instanceof Ast.FunctionDecl fn && fn.name().equals("init")) { + last = invoke(functionBodyInvocation(fn, List.of())); + } + } + } + startupPhase = StartupPhase.READY; + return last; + } catch (RuntimeException | Error failure) { + startupPhase = StartupPhase.FAILED; + throw failure; + } + } + + private Object executeMain(Object[] arguments) { + if (startupPhase != StartupPhase.READY) { + throw new IllegalStateException( + "main cannot run before successful initialization of code unit " + + codeUnitId + "; current phase=" + startupPhase); + } + Ast.FunctionDecl main = functions.get(Parser.ROOT_MODULE + ".main"); + if (main == null) main = findFunction("main"); + if (main == null) return null; + Object result = callFunction(main, List.of(arguments)); + if (result instanceof CompletionStage stage) { + return AsyncRuntime.await(stage); + } + return result; + } + + private Object invokePublic(String name, Object[] arguments) { + Ast.FunctionDecl fn = findFunction(name); + if (fn == null || fn.visibility() != Ast.Visibility.PUBLIC) { + throw new IllegalArgumentException("code unit '" + codeUnitId + + "' does not export public function '" + name + "'"); + } + Object result = callFunction(fn, java.util.Arrays.asList(arguments)); + return result instanceof HostObjectFacade host ? host.value() : result; + } + + private Object invoke(Invocation initial) { + Invocation current = initial; + int tailHops = 0; + while (true) { + Object result = current.owner().executeRaw(current); + if (!(result instanceof TailCall tail)) return result; + current = tail.invocation(); + tailHops++; + if (tailHops % TAIL_SAFEPOINT_INTERVAL == 0) { + current.owner().context.schedulerSafepoint(); + } + } + } + + private Object executeRaw(Invocation invocation) { + return switch (invocation.kind()) { + case FUNCTION -> callFunctionRaw( + (Ast.FunctionDecl) invocation.target(), + invocation.arguments()); + case FUNCTION_BODY -> callFunctionBodyRaw( + (Ast.FunctionDecl) invocation.target(), + invocation.arguments()); + case METHOD -> callMethodRaw( + (OresObject) invocation.receiver(), + (Ast.MethodDecl) invocation.target(), + invocation.arguments()); + case STATIC_FUNCTION -> callStaticFunctionRaw( + (Ast.MethodDecl) invocation.target(), + invocation.arguments()); + case STATIC_FUNCTION_BODY -> callStaticFunctionBodyRaw( + (Ast.MethodDecl) invocation.target(), + invocation.arguments()); + case INVOKABLE -> ((Invokable) invocation.target()).call(invocation.arguments()); + }; + } + + private Invocation functionInvocation(Ast.FunctionDecl fn, List args) { + return new Invocation(this, InvocationKind.FUNCTION, null, fn, objectArguments(args)); + } + + private Invocation functionBodyInvocation(Ast.FunctionDecl fn, List args) { + return new Invocation(this, InvocationKind.FUNCTION_BODY, null, fn, objectArguments(args)); + } + + private Invocation methodInvocation(OresObject receiver, Ast.MethodDecl method, List args) { + return new Invocation(this, InvocationKind.METHOD, receiver, method, objectArguments(args)); + } + + private Invocation staticFunctionInvocation(Ast.MethodDecl fn, List args) { + return new Invocation(this, InvocationKind.STATIC_FUNCTION, null, fn, objectArguments(args)); + } + + private Invocation staticFunctionBodyInvocation(Ast.MethodDecl fn, List args) { + return new Invocation(this, InvocationKind.STATIC_FUNCTION_BODY, null, fn, objectArguments(args)); + } + + private Invocation invokableInvocation(Invokable callable, List args) { + return new Invocation(this, InvocationKind.INVOKABLE, null, callable, objectArguments(args)); + } + + private TailInvokable tailCallable(Invokable callable) { + return new TailCallable(this, callable); + } + + @SuppressWarnings("unchecked") + private static List objectArguments(List args) { + return (List) args; + } + + private Object callFunction(Ast.FunctionDecl fn, List args) { + return invoke(functionInvocation(fn, args)); + } + + private Object callFunctionRaw(Ast.FunctionDecl fn, List args) { + if (fn.name().equals("init")) { + throw new IllegalStateException( + "init is a lifecycle hook and cannot be invoked directly; startup runs it exactly once"); + } + List normalized = normalizeFunctionArguments(fn, args); + if (fn.actorKind() == Ast.ActorKind.NONE) { + if (!fn.async()) return callFunctionBodyRaw(fn, normalized); + + List detached = detachAsyncArguments(normalized); + return context.asyncRuntime().submit(() -> + detachAsyncValue( + invoke(functionBodyInvocation(fn, detached)), + new IdentityHashMap<>())); + } + + if (fn.async()) { + throw new IllegalStateException( + "async actor callables require mailbox continuation lowering and are not executed synchronously"); + } + + if (ActorRuntime.inActorExecution()) { + throw new IllegalArgumentException( + "actor callable '" + fn.name() + + "' cannot be synchronously invoked from another actor turn; " + + "use mailbox-oriented actor composition"); + } + + ActorRuntime.ActorKind runtimeKind = switch (fn.actorKind()) { + case NONE -> throw new AssertionError("non-actor callable reached actor lowering"); + case PRIVATE -> ActorRuntime.ActorKind.PRIVATE; + case SHARED -> ActorRuntime.ActorKind.SHARED; + }; + + return context.actors().invoke( + runtimeKind, + normalized, + (delivered, actorContext) -> + invoke(functionBodyInvocation(fn, delivered))); + } + + private List normalizeFunctionArguments(Ast.FunctionDecl fn, List args) { + if (args.size() != fn.parameters().size()) { + if (fn.parameters().isEmpty() + && args.size() == 1 + && args.getFirst() instanceof Object[] array + && array.length == 0) { + return List.of(); + } + throw new IllegalArgumentException( + "function " + fn.name() + " expects " + fn.parameters().size() + + " arguments, got " + args.size()); + } + return objectArguments(args); + } + + private List detachAsyncArguments(List args) { + ArrayList detached = new ArrayList<>(args.size()); + for (Object arg : args) { + detached.add(detachAsyncValue(arg, new IdentityHashMap<>())); + } + return List.copyOf(detached); + } + + private Object detachAsyncValue( + Object value, + IdentityHashMap visiting) { + if (value == null + || value instanceof String + || value instanceof Boolean + || value instanceof Character + || value instanceof Byte + || value instanceof Short + || value instanceof Integer + || value instanceof Long + || value instanceof Float + || value instanceof Double + || value instanceof java.math.BigInteger + || value instanceof java.math.BigDecimal + || value instanceof Enum + || value instanceof java.util.UUID + || value instanceof Complex + || value instanceof OptionUnwrapError) { + return value; + } + + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException( + "cyclic mutable values cannot cross an async task boundary"); + } + try { + if (value instanceof OresObject object) { + LinkedHashMap fields = new LinkedHashMap<>(); + for (Map.Entry entry : object.fields.entrySet()) { + fields.put(entry.getKey(), detachAsyncValue(entry.getValue(), visiting)); + } + return new OresObject(object.owner, object.klass, fields); + } + if (value instanceof DynamicStructValue dynamic) { + LinkedHashMap fields = new LinkedHashMap<>(); + for (Map.Entry entry : dynamic.fields.entrySet()) { + fields.put(entry.getKey(), detachAsyncValue(entry.getValue(), visiting)); + } + return new DynamicStructValue(fields); + } + if (value instanceof OptionValue option) { + return option.present() + ? new OptionValue(true, detachAsyncValue(option.value(), visiting)) + : option; + } + if (value instanceof ResultValue result) { + return new ResultValue(result.ok(), detachAsyncValue(result.value(), visiting)); + } + if (value instanceof List list) { + ArrayList copy = new ArrayList<>(list.size()); + for (Object item : list) copy.add(detachAsyncValue(item, visiting)); + return copy; + } + if (value instanceof Map map) { + LinkedHashMap copy = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + copy.put( + detachAsyncValue(entry.getKey(), visiting), + detachAsyncValue(entry.getValue(), visiting)); + } + return copy; + } + if (value instanceof Set set) { + LinkedHashSet copy = new LinkedHashSet<>(); + for (Object item : set) copy.add(detachAsyncValue(item, visiting)); + return copy; + } + if (value.getClass().isArray()) { + int length = java.lang.reflect.Array.getLength(value); + ArrayList copy = new ArrayList<>(length); + for (int i = 0; i < length; i++) { + copy.add(detachAsyncValue( + java.lang.reflect.Array.get(value, i), + visiting)); + } + return copy; + } + + throw new IllegalArgumentException( + "value of type " + value.getClass().getName() + + " cannot cross an async task boundary; use owned data"); + } finally { + visiting.remove(value); + } + } + + private Object callFunctionBodyRaw(Ast.FunctionDecl fn, List args) { + Env env = new Env(null, fn.nonLexical()); + for (int i = 0; i < fn.parameters().size(); i++) { + Ast.Param param = fn.parameters().get(i); + env.define(param.name(), args.get(i), param.mutable() ? Ast.BindingKind.LET : Ast.BindingKind.VAL); + } + try { + executeBlock(fn.body(), env); + return null; + } catch (TailCallSignal signal) { + return new TailCall(signal.invocation); + } catch (ReturnSignal signal) { + return shapeReturnedValue( + fn.returnType(), + signal.value, + "function " + fn.name()); + } catch (BreakSignal | ContinueSignal signal) { + throw new IllegalStateException("loop control cannot cross a function boundary", signal); + } + } + + private Object callMethod(OresObject receiver, Ast.MethodDecl method, List args) { + return invoke(methodInvocation(receiver, method, args)); + } + + private Object callMethodRaw(OresObject receiver, Ast.MethodDecl method, List args) { + if (method.async()) { + throw new IllegalStateException( + "async instance methods are not admitted until receiver ownership can be moved into the task"); + } + if (args.size() != method.parameters().size()) throw new IllegalArgumentException("method " + method.name() + " arity mismatch"); + Env env = new Env(null, false, declaringClass(method)); + if (!method.isStatic()) env.define("self", receiver, Ast.BindingKind.VAL); + for (int i = 0; i < method.parameters().size(); i++) { + Ast.Param param = method.parameters().get(i); + env.define(param.name(), args.get(i), param.mutable() ? Ast.BindingKind.LET : Ast.BindingKind.VAL); + } + try { + executeBlock(method.body(), env); + return null; + } catch (TailCallSignal signal) { + return new TailCall(signal.invocation); + } catch (ReturnSignal signal) { + return shapeReturnedValue(method.returnType(), signal.value, "method " + method.name()); + } catch (BreakSignal | ContinueSignal signal) { + throw new IllegalStateException("loop control cannot cross a method boundary", signal); + } + } + + private void executeBlock(List statements, Env parent) { + executeBlock(statements, parent, false); + } + + private void executeBlock(List statements, Env parent, boolean inheritedTailBarrier) { + Env env = new Env(parent); + ArrayDeque deferred = new ArrayDeque<>(); + boolean abnormalExit = false; + try { + for (Ast.Stmt stmt : statements) executeStatement(stmt, env, deferred, inheritedTailBarrier); + } catch (TailCallSignal signal) { + throw signal; + } catch (ReturnSignal | BreakSignal | ContinueSignal signal) { + throw signal; + } catch (RuntimeException | Error failure) { + abnormalExit = true; + throw failure; + } finally { + boolean deferredFailure = false; + try { + while (!deferred.isEmpty()) eval(deferred.pop(), env); + } catch (RuntimeException | Error failure) { + deferredFailure = true; + throw failure; + } finally { + env.releaseMutexGuards(abnormalExit || deferredFailure); + } + } + } + + private void executeStatement( + Ast.Stmt stmt, + Env env, + ArrayDeque deferred, + boolean inheritedTailBarrier) { + if (stmt instanceof Ast.BindingStmt binding) { + if (binding.initializer() instanceof Ast.LambdaExpr) { + env.reserve(binding.name(), binding.kind()); + env.initialize(binding.name(), eval(binding.initializer(), env)); + } else { + env.define(binding.name(), eval(binding.initializer(), env), binding.kind()); + } + return; + } + if (stmt instanceof Ast.DestructureStmt destructure) { + Object value = eval(destructure.initializer(), env); + if (destructure.kind() == Ast.DestructureKind.SEQUENCE) { + List items = asSequence(value); + if (items.size() != destructure.bindings().size()) { + throw new IllegalArgumentException("destructure arity mismatch: value has " + items.size() + + " element(s), pattern has " + destructure.bindings().size()); + } + for (int i = 0; i < items.size(); i++) { + Ast.DestructureBinding binding = destructure.bindings().get(i); + if (!binding.isDiscard()) env.define(binding.name(), items.get(i), binding.kind()); + } + } else { + for (Ast.DestructureBinding binding : destructure.bindings()) { + if (!binding.isDiscard()) { + env.define( + binding.name(), + destructureMember(value, binding.name(), env), + binding.kind()); + } + } + } + return; + } + if (stmt instanceof Ast.ReturnStmt ret) { + returnFrom( + ret.value(), + env, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + } + if (stmt instanceof Ast.ExprStmt expression) { eval(expression.expression(), env); return; } + if (stmt instanceof Ast.DeferStmt defer) { deferred.push(defer.expression()); return; } + if (stmt instanceof Ast.BlockStmt block) { + executeBlock( + block.body(), + env, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + return; + } + if (stmt instanceof Ast.BreakStmt) throw new BreakSignal(); + if (stmt instanceof Ast.ContinueStmt) throw new ContinueSignal(); + if (stmt instanceof Ast.IfStmt ifStmt) { + for (Ast.IfBranch branch : ifStmt.branches()) { + ConditionResult condition = evalCondition(branch.condition(), env); + if (condition.matched()) { + Env branchEnv = new Env(env); + condition.bindings().forEach((name, value) -> + branchEnv.define(name, value, Ast.BindingKind.VAL)); + executeBlock( + branch.body(), + branchEnv, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + return; + } + } + executeBlock( + ifStmt.elseBody(), + env, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + return; + } + if (stmt instanceof Ast.MatchStmt matched) { + Object subject = eval(matched.subject(), env); + Ast.MatchArm selected = null; + Map selectedBindings = Map.of(); + Ast.MatchArm fallback = null; + + for (Ast.MatchArm arm : matched.arms()) { + boolean catchAll = arm.guard() == null + && (arm.pattern() instanceof Ast.WildcardPattern + || arm.pattern() instanceof Ast.BindingPattern); + if (!matched.ordered() && catchAll) { + fallback = arm; + continue; + } + + LinkedHashMap bindings = new LinkedHashMap<>(); + if (!patternMatches(arm.pattern(), subject, bindings)) continue; + Env armEnv = new Env(env); + bindings.forEach((name, value) -> + armEnv.define(name, value, Ast.BindingKind.VAL)); + if (arm.guard() != null && !truth(eval(arm.guard(), armEnv))) continue; + + if (matched.ordered()) { + executeBlock( + arm.body(), + armEnv, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + return; + } + if (selected != null) { + throw new IllegalStateException( + "exclusive match invariant violated at runtime: more than one explicit arm matched; " + + "the static pattern proof and runtime type metadata disagree"); + } + selected = arm; + selectedBindings = Map.copyOf(bindings); + } + + if (selected == null && fallback != null) { + LinkedHashMap bindings = new LinkedHashMap<>(); + if (!patternMatches(fallback.pattern(), subject, bindings)) { + throw new IllegalStateException("match fallback did not accept the unmatched subject"); + } + selected = fallback; + selectedBindings = Map.copyOf(bindings); + } + + if (selected == null) { + throw new IllegalStateException( + "exhaustive match invariant violated at runtime: no arm matched"); + } + Env selectedEnv = new Env(env); + selectedBindings.forEach((name, value) -> + selectedEnv.define(name, value, Ast.BindingKind.VAL)); + executeBlock( + selected.body(), + selectedEnv, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + return; + } + if (stmt instanceof Ast.SwitchStmt switched) { + Object subject = eval(switched.subject(), env); + for (Ast.SwitchCase arm : switched.cases()) { + boolean selected = false; + for (Ast.Expr constant : arm.constants()) { + if (Objects.equals(subject, eval(constant, env))) { + selected = true; + break; + } + } + if (selected) { + executeBlock( + arm.body(), + env, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + return; + } + } + executeBlock( + switched.defaultBody(), + env, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + return; + } + if (stmt instanceof Ast.SelectStmt selected) { + executeSelectStatement( + selected, + env, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + return; + } + if (stmt instanceof Ast.TryStmt tried) { + // A call under catch/finally is not a proper tail call: the + // caller still owns exception/cleanup semantics after the call. + try { executeBlock(tried.body(), env, true); } + catch (TailCallSignal signal) { throw signal; } + catch (ReturnSignal | BreakSignal | ContinueSignal signal) { throw signal; } + catch (OresPanic panic) { throw panic; } + catch (RuntimeException failure) { + Env catchEnv = new Env(env); + catchEnv.define(tried.errorName(), failure, Ast.BindingKind.VAL); + executeBlock(tried.catchBody(), catchEnv, true); + } finally { executeBlock(tried.finallyBody(), env, true); } + return; + } + if (stmt instanceof Ast.ForOfDestructureStmt loop) { + Object iterable = eval(loop.iterable(), env); + for (Object item : iterableValues(iterable, env)) { + context.schedulerSafepoint(); + List items = asSequence(item); + if (items.size() != loop.bindings().size()) { + throw new IllegalArgumentException( + "for-of destructure arity mismatch: value has " + items.size() + + " element(s), pattern has " + loop.bindings().size()); + } + Env iteration = new Env(env); + for (int i = 0; i < items.size(); i++) { + Ast.DestructureBinding binding = loop.bindings().get(i); + if (!binding.isDiscard()) { + iteration.define(binding.name(), items.get(i), binding.kind()); + } + } + try { + executeBlock( + loop.body(), + iteration, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + } catch (ContinueSignal ignored) { + continue; + } catch (BreakSignal ignored) { + break; + } + } + return; + } + if (stmt instanceof Ast.ForOfStmt loop) { + Object iterable = eval(loop.iterable(), env); + for (Object item : iterableValues(iterable, env)) { + context.schedulerSafepoint(); + Env iteration = new Env(env); + iteration.define(loop.bindingName(), item, loop.bindingKind()); + try { + executeBlock( + loop.body(), + iteration, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + } catch (ContinueSignal ignored) { + continue; + } catch (BreakSignal ignored) { + break; + } + } + return; + } + if (stmt instanceof Ast.ForStmt loop) { + Env loopEnv = new Env(env); + if (loop.initializer() != null) { + executeStatement( + loop.initializer(), + loopEnv, + new ArrayDeque<>(), + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + } + while (loop.condition() == null || truth(eval(loop.condition(), loopEnv))) { + context.schedulerSafepoint(); + try { + executeBlock( + loop.body(), + loopEnv, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + } catch (ContinueSignal ignored) { + // Conventional for-loops still execute their update on continue. + } catch (BreakSignal ignored) { + break; + } + if (loop.update() != null) eval(loop.update(), loopEnv); + } + return; + } + if (stmt instanceof Ast.LoopStmt loop) { + while (true) { + context.schedulerSafepoint(); + try { + executeBlock( + loop.body(), + env, + inheritedTailBarrier || !deferred.isEmpty() || env.hasLiveMutexGuards()); + } catch (ContinueSignal ignored) { + continue; + } catch (BreakSignal ignored) { + break; + } + } + } + } + + private void executeSelectStatement( + Ast.SelectStmt selected, + Env env, + boolean tailBarrier) { + ChannelRuntime.SelectSet set = buildStaticSelectSet(selected, env); + ChannelRuntime.SelectPolicy policy = runtimeSelectPolicy(selected.policy()); + + if (selected.mode() == Ast.WaitMode.IMMEDIATE) { + java.util.Optional result = + set.trySelect(policy); + if (result.isPresent()) { + executeSelectedArm( + selected, + result.get(), + env, + tailBarrier, + false); + } + return; + } + + OresFuture future = + set.selectAsync(policy); + + if (selected.mode() == Ast.WaitMode.NONBLOCKING) { + if (!ActorRuntime.inActorExecution()) { + future.cancel(false); + throw new IllegalStateException( + "static nb select branches require an actor execution context; " + + "use 'nb select from cases' when you only need a Future"); + } + + ActorRuntime.ContinuationTarget target = + context.actors().captureCurrentContinuationTarget(); + Env captured = env.snapshot(); + context.actors().enqueueOnCompletion( + future, + target, + (result, failure) -> { + if (failure != null) throw propagateAsyncFailure(failure); + executeSelectedArm( + selected, + result, + captured, + true, + true); + }); + return; + } + + ChannelRuntime.SelectResult result = + (ChannelRuntime.SelectResult) + awaitBlockingChannelFuture(future, "select"); + executeSelectedArm(selected, result, env, tailBarrier, false); + } + + private ChannelRuntime.SelectSet buildStaticSelectSet( + Ast.SelectStmt selected, + Env env) { + ArrayList cases = + new ArrayList<>(selected.arms().size()); + for (Ast.SelectArm arm : selected.arms()) { + switch (arm.operation()) { + case DEFAULT -> cases.add(ChannelRuntime.defaultCase()); + case READ -> cases.add(ChannelRuntime.read( + requireChannel(eval(arm.channel(), env), "readch select case"))); + case WRITE -> cases.add(ChannelRuntime.write( + requireChannel(eval(arm.channel(), env), "writech select case"), + eval(arm.value(), env))); + } + } + return new ChannelRuntime.SelectSet( + cases, + staticSelectTicket(selected)); + } + + private long staticSelectTicket(Ast.SelectStmt selected) { + synchronized (staticSelectCursors) { + return staticSelectCursors + .computeIfAbsent(selected, ignored -> new AtomicLong()) + .getAndIncrement(); + } + } + + private void executeSelectedArm( + Ast.SelectStmt selected, + ChannelRuntime.SelectResult result, + Env parent, + boolean tailBarrier, + boolean detached) { + if (result.index() < 0 || result.index() >= selected.arms().size()) { + throw new IllegalStateException( + "select result index is outside its source arm set: " + + result.index()); + } + + Ast.SelectArm arm = selected.arms().get(result.index()); + Env armEnv = new Env(parent); + if (arm.operation() == Ast.ChannelOperation.READ + && arm.bindingName() != null) { + armEnv.define( + arm.bindingName(), + result.value(), + arm.bindingKind()); + } + + if (!detached) { + executeBlock(arm.body(), armEnv, tailBarrier); + return; + } + + try { + executeBlock(arm.body(), armEnv, true); + } catch (ReturnSignal returned) { + if (returned.value != null) { + throw new IllegalStateException( + "nb select continuation cannot return a value"); + } + // return; exits only this detached arm. + } catch (BreakSignal | ContinueSignal escapedLoopControl) { + throw new IllegalStateException( + "nb select continuation cannot break/continue an enclosing loop", + escapedLoopControl); + } + } + + private Object evalChannelOperation( + Ast.ChannelOpExpr operation, + Env env) { + ChannelRuntime.Channel channel = requireChannel( + eval(operation.channel(), env), + operation.operation() == Ast.ChannelOperation.READ + ? "readch" + : "writech"); + + if (operation.operation() == Ast.ChannelOperation.READ) { + return switch (operation.mode()) { + case IMMEDIATE -> { + java.util.Optional value = channel.tryRead(); + yield value.isPresent() + ? new OptionValue(true, value.get()) + : new OptionValue(false, null); + } + case NONBLOCKING -> context.actors() + .ownCurrentActorFuture(channel.readAsync()); + case BLOCKING -> awaitBlockingChannelFuture( + channel.readAsync(), + "readch"); + }; + } + + Object value = eval(operation.value(), env); + return switch (operation.mode()) { + case IMMEDIATE -> channel.tryWrite(value); + case NONBLOCKING -> context.actors() + .ownCurrentActorFuture(channel.writeAsync(value)); + case BLOCKING -> { + awaitBlockingChannelFuture( + channel.writeAsync(value), + "writech"); + yield null; + } + }; + } + + private Object awaitBlockingChannelFuture( + OresFuture future, + String operation) { + if (future.isDone()) return future.join(); + if (ActorRuntime.inActorExecution()) { + // This registration belongs only to this attempted blocking + // operation. Remove it before failing closed so no waiter leaks. + future.cancel(false); + throw new IllegalStateException( + operation + + " would suspend this actor, but the current interpreter has not yet " + + "lowered this call stack to the OresScheduler resumable-task ABI; " + + "the runtime refuses to park an actor carrier. Use nb " + + operation + + " or a ready/immediate case until continuation lowering is active."); + } + // Transitional root/embedder path. Actor carriers never reach here. + return future.join(); + } + + @SuppressWarnings("unchecked") + private ChannelRuntime.Channel requireChannel( + Object value, + String where) { + if (!(value instanceof ChannelRuntime.Channel channel)) { + throw new IllegalArgumentException( + where + " requires Channel; got " + value); + } + return (ChannelRuntime.Channel) channel; + } + + private ChannelRuntime.SelectSet asSelectSet(Object value) { + if (value instanceof ChannelRuntime.SelectSet set) return set; + + ArrayList cases = new ArrayList<>(); + if (value instanceof List list) { + for (Object item : list) cases.add(requireSelectCase(item)); + return new ChannelRuntime.SelectSet(cases); + } + if (value instanceof Map map) { + for (Object item : map.values()) cases.add(requireSelectCase(item)); + return new ChannelRuntime.SelectSet(cases); + } + if (value instanceof DynamicStructValue dynamic) { + for (Object item : dynamic.fields.values()) { + cases.add(requireSelectCase(item)); + } + return new ChannelRuntime.SelectSet(cases); + } + throw new IllegalArgumentException( + "dynamic select requires SelectSet or list/map of SelectCase values"); + } + + private ChannelRuntime.SelectCase requireSelectCase(Object value) { + if (value instanceof ChannelRuntime.SelectCase selectCase) { + return selectCase; + } + throw new IllegalArgumentException( + "dynamic select collection contains non-SelectCase value: " + + value); + } + + private ChannelRuntime.SelectPolicy runtimeSelectPolicy( + Ast.SelectPolicy policy) { + return switch (policy) { + case FAIR -> ChannelRuntime.SelectPolicy.FAIR; + case PRIORITY -> ChannelRuntime.SelectPolicy.PRIORITY; + case RANDOM -> ChannelRuntime.SelectPolicy.RANDOM; + }; + } + + private RuntimeException propagateAsyncFailure(Throwable failure) { + if (failure instanceof RuntimeException runtime) return runtime; + if (failure instanceof Error error) throw error; + return new RuntimeException(failure); + } + + private void returnFrom(Ast.Expr value, Env env, boolean tailBarrier) { + if (value == null) throw new ReturnSignal(null); + + if (!tailBarrier) { + if (value instanceof Ast.CallExpr call) { + Invocation invocation = prepareInvocation(call, env); + boolean localTailTarget = invocation.kind() == InvocationKind.INVOKABLE + ? invocation.target() instanceof TailCallable callable + && callable.owner() == this + : invocation.owner() == this; + if (localTailTarget) { + throw new TailCallSignal(invocation); + } + // Crossing an untyped linked-code-unit boundary keeps this + // activation until the imported call returns so the caller's + // declared return-shape check still runs. + throw new ReturnSignal(invoke(invocation)); + } + if (value instanceof Ast.ConditionalExpr conditional) { + Ast.Expr selected = truth(eval(conditional.condition(), env)) + ? conditional.whenTrue() + : conditional.whenFalse(); + returnFrom(selected, env, false); + return; + } + } + + throw new ReturnSignal(eval(value, env)); + } + + private Invocation prepareInvocation(Ast.CallExpr call, Env env) { + if (call.callee() instanceof Ast.NameExpr directName + && env.lookup(directName.name()) == Env.MISSING) { + Ast.FunctionDecl direct = findFunction(directName.name()); + if (direct != null) { + List args = evaluateArguments(call.arguments(), env); + return functionInvocation(direct, args); + } + } + + if (call.callee() instanceof Ast.MemberExpr methodCall) { + Object receiver = eval(methodCall.receiver(), env); + List args = evaluateArguments(call.arguments(), env); + + if (receiver instanceof OresObject object) { + Ast.MethodDecl method = object.owner.findMethod( + object.klass, methodCall.member(), args.size(), new LinkedHashSet<>()); + if (method != null) { + object.owner.requireClassMemberVisible( + method.visibility(), + object.owner.declaringClass(method), + env.accessClass(), + "method", + method.name()); + return object.owner.methodInvocation(object, method, args); + } + + OwnedField ownedField = object.owner.findField( + object.klass, methodCall.member(), new LinkedHashSet<>()); + if (ownedField != null) { + object.owner.requireClassMemberVisible( + ownedField.field().visibility(), + ownedField.owner(), + env.accessClass(), + "field", + ownedField.field().name()); + } + Object fieldValue = object.fields.get(methodCall.member()); + if (fieldValue instanceof Invokable invokable) { + return object.owner.invokableInvocation(invokable, args); + } + if (object.fields.containsKey(methodCall.member())) { + throw new IllegalArgumentException( + "field " + object.klass.name() + "." + methodCall.member() + + " is not callable"); + } + throw new IllegalArgumentException( + "no method or callable field " + object.klass.name() + "." + + methodCall.member() + " with arity " + args.size()); + } + + if (receiver instanceof ClassFacade klass) { + Ast.MethodDecl fn = klass.owner().findStaticFunction( + klass.klass(), methodCall.member(), args.size(), new LinkedHashSet<>()); + if (fn == null) { + throw new IllegalArgumentException( + "no static function " + klass.klass().name() + "." + methodCall.member() + + " with arity " + args.size()); + } + klass.owner().requireClassMemberVisible( + fn.visibility(), + klass.owner().declaringClass(fn), + env.accessClass(), + "static function", + fn.name()); + return klass.owner().staticFunctionInvocation(fn, args); + } + + if (receiver instanceof ModuleFacade module) { + return module.owner().prepareModuleInvocation( + module.module(), methodCall.member(), args); + } + + if (receiver instanceof OresMutex.Guard guard + && !methodCall.member().equals("release") + && !methodCall.member().equals("is_released") + && guard.value() instanceof OresObject object) { + Ast.MethodDecl method = object.owner.findMethod( + object.klass, methodCall.member(), args.size(), new LinkedHashSet<>()); + if (method != null) { + object.owner.requireClassMemberVisible( + method.visibility(), + object.owner.declaringClass(method), + env.accessClass(), + "method", + method.name()); + return object.owner.methodInvocation(object, method, args); + } + } + + if (receiver instanceof ImportedNamespace namespace) { + return namespace.owner().prepareImportedInvocation( + namespace.kind(), methodCall.member(), args); + } + + Object callee = member(receiver, methodCall.member(), env); + if (!(callee instanceof Invokable invokable)) { + throw new IllegalArgumentException("value is not callable: " + callee); + } + return invokableInvocation(invokable, args); + } + + Object callee = eval(call.callee(), env); + List args = evaluateArguments(call.arguments(), env); + if (!(callee instanceof Invokable invokable)) { + throw new IllegalArgumentException("value is not callable: " + callee); + } + return invokableInvocation(invokable, args); + } + + private List evaluateArguments(List arguments, Env env) { + ArrayList values = new ArrayList<>(arguments.size()); + for (Ast.Expr argument : arguments) values.add(eval(argument, env)); + return List.copyOf(values); + } + + private Object eval(Ast.Expr expr, Env env) { + if (expr instanceof Ast.LiteralExpr literal) { + if (literal.value() == null) throw new IllegalArgumentException("standalone null values are forbidden"); + if (literal.value() instanceof Ast.Imaginary imaginary) return new Complex(0.0, imaginary.coefficient()); + return literal.value(); + } + if (expr instanceof Ast.NameExpr name) { + Object local = env.lookup(name.name()); + if (local != Env.MISSING) return local; + if (name.name().equals("stdio")) return new StdioFacade(context); + if (name.name().equals("process")) return new ProcessFacade(context); + if (name.name().equals("actor")) return new ActorFacade(context); + if (name.name().equals("Mutex")) return new MutexFactory(false, context); + if (name.name().equals("SharedMutex")) return new MutexFactory(true, context); + if (name.name().equals("Channel")) return new ChannelFactory(); + if (name.name().equals("SelectCase")) return new SelectCaseFactory(); + if (name.name().equals("SelectSet")) return new SelectSetFactory(this); + if (name.name().equals("print")) return (Invokable) args -> { + context.requireCapability(IsolatePolicy.Capability.STDOUT, "print"); + requireOne(args, "print"); context.output().print(display(args.getFirst())); context.output().flush(); return null; + }; + if (name.name().equals("Some")) return (Invokable) args -> { + requireOne(args, "Some"); + return new OptionValue(true, args.getFirst()); + }; + if (name.name().equals("None")) return new OptionValue(false, null); + if (name.name().equals("Ok")) return (Invokable) args -> { + requireOne(args, "Ok"); + return new ResultValue(true, args.getFirst()); + }; + if (name.name().equals("Err")) return (Invokable) args -> { + requireOne(args, "Err"); + return new ResultValue(false, args.getFirst()); + }; + HostClassFacade hostClass = hostClasses.get(name.name()); + if (hostClass != null) { + context.requireCapability(IsolatePolicy.Capability.JAVA_INTEROP, + "Java host class " + hostClass.className()); + return hostClass; + } + Invokable hostFunction = hostFunctions.get(name.name()); + if (hostFunction != null) return hostFunction; + Ast.ModuleDecl module = modules.get(name.name()); + if (module != null) return new ModuleFacade(this, module); + Ast.ClassDecl klass = findClass(name.name()); + if (klass != null) return new ClassFacade(this, klass); + Object imported = importedValue(name.name()); + if (imported != Env.MISSING) return imported; + Ast.FunctionDecl fn = findFunction(name.name()); + if (fn != null) { + if (fn.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException("actor callable " + fn.name() + + " is an actor entry point, not a first-class callable value"); + } + if (fn.kind() == Ast.CallableKind.ROUTINE) { + throw new IllegalArgumentException("routine " + fn.name() + + " is direct-call-only and cannot be used as a first-class callable value"); + } + if (!fn.genericParameters().isEmpty()) { + throw new IllegalArgumentException( + "generic fnc '" + fn.name() + + "' must be specialized by a direct call; " + + "polymorphic function values are not supported yet"); + } + return tailCallable(args -> callFunctionRaw(fn, objectArguments(args))); + } + throw new IllegalArgumentException("unknown name " + name.name()); + } + if (expr instanceof Ast.AssignExpr assignment) { + Object value = eval(assignment.value(), env); + if (assignment.target() instanceof Ast.NameExpr target) { + env.assign(target.name(), value); + return value; + } + if (assignment.target() instanceof Ast.MemberExpr target) { + Object receiver = eval(target.receiver(), env); + if (receiver instanceof OresMutex.Guard guard) receiver = guard.value(); + if (receiver instanceof OresObject object) { + if (!object.fields.containsKey(target.member())) { + throw new IllegalArgumentException("unknown field " + target.member()); + } + OwnedField ownedField = object.owner.findField( + object.klass, target.member(), new LinkedHashSet<>()); + if (ownedField == null) { + throw new IllegalArgumentException("unknown field " + target.member()); + } + Ast.FieldDecl field = ownedField.field(); + object.owner.requireClassMemberVisible( + field.visibility(), + ownedField.owner(), + env.accessClass(), + "field", + field.name()); + if (field.bindingKind() != Ast.BindingKind.LET) { + throw new IllegalArgumentException("field '" + object.klass.name() + "." + + target.member() + "' is immutable"); + } + object.fields.put(target.member(), value); + return value; + } + if (receiver instanceof DynamicStructValue dynamic) { + dynamic.fields.put(target.member(), value); + return value; + } + throw new IllegalArgumentException("member assignment requires a class instance, DynamicStruct, or mutex guard"); + } + if (assignment.target() instanceof Ast.IndexExpr target) { + Object receiver = eval(target.receiver(), env); + Object index = eval(target.index(), env); + if (receiver instanceof DynamicStructValue dynamic) { + if (!(index instanceof String key)) { + throw new IllegalArgumentException("DynamicStruct key must be a string"); + } + dynamic.fields.put(key, value); + return value; + } + if (!(index instanceof Number number)) throw new IllegalArgumentException("array/list index must be an integer"); + int i = Math.toIntExact(number.longValue()); + if (receiver instanceof List raw) { + @SuppressWarnings("unchecked") List list = (List) raw; + list.set(i, value); + return value; + } + throw new IllegalArgumentException("indexed assignment requires a mutable array/list or DynamicStruct"); + } + throw new IllegalArgumentException("unsupported assignment target"); + } + if (expr instanceof Ast.ConditionalExpr conditional) { + return truth(eval(conditional.condition(), env)) + ? eval(conditional.whenTrue(), env) + : eval(conditional.whenFalse(), env); + } + if (expr instanceof Ast.UnaryExpr unary) { + Object value = eval(unary.operand(), env); + return switch (unary.operator()) { + case "&", "&mut" -> value; + case "!" -> !truth(value); + case "~" -> ~integralLong(value); + case "+" -> value; + case "-" -> negate(value); + default -> throw new IllegalArgumentException("unsupported unary operator " + unary.operator()); + }; + } + if (expr instanceof Ast.BinaryExpr binary) { + if (binary.operator().equals("&&")) { + Object left = eval(binary.left(), env); + return truth(left) && truth(eval(binary.right(), env)); + } + if (binary.operator().equals("||")) { + Object left = eval(binary.left(), env); + return truth(left) || truth(eval(binary.right(), env)); + } + if (binary.operator().equals("^^")) { + return truth(eval(binary.left(), env)) ^ truth(eval(binary.right(), env)); + } + Object left = eval(binary.left(), env); + Object right = eval(binary.right(), env); + if (binary.operator().equals("|") && left instanceof Boolean lb && right instanceof Boolean rb) { + return lb || rb; + } + return binary(binary.operator(), left, right); + } + if (expr instanceof Ast.TypeTestExpr test) { + Object value = eval(test.value(), env); + return oresTypeMatches(value, test.targetType()); + } + if (expr instanceof Ast.PatternTestExpr test) { + Object value = eval(test.value(), env); + return patternMatches(test.pattern(), value, new LinkedHashMap<>()); + } + if (expr instanceof Ast.CastExpr cast) { + Object value = eval(cast.value(), env); + boolean matches = oresTypeMatches(value, cast.targetType()); + if (cast.mode() == Ast.CastMode.OPTIONAL) { + return new OptionValue(matches, matches ? value : null); + } + if (!matches) { + throw new OresCastError("cannot cast runtime type " + oresRuntimeTypeName(value) + + " to " + cast.targetType().name()); + } + return value; + } + if (expr instanceof Ast.CallExpr call) { + return invoke(prepareInvocation(call, env)); + } + if (expr instanceof Ast.MemberExpr member) return member(eval(member.receiver(), env), member.member(), env); + if (expr instanceof Ast.IndexExpr indexed) { + Object receiver = eval(indexed.receiver(), env); + Object index = eval(indexed.index(), env); + if (receiver instanceof DynamicStructValue dynamic) { + if (!(index instanceof String key)) { + throw new IllegalArgumentException("DynamicStruct key must be a string"); + } + if (!dynamic.fields.containsKey(key)) { + throw new IllegalArgumentException("unknown DynamicStruct key " + key); + } + return dynamic.fields.get(key); + } + if (receiver instanceof Map map) { + if (!(index instanceof String key)) { + throw new IllegalArgumentException("object/map key must be a string"); + } + if (!map.containsKey(key)) { + throw new IllegalArgumentException("unknown object/map key " + key); + } + return map.get(key); + } + if (!(index instanceof Number number)) throw new IllegalArgumentException("array/list index must be an integer"); + int i = Math.toIntExact(number.longValue()); + if (receiver instanceof List list) return list.get(i); + if (receiver instanceof Object[] array) return array[i]; + throw new IllegalArgumentException("value is not indexable: " + receiver); + } + if (expr instanceof Ast.NewExpr created) { + if (created.type().name().equals("DynamicStruct")) { + if (!created.arguments().isEmpty()) { + throw new IllegalArgumentException("DynamicStruct constructor takes no positional arguments"); + } + return new DynamicStructValue(); + } + HostClassFacade hostClass = hostClasses.get(created.type().name()); + if (hostClass != null) { + context.requireCapability(IsolatePolicy.Capability.JAVA_INTEROP, + "Java host constructor " + hostClass.className()); + if (!hostClass.constructible()) { + throw new IllegalArgumentException( + "Java function namespace '" + created.type().name() + "' is not constructible"); + } + List args = created.arguments().stream().map(arg -> eval(arg, env)).toList(); + return instantiateHost(hostClass, args); + } + + Ast.ClassDecl klass = findClass(created.type().name()); + Evaluator owner = this; + if (klass == null) { + Object imported = importedValue(created.type().name()); + if (imported instanceof ClassFacade externalClass) { + owner = externalClass.owner(); + klass = externalClass.klass(); + } + } + if (klass == null) throw new IllegalArgumentException("unknown class " + created.type().name()); + List args = created.arguments().stream().map(arg -> eval(arg, env)).toList(); + return owner.instantiate(klass, args); + } + if (expr instanceof Ast.AwaitExpr awaited) { + Object value = eval(awaited.expression(), env); + if (value instanceof OresFuture future) { + if (future.isDone()) return future.join(); + if (ActorRuntime.inActorExecution()) { + throw new IllegalStateException( + "pending await inside an actor requires resumable actor continuation lowering; " + + "the runtime will not park an actor carrier"); + } + return future.join(); + } + if (value instanceof CompletionStage stage) { + return AsyncRuntime.await(stage); + } + return value; + } + if (expr instanceof Ast.ChannelOpExpr channelOp) { + return evalChannelOperation(channelOp, env); + } + if (expr instanceof Ast.DynamicSelectExpr selected) { + ChannelRuntime.SelectSet set = asSelectSet(eval(selected.cases(), env)); + ChannelRuntime.SelectPolicy policy = runtimeSelectPolicy(selected.policy()); + if (selected.mode() == Ast.WaitMode.IMMEDIATE) { + java.util.Optional result = + set.trySelect(policy); + return result.isPresent() + ? new OptionValue(true, result.get()) + : new OptionValue(false, null); + } + + OresFuture future = + set.selectAsync(policy); + if (selected.mode() == Ast.WaitMode.NONBLOCKING) { + return context.actors().ownCurrentActorFuture(future); + } + return awaitBlockingChannelFuture(future, "dynamic select"); + } + if (expr instanceof Ast.ListExpr list) { + ArrayList result = new ArrayList<>(list.elements().size()); + for (Ast.Expr item : list.elements()) result.add(eval(item, env)); + return result; + } + if (expr instanceof Ast.TupleExpr tuple) return tuple.elements().stream().map(item -> eval(item, env)).toList(); + if (expr instanceof Ast.ObjectExpr object) { + boolean dynamicKeys = object.fields().stream().anyMatch(Ast.ObjectField::isDynamic); + LinkedHashMap result = new LinkedHashMap<>(); + for (Ast.ObjectField field : object.fields()) { + String key; + if (field.isDynamic()) { + Object evaluatedKey = eval(field.dynamicName(), env); + if (!(evaluatedKey instanceof String stringKey)) { + throw new IllegalArgumentException("dynamic obj key must evaluate to a string"); + } + key = stringKey; + } else { + key = field.name(); + } + if (result.putIfAbsent(key, eval(field.value(), env)) != null) { + throw new IllegalArgumentException("duplicate obj field " + key); + } + } + return dynamicKeys ? new DynamicStructValue(result) : Map.copyOf(result); + } + if (expr instanceof Ast.LambdaExpr lambda) { + boolean nonLexical = lambda.nonLexical() || env.descendantsNonLexical(); + Env captured = nonLexical ? null : env.snapshot(); + return tailCallable(args -> { + if (args.size() != lambda.parameters().size()) throw new IllegalArgumentException("lambda arity mismatch"); + Env local = new Env(captured, nonLexical); + for (int i = 0; i < lambda.parameters().size(); i++) { + Ast.Param param = lambda.parameters().get(i); + local.define(param.name(), args.get(i), param.mutable() ? Ast.BindingKind.LET : Ast.BindingKind.VAL); + } + try { + if (lambda.expressionBody() != null) { + // An expression-bodied lambda's sole expression is + // inherently in tail position. Route it through the + // same tail-return lowering as an explicit + // `return expr;` in a block-bodied lambda. + returnFrom(lambda.expressionBody(), local, false); + throw new AssertionError("lambda expression return did not transfer control"); + } + executeBlock(lambda.blockBody(), local); + return null; + } catch (TailCallSignal signal) { + return new TailCall(signal.invocation); + } catch (ReturnSignal signal) { + return signal.value; + } catch (BreakSignal | ContinueSignal signal) { + throw new IllegalStateException("loop control cannot cross a lambda boundary", signal); + } + }); + } + throw new IllegalArgumentException("unsupported expression " + expr); + } + + private Object member(Object receiver, String name, Env env) { + if (receiver instanceof HostClassFacade host) { + context.requireCapability(IsolatePolicy.Capability.JAVA_INTEROP, + "Java host class " + host.className()); + return hostMember(host.symbol(), host.className(), name); + } + if (receiver instanceof HostObjectFacade host) { + context.requireCapability(IsolatePolicy.Capability.JAVA_INTEROP, + "Java host object member " + name); + return hostMember(host.value(), "host object", name); + } + if (receiver instanceof StdioFacade stdio) { + return switch (name) { + case "print" -> (Invokable) stdio::print; + case "println" -> (Invokable) stdio::println; + case "stdout" -> new StdoutFacade(stdio.context()); + default -> throw new IllegalArgumentException("unknown stdio member " + name); + }; + } + if (receiver instanceof StdoutFacade stdout) { + return switch (name) { + case "write" -> (Invokable) stdout::write; + case "println" -> (Invokable) stdout::println; + default -> throw new IllegalArgumentException("unknown stdout member " + name); + }; + } + if (receiver instanceof ProcessFacade process) { + return switch (name) { + case "context_id" -> process.contextId(); + case "descriptor" -> process.descriptor(); + case "share_readonly" -> (Invokable) process::shareReadonly; + case "gc" -> (Invokable) process::gc; + default -> throw new IllegalArgumentException("unknown process member " + name); + }; + } + if (receiver instanceof ActorFacade actor) { + return switch (name) { + case "gc" -> (Invokable) actor::gc; + default -> throw new IllegalArgumentException("unknown actor member " + name); + }; + } + if (receiver instanceof ChannelFactory factory) { + if (!name.equals("new")) { + throw new IllegalArgumentException("unknown Channel factory member " + name); + } + return (Invokable) factory::create; + } + if (receiver instanceof SelectCaseFactory factory) { + return switch (name) { + case "read" -> (Invokable) factory::read; + case "write" -> (Invokable) factory::write; + case "default" -> (Invokable) factory::defaultCase; + default -> throw new IllegalArgumentException( + "unknown SelectCase factory member " + name); + }; + } + if (receiver instanceof SelectSetFactory factory) { + if (!name.equals("new")) { + throw new IllegalArgumentException("unknown SelectSet factory member " + name); + } + return (Invokable) factory::create; + } + if (receiver instanceof SelectResultValue selected) { + return switch (name) { + case "index" -> (long) selected.index(); + case "operation" -> selected.operation(); + case "value" -> selected.value(); + default -> throw new IllegalArgumentException( + "unknown SelectResult member " + name); + }; + } + if (receiver instanceof ChannelRuntime.SelectResult selected) { + return switch (name) { + case "index" -> (long) selected.index(); + case "operation" -> selected.operation().name().toLowerCase(java.util.Locale.ROOT); + case "value" -> selected.value(); + default -> throw new IllegalArgumentException( + "unknown SelectResult member " + name); + }; + } + if (receiver instanceof MutexFactory factory) { + if (!name.equals("new")) throw new IllegalArgumentException("unknown mutex factory member " + name); + return (Invokable) factory::create; + } + if (receiver instanceof OptionValue option) return optionMember(option, name); + if (receiver instanceof ResultValue result) return resultMember(result, name); + if (receiver instanceof OresMutex.Lock lock) return mutexMember(lock, name); + if (receiver instanceof OresMutex.Guard guard) { + return switch (name) { + case "release" -> (Invokable) args -> { requireZero(args, "MutexGuard.release"); guard.release(); return null; }; + case "is_released" -> (Invokable) args -> { requireZero(args, "MutexGuard.is_released"); return guard.released(); }; + default -> member(guard.value(), name, env); + }; + } + if (receiver instanceof ImportedNamespace namespace) return namespace.owner().exportValue(namespace.kind(), name); + if (receiver instanceof ModuleFacade namespace) return namespace.owner().moduleMember(namespace.module(), name); + if (receiver instanceof ClassFacade klass) { + List functions = klass.owner().findStaticFunctionsByName(klass.klass(), name, new LinkedHashSet<>()); + if (functions.size() == 1) { + Ast.MethodDecl fn = functions.getFirst(); + klass.owner().requireClassMemberVisible( + fn.visibility(), + klass.owner().declaringClass(fn), + env == null ? null : env.accessClass(), + "static function", + fn.name()); + if (!fn.genericParameters().isEmpty()) { + throw new IllegalArgumentException( + "generic static fnc '" + klass.klass().name() + "." + name + + "' must be specialized by a direct call; " + + "polymorphic function values are not supported yet"); + } + return klass.owner().tailCallable( + args -> klass.owner().callStaticFunctionRaw(fn, objectArguments(args))); + } + if (functions.size() > 1) throw new IllegalArgumentException("overloaded static function " + klass.klass().name() + "." + name + " must be called so arity can select it"); + throw new IllegalArgumentException("unknown static member " + klass.klass().name() + "." + name); + } + if (receiver instanceof OresObject object) { + if (object.fields.containsKey(name)) { + OwnedField ownedField = object.owner.findField( + object.klass, name, new LinkedHashSet<>()); + if (ownedField != null) { + object.owner.requireClassMemberVisible( + ownedField.field().visibility(), + ownedField.owner(), + env == null ? null : env.accessClass(), + "field", + ownedField.field().name()); + } + return object.fields.get(name); + } + if (object.owner.hasInstanceMethodNamed(object.klass, name, new LinkedHashSet<>())) { + throw new IllegalArgumentException("instance method " + object.klass.name() + "." + name + + " is direct-call-only and cannot be used as a first-class callable value; " + + "wrap receiver." + name + "(...) in an explicit lambda when a callback is required"); + } + throw new IllegalArgumentException("unknown member " + object.klass.name() + "." + name); + } + if (receiver instanceof DynamicStructValue dynamic) { + if (!dynamic.fields.containsKey(name)) { + throw new IllegalArgumentException("unknown DynamicStruct member " + name); + } + return dynamic.fields.get(name); + } + if (receiver instanceof Map map) { + if (!map.containsKey(name)) throw new IllegalArgumentException("unknown obj member " + name); + return map.get(name); + } + InteropLibrary foreign = InteropLibrary.getUncached(receiver); + if (foreign.hasMembers(receiver)) { + context.requireCapability(IsolatePolicy.Capability.JAVA_INTEROP, + "Java host object member " + name); + return hostMember(receiver, "host object", name); + } + throw new IllegalArgumentException("cannot access member '" + name + "' on " + receiver); + } + + private Object hostMember(Object receiver, String ownerName, String name) { + InteropLibrary interop = InteropLibrary.getUncached(receiver); + if (interop.isMemberInvocable(receiver, name)) { + return (Invokable) args -> { + context.requireCapability( + IsolatePolicy.Capability.JAVA_INTEROP, + "Java host member " + ownerName + "." + name); + return invokeHostMember(receiver, name, args); + }; + } + if (interop.isMemberReadable(receiver, name)) { + try { + return normalizeHostResult(interop.readMember(receiver, name)); + } catch (Exception failure) { + throw hostInteropError("read Java member " + ownerName + "." + name, failure); + } + } + throw new IllegalArgumentException( + "Java member is not exported by HostAccess: " + ownerName + "." + name); + } + + private Object invokeHostMember(Object receiver, String name, List args) { + try { + Object[] unwrapped = args.stream().map(this::unwrapHostArgument).toArray(); + Object result = InteropLibrary.getUncached(receiver).invokeMember(receiver, name, unwrapped); + return normalizeHostResult(result); + } catch (Exception failure) { + throw hostInteropError("invoke Java member " + name, failure); + } + } + + private Object instantiateHost(HostClassFacade hostClass, List args) { + InteropLibrary interop = InteropLibrary.getUncached(hostClass.symbol()); + if (!interop.isInstantiable(hostClass.symbol())) { + throw new IllegalArgumentException( + "allowlisted Java host class is not constructible: " + hostClass.className()); + } + try { + Object[] unwrapped = args.stream().map(this::unwrapHostArgument).toArray(); + Object value = interop.instantiate(hostClass.symbol(), unwrapped); + return new HostObjectFacade(value); + } catch (Exception failure) { + throw hostInteropError("construct Java host class " + hostClass.className(), failure); + } + } + + private Object normalizeHostResult(Object value) { + if (value == null) return new OptionValue(false, null); + if (value instanceof Number || value instanceof Boolean || value instanceof String + || value instanceof Character || value instanceof CompletionStage) { + return value; + } + return new HostObjectFacade(value); + } + + private Object unwrapHostArgument(Object value) { + return value instanceof HostObjectFacade host ? host.value() : value; + } + + private RuntimeException hostInteropError(String operation, Exception failure) { + return new IllegalArgumentException(operation + " failed: " + failure.getMessage(), failure); + } + + private Object optionMember(OptionValue option, String name) { + return switch (name) { + case "is_some" -> (Invokable) args -> { requireZero(args, "Option.is_some"); return option.present(); }; + case "is_none" -> (Invokable) args -> { requireZero(args, "Option.is_none"); return !option.present(); }; + case "unwrap" -> (Invokable) args -> { + requireZero(args, "Option.unwrap"); + if (!option.present()) throw new OresPanic("called Option::unwrap() on a None value"); + return option.value(); + }; + case "unwrap_safe" -> (Invokable) args -> { + requireZero(args, "Option.unwrap_safe"); + return option.present() + ? new ResultValue(true, option.value()) + : new ResultValue(false, new OptionUnwrapError("None")); + }; + case "expect" -> (Invokable) args -> { + String message = requireStringArg(args, "Option.expect"); + if (!option.present()) throw new OresPanic(message); + return option.value(); + }; + case "unwrap_or" -> (Invokable) args -> { + requireOne(args, "Option.unwrap_or"); + return option.present() ? option.value() : args.getFirst(); + }; + default -> throw new IllegalArgumentException("unknown Option member " + name); + }; + } + + private Object resultMember(ResultValue result, String name) { + return switch (name) { + case "is_ok" -> (Invokable) args -> { requireZero(args, "Result.is_ok"); return result.ok(); }; + case "is_err" -> (Invokable) args -> { requireZero(args, "Result.is_err"); return !result.ok(); }; + case "unwrap" -> (Invokable) args -> { + requireZero(args, "Result.unwrap"); + if (!result.ok()) { + throw new OresPanic("called Result::unwrap() on an Err value: " + display(result.value())); + } + return result.value(); + }; + case "unwrap_safe" -> (Invokable) args -> { + requireZero(args, "Result.unwrap_safe"); + return result; + }; + case "expect" -> (Invokable) args -> { + String message = requireStringArg(args, "Result.expect"); + if (!result.ok()) throw new OresPanic(message + ": " + display(result.value())); + return result.value(); + }; + case "unwrap_or" -> (Invokable) args -> { + requireOne(args, "Result.unwrap_or"); + return result.ok() ? result.value() : args.getFirst(); + }; + default -> throw new IllegalArgumentException("unknown Result member " + name); + }; + } + + @SuppressWarnings("unchecked") + private Object mutexMember(OresMutex.Lock rawLock, String name) { + if (rawLock instanceof OresMutex.Shared) { + context.requireCapability(IsolatePolicy.Capability.SHARED_MEMORY, "SharedMutex." + name); + } + OresMutex.Lock lock = (OresMutex.Lock) rawLock; + return switch (name) { + case "lock" -> (Invokable) args -> { requireZero(args, "Mutex.lock"); return lock.lock(); }; + case "try_lock" -> (Invokable) args -> { + requireZero(args, "Mutex.try_lock"); + var guard = lock.tryLock(); + return guard.isPresent() ? new OptionValue(true, guard.get()) : new OptionValue(false, null); + }; + case "lock_async" -> (Invokable) args -> { requireZero(args, "Mutex.lock_async"); return lock.lockAsync(); }; + case "with_lock" -> (Invokable) args -> { + requireOne(args, "Mutex.with_lock"); + if (!(args.getFirst() instanceof Invokable callback)) { + throw new IllegalArgumentException("Mutex.with_lock expects a one-argument lambda/function"); + } + return lock.withLock(value -> { + Object result = invoke(invokableInvocation(callback, List.of(value))); + if (result != null) { + throw new IllegalArgumentException( + "Mutex.with_lock callback must return void"); + } + return null; + }); + }; + case "is_poisoned" -> (Invokable) args -> { requireZero(args, "Mutex.is_poisoned"); return lock.isPoisoned(); }; + case "recover" -> { + if (!(lock instanceof OresMutex.Shared sharedRaw)) { + throw new IllegalArgumentException("recover is only available on SharedMutex"); + } + OresMutex.Shared shared = (OresMutex.Shared) sharedRaw; + yield (Invokable) args -> { + requireOne(args, "SharedMutex.recover"); + if (!(args.getFirst() instanceof Invokable callback)) { + throw new IllegalArgumentException("SharedMutex.recover expects a one-argument lambda/function"); + } + return shared.recover(value -> { + Object result = invoke(invokableInvocation(callback, List.of(value))); + if (result != null) { + throw new IllegalArgumentException( + "SharedMutex.recover callback must return void"); + } + return null; + }); + }; + } + default -> throw new IllegalArgumentException("unknown mutex member " + name); + }; + } + + private Object invokeMethod(OresObject receiver, String name, List args) { + Ast.MethodDecl method = findMethod(receiver.klass, name, args.size(), new LinkedHashSet<>()); + if (method == null) throw new IllegalArgumentException("no method " + receiver.klass.name() + "." + name + " with arity " + args.size()); + return callMethod(receiver, method, args); + } + + private Object invokeStaticFunction(Ast.ClassDecl klass, String name, List args) { + Ast.MethodDecl fn = findStaticFunction(klass, name, args.size(), new LinkedHashSet<>()); + if (fn == null) throw new IllegalArgumentException("no static function " + klass.name() + "." + name + " with arity " + args.size()); + return callStaticFunction(klass, fn, args); + } + + private Object callStaticFunction(Ast.ClassDecl klass, Ast.MethodDecl fn, List args) { + if (!fn.isStatic()) throw new IllegalArgumentException("not a static class function: " + klass.name() + "." + fn.name()); + return invoke(staticFunctionInvocation(fn, args)); + } + + private Object callStaticFunctionRaw(Ast.MethodDecl fn, List args) { + if (!fn.isStatic()) throw new IllegalArgumentException("not a static class function: " + fn.name()); + if (args.size() != fn.parameters().size()) throw new IllegalArgumentException("static function " + fn.name() + " arity mismatch"); + if (!fn.async()) return callStaticFunctionBodyRaw(fn, args); + + List detached = detachAsyncArguments(args); + return context.asyncRuntime().submit(() -> + detachAsyncValue( + invoke(staticFunctionBodyInvocation(fn, detached)), + new IdentityHashMap<>())); + } + + private Object callStaticFunctionBodyRaw(Ast.MethodDecl fn, List args) { + Env env = new Env(null, false, declaringClass(fn)); + for (int i = 0; i < fn.parameters().size(); i++) { + Ast.Param param = fn.parameters().get(i); + env.define(param.name(), args.get(i), param.mutable() ? Ast.BindingKind.LET : Ast.BindingKind.VAL); + } + try { + executeBlock(fn.body(), env); + return null; + } catch (TailCallSignal signal) { + return new TailCall(signal.invocation); + } catch (ReturnSignal signal) { + return shapeReturnedValue(fn.returnType(), signal.value, "static function " + fn.name()); + } catch (BreakSignal | ContinueSignal signal) { + throw new IllegalStateException("loop control cannot cross a static function boundary", signal); + } + } + + private Object importedValue(String name) { + ImportedBinding direct = namedImports.get(name); + if (direct != null) { + return importedTarget(direct.declaration()) + .exportValue(direct.declaration().kind(), direct.sourceName()); + } + Ast.ImportDecl namespace = namespaceImports.get(name); + if (namespace != null) return new ImportedNamespace(importedTarget(namespace), namespace.kind()); + return Env.MISSING; + } + + private Evaluator importedTarget(Ast.ImportDecl imported) { + String targetId = resolveImportUnitId(imported.path()); + Object target = context.linkedCodeUnit(targetId); + if (!(target instanceof Evaluator evaluator)) { + throw new IllegalStateException( + "import target '" + imported.path() + "' for '" + codeUnitId + + "' is not linked yet; all members of an import cycle must be linked before init"); + } + return evaluator; + } + + private String resolveImportUnitId(String rawPath) { + String hostResolved = context.resolvedLinkedImport(codeUnitId, rawPath); + if (hostResolved != null) return hostResolved; + + String raw = rawPath.replace('\\', '/'); + Path parent = Path.of(codeUnitId).getParent(); + Path candidatePath = raw.startsWith(".") + ? (parent == null ? Path.of(raw) : parent.resolve(raw)).normalize() + : Path.of(raw).normalize(); + String candidate = normalizeUnitId(candidatePath.toString()); + if (!context.hasLinkedCodeUnit(candidate) + && !candidate.endsWith(".ores") + && !candidate.endsWith(".java")) { + if (context.hasLinkedCodeUnit(candidate + ".ores")) candidate += ".ores"; + else if (context.hasLinkedCodeUnit(candidate + ".java")) candidate += ".java"; + } + return candidate; + } + + private Invocation prepareImportedInvocation(Ast.ImportKind kind, String name, List args) { + Ast.FunctionDecl fn = findFunction(name); + if (fn == null || fn.visibility() != Ast.Visibility.PUBLIC) { + throw new IllegalArgumentException("code unit '" + codeUnitId + + "' does not export callable '" + name + "'"); + } + if (kind == Ast.ImportKind.FUNCTION) { + if (fn.kind() != Ast.CallableKind.FNC || fn.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException("import fnc requires a reifiable non-actor fnc; '" + name + + "' is direct-call-only or actor-scheduled"); + } + if (!fn.genericParameters().isEmpty()) { + throw new IllegalArgumentException( + "import fnc requires a reifiable non-generic fnc; '" + name + + "' requires direct-call specialization"); + } + return functionInvocation(fn, args); + } + if (kind == Ast.ImportKind.ALL) { + return functionInvocation(fn, args); + } + throw new IllegalArgumentException("import namespace kind " + kind + + " does not expose direct callable '" + name + "'"); + } + + private Object invokeImportedCallable(Ast.ImportKind kind, String name, List args) { + return invoke(prepareImportedInvocation(kind, name, args)); + } + + private Object exportValue(Ast.ImportKind kind, String name) { + return switch (kind) { + case FUNCTION -> { + Ast.FunctionDecl fn = findFunction(name); + if (fn == null || fn.visibility() != Ast.Visibility.PUBLIC) { + throw new IllegalArgumentException("code unit '" + codeUnitId + "' does not export function '" + name + "'"); + } + if (fn.kind() != Ast.CallableKind.FNC || fn.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException("import fnc requires a reifiable non-actor fnc; '" + name + + "' is direct-call-only or actor-scheduled"); + } + if (!fn.genericParameters().isEmpty()) { + throw new IllegalArgumentException( + "generic fnc '" + name + + "' must be specialized by a direct call; " + + "polymorphic function values are not supported yet"); + } + yield tailCallable(args -> callFunctionRaw(fn, objectArguments(args))); + } + case CLASS -> { + Ast.ClassDecl klass = findClass(name); + if (klass == null || klass.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException("code unit '" + codeUnitId + "' does not export ordinary class '" + name + "'"); + } + yield new ClassFacade(this, klass); + } + case ACTOR, INTERFACE, TRAIT, STRUCT, TYPE, TYPES -> + throw new IllegalArgumentException( + "import " + kind.name().toLowerCase() + + " is a type-only selector and cannot be evaluated as a runtime value"); + case MODULE -> { + Ast.ModuleDecl module = modules.get(name); + if (module == null) throw new IllegalArgumentException("code unit '" + codeUnitId + "' does not export module '" + name + "'"); + yield new ModuleFacade(this, module); + } + case ALL -> exportAny(name); + }; + } + + private Object exportAny(String name) { + Ast.ModuleDecl module = modules.get(name); + if (module != null && !module.name().equals(Parser.ROOT_MODULE)) return new ModuleFacade(this, module); + Ast.ClassDecl klass = findClass(name); + if (klass != null) return new ClassFacade(this, klass); + Ast.FunctionDecl fn = findFunction(name); + if (fn != null && fn.visibility() == Ast.Visibility.PUBLIC) { + if (fn.kind() == Ast.CallableKind.ROUTINE) { + throw new IllegalArgumentException( + "routine '" + name + + "' is direct-call-only and cannot be extracted through a wildcard import namespace"); + } + if (fn.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException( + "actor callable '" + name + + "' is scheduler-dispatched and cannot be extracted as a first-class callable value"); + } + if (!fn.genericParameters().isEmpty()) { + throw new IllegalArgumentException( + "generic fnc '" + name + + "' must be specialized by a direct call; " + + "polymorphic function values are not supported yet"); + } + return tailCallable(args -> callFunctionRaw(fn, objectArguments(args))); + } + for (Ast.ModuleDecl candidate : program.modules()) { + for (Ast.Decl decl : candidate.declarations()) { + if (decl instanceof Ast.FieldDecl field + && field.visibility() == Ast.Visibility.PUBLIC + && field.name().equals(name)) { + if (field.initializer() == null) throw new IllegalArgumentException("exported binding has no initializer: " + name); + return eval(field.initializer(), new Env(null)); + } + } + } + throw new IllegalArgumentException("code unit '" + codeUnitId + "' does not export '" + name + "'"); + } + + private OresObject instantiate(Ast.ClassDecl klass, List args) { + if (klass.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalStateException("actor '" + klass.name() + + "' cannot be constructed with new; actor state must be initialized inside ActorRuntime"); + } + List classFields = effectiveFields(klass, new LinkedHashSet<>()); + if (args.size() > classFields.size()) throw new IllegalArgumentException("too many constructor arguments for " + klass.name()); + LinkedHashMap fields = new LinkedHashMap<>(); + Env env = new Env(null, false, klass); + for (int i = 0; i < classFields.size(); i++) { + Ast.FieldDecl field = classFields.get(i); + Object value; + if (i < args.size()) value = args.get(i); + else if (field.initializer() != null) value = eval(field.initializer(), env); + else throw new IllegalArgumentException("missing constructor field " + klass.name() + "." + field.name()); + fields.put(field.name(), value); + } + return new OresObject(this, klass, fields); + } + + private static String normalizeUnitId(String id) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("code unit id cannot be blank"); + return Path.of(id).normalize().toString().replace('\\', '/'); + } + + private Object moduleMember(Ast.ModuleDecl module, String name) { + for (Ast.Decl decl : module.declarations()) { + if (decl instanceof Ast.ClassDecl klass && klass.name().equals(name)) { + return new ClassFacade(this, klass); + } + if (decl instanceof Ast.FunctionDecl fn && fn.name().equals(name) && fn.visibility() == Ast.Visibility.PUBLIC) { + if (fn.kind() == Ast.CallableKind.ROUTINE || fn.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException("callable '" + module.name() + "." + name + + "' is direct-call-only and cannot be extracted as a value"); + } + if (!fn.genericParameters().isEmpty()) { + throw new IllegalArgumentException( + "generic fnc '" + module.name() + "." + name + + "' must be specialized by a direct call; " + + "polymorphic function values are not supported yet"); + } + return tailCallable(args -> callFunctionRaw(fn, objectArguments(args))); + } + if (decl instanceof Ast.FieldDecl field && field.name().equals(name) && field.visibility() == Ast.Visibility.PUBLIC) { + if (field.initializer() == null) throw new IllegalArgumentException("module field has no initializer: " + module.name() + "." + name); + return eval(field.initializer(), new Env(null)); + } + } + throw new IllegalArgumentException("module '" + module.name() + "' does not export '" + name + "'"); + } + + private Invocation prepareModuleInvocation(Ast.ModuleDecl module, String name, List args) { + for (Ast.Decl decl : module.declarations()) { + if (decl instanceof Ast.FunctionDecl fn + && fn.name().equals(name) + && fn.visibility() == Ast.Visibility.PUBLIC) { + return functionInvocation(fn, args); + } + } + throw new IllegalArgumentException("module '" + module.name() + + "' does not export callable '" + name + "'"); + } + + private Object invokeModuleFunction(Ast.ModuleDecl module, String name, List args) { + return invoke(prepareModuleInvocation(module, name, args)); + } + + private boolean hasInstanceMethodNamed(Ast.ClassDecl klass, String name, Set seen) { + if (!seen.add(klass)) return false; + for (Ast.MethodDecl method : klass.methods()) { + if (!method.isStatic() && method.name().equals(name)) { + seen.remove(klass); + return true; + } + } + for (Ast.TypeRef parentRef : klass.parents()) { + if (parentRef.name().equals("Object") || parentRef.name().equals("List")) continue; + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent != null && hasInstanceMethodNamed(parent, name, seen)) { + seen.remove(klass); + return true; + } + } + seen.remove(klass); + return false; + } + + private List effectiveFields(Ast.ClassDecl klass, Set seen) { + if (!seen.add(klass)) throw new IllegalArgumentException("inheritance cycle involving " + klass.name()); + LinkedHashMap result = new LinkedHashMap<>(); + for (Ast.TypeRef parentRef : klass.parents()) { + if (parentRef.name().equals("Object") || parentRef.name().equals("List")) continue; + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent == null) throw new IllegalArgumentException("unknown parent class " + parentRef.name()); + for (Ast.FieldDecl field : effectiveFields(parent, seen)) result.putIfAbsent(field.name(), field); + } + for (Ast.FieldDecl field : klass.fields()) result.put(field.name(), field); + seen.remove(klass); + return List.copyOf(result.values()); + } + + private record OwnedField(Ast.ClassDecl owner, Ast.FieldDecl field) { } + + private Ast.ClassDecl declaringClass(Ast.MethodDecl method) { + Ast.ClassDecl owner = methodOwners.get(method); + if (owner == null) { + throw new IllegalStateException( + "cannot find declaring class for method '" + method.name() + "'"); + } + return owner; + } + + private void requireClassMemberVisible( + Ast.Visibility visibility, + Ast.ClassDecl owner, + Ast.ClassDecl accessClass, + String kind, + String name) { + if (visibility == Ast.Visibility.PRIVATE && accessClass != owner) { + throw new IllegalArgumentException( + "private " + kind + " '" + owner.name() + "." + name + + "' is accessible only from code declared in class " + owner.name()); + } + } + + private OwnedField findField( + Ast.ClassDecl klass, + String name, + Set seen) { + if (!seen.add(klass)) { + throw new IllegalArgumentException("inheritance cycle involving " + klass.name()); + } + for (Ast.FieldDecl field : klass.fields()) { + if (field.name().equals(name)) { + seen.remove(klass); + return new OwnedField(klass, field); + } + } + for (Ast.TypeRef parentRef : klass.parents()) { + if (parentRef.name().equals("Object") || parentRef.name().equals("List")) continue; + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent == null) continue; + OwnedField candidate = findField(parent, name, seen); + if (candidate != null) { + seen.remove(klass); + return candidate; + } + } + seen.remove(klass); + return null; + } + + private Ast.MethodDecl findMethod(Ast.ClassDecl klass, String name, int arity, Set seen) { + if (!seen.add(klass)) throw new IllegalArgumentException("inheritance cycle involving " + klass.name()); + for (Ast.MethodDecl method : klass.methods()) { + if (!method.isStatic() && method.name().equals(name) && method.parameters().size() == arity) { + seen.remove(klass); + return method; + } + } + for (Ast.TypeRef parentRef : klass.parents()) { + if (parentRef.name().equals("Object") || parentRef.name().equals("List")) continue; + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent == null) continue; + Ast.MethodDecl candidate = findMethod(parent, name, arity, seen); + if (candidate != null) { + seen.remove(klass); + return candidate; + } + } + seen.remove(klass); + return null; + } + + private Ast.MethodDecl findStaticFunction(Ast.ClassDecl klass, String name, int arity, Set seen) { + if (!seen.add(klass)) throw new IllegalArgumentException("inheritance cycle involving " + klass.name()); + for (Ast.MethodDecl fn : klass.methods()) { + if (fn.isStatic() && fn.name().equals(name) && fn.parameters().size() == arity) { + seen.remove(klass); + return fn; + } + } + for (Ast.TypeRef parentRef : klass.parents()) { + if (parentRef.name().equals("Object") || parentRef.name().equals("List")) continue; + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent == null) continue; + Ast.MethodDecl candidate = findStaticFunction(parent, name, arity, seen); + if (candidate != null) { + seen.remove(klass); + return candidate; + } + } + seen.remove(klass); + return null; + } + + private List findStaticFunctionsByName(Ast.ClassDecl klass, String name, Set seen) { + if (!seen.add(klass)) return List.of(); + LinkedHashMap result = new LinkedHashMap<>(); + for (Ast.MethodDecl fn : klass.methods()) { + if (fn.isStatic() && fn.name().equals(name)) result.put(fn.parameters().size(), fn); + } + for (Ast.TypeRef parentRef : klass.parents()) { + if (parentRef.name().equals("Object") || parentRef.name().equals("List")) continue; + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent == null) continue; + for (Ast.MethodDecl fn : findStaticFunctionsByName(parent, name, seen)) result.putIfAbsent(fn.parameters().size(), fn); + } + seen.remove(klass); + return List.copyOf(result.values()); + } + + private List iterableValues(Object value, Env env) { + if (value instanceof List list) return list; + if (value instanceof Object[] array) return List.of(array); + if (value instanceof OresObject object) { + Ast.MethodDecl iterator = findMethod(object.klass, "Symbol.iterator", 0, new LinkedHashSet<>()); + if (iterator == null) throw new IllegalArgumentException("value has no [Symbol.iterator]()"); + object.owner.requireClassMemberVisible( + iterator.visibility(), + object.owner.declaringClass(iterator), + env == null ? null : env.accessClass(), + "method", + iterator.name()); + Object produced = callMethod(object, iterator, List.of()); + return iterableValues(produced, env); + } + throw new IllegalArgumentException("value is not iterable"); + } + + private ConditionResult evalCondition(Ast.Expr condition, Env env) { + if (condition instanceof Ast.BinaryExpr binary && binary.operator().equals("&&")) { + ConditionResult left = evalCondition(binary.left(), env); + if (!left.matched()) return ConditionResult.noMatch(); + Env rightEnv = new Env(env); + left.bindings().forEach((name, value) -> + rightEnv.define(name, value, Ast.BindingKind.VAL)); + ConditionResult right = evalCondition(binary.right(), rightEnv); + if (!right.matched()) return ConditionResult.noMatch(); + LinkedHashMap merged = new LinkedHashMap<>(left.bindings()); + for (Map.Entry entry : right.bindings().entrySet()) { + Object previous = merged.putIfAbsent(entry.getKey(), entry.getValue()); + if (previous != null && previous != entry.getValue()) { + throw new IllegalStateException("condition pattern binds '" + entry.getKey() + "' more than once"); + } + } + return new ConditionResult(true, Map.copyOf(merged)); + } + if (condition instanceof Ast.TypeTestExpr test) { + Object value = eval(test.value(), env); + if (!oresTypeMatches(value, test.targetType())) return ConditionResult.noMatch(); + if (test.binding() == null) return ConditionResult.match(); + return new ConditionResult(true, Map.of(test.binding(), value)); + } + if (condition instanceof Ast.PatternTestExpr test) { + Object value = eval(test.value(), env); + LinkedHashMap bindings = new LinkedHashMap<>(); + return patternMatches(test.pattern(), value, bindings) + ? new ConditionResult(true, Map.copyOf(bindings)) + : ConditionResult.noMatch(); + } + return truth(eval(condition, env)) ? ConditionResult.match() : ConditionResult.noMatch(); + } + + private boolean patternMatches(Ast.Pattern pattern, Object value, Map bindings) { + if (pattern instanceof Ast.WildcardPattern) return true; + if (pattern instanceof Ast.BindingPattern binding) { + if (bindings.putIfAbsent(binding.name(), value) != null) { + throw new IllegalStateException("pattern binds '" + binding.name() + "' more than once"); + } + return true; + } + if (pattern instanceof Ast.LiteralPattern literal) { + return Objects.equals(literal.value(), value); + } + if (pattern instanceof Ast.TypePattern typed) { + if (!oresTypeMatches(value, typed.type())) return false; + if (typed.binding() != null && bindings.putIfAbsent(typed.binding(), value) != null) { + throw new IllegalStateException("pattern binds '" + typed.binding() + "' more than once"); + } + return true; + } + if (pattern instanceof Ast.ConstructorPattern constructor) { + String name = constructor.constructor(); + if (name.equals("Some")) { + if (!(value instanceof OptionValue option) || !option.present() || constructor.arguments().size() != 1) return false; + return patternMatches(constructor.arguments().getFirst(), option.value(), bindings); + } + if (name.equals("None")) { + return value instanceof OptionValue option && !option.present() && constructor.arguments().isEmpty(); + } + if (name.equals("Ok")) { + if (!(value instanceof ResultValue result) || !result.ok() || constructor.arguments().size() != 1) return false; + return patternMatches(constructor.arguments().getFirst(), result.value(), bindings); + } + if (name.equals("Err")) { + if (!(value instanceof ResultValue result) || result.ok() || constructor.arguments().size() != 1) return false; + return patternMatches(constructor.arguments().getFirst(), result.value(), bindings); + } + return false; + } + return false; + } + + /** + * Host-neutral Oreslang type relation. The Truffle bootstrap evaluator reads Oreslang + * metadata here; native lowering must use the same relation against native type tags + * (directly or through the narrow JNI bridge), never JVM Class.isInstance/instanceof. + */ + private boolean oresTypeMatches(Object value, Ast.TypeRef target) { + if (target.isUnion()) { + for (Ast.TypeRef option : target.arguments()) { + if (oresTypeMatches(value, option)) return true; + } + return false; + } + String name = target.name(); + if (name.equals("int") || name.equals("i8") || name.equals("i16") || name.equals("i32") + || name.equals("i64") || name.equals("u8") || name.equals("u16") + || name.equals("u32") || name.equals("u64") || name.equals("uint") + || name.equals("bigint")) return value instanceof Byte || value instanceof Short + || value instanceof Integer || value instanceof Long; + if (name.equals("float") || name.equals("f32") || name.equals("f64") + || name.equals("decimal")) return value instanceof Float || value instanceof Double; + if (name.equals("bool") || name.equals("Bool")) return value instanceof Boolean; + if (name.equals("string") || name.equals("String")) return value instanceof String; + if (name.equals("complex") || name.equals("complex64") || name.equals("complex128")) return value instanceof Complex; + if (name.equals("Option")) return value instanceof OptionValue; + if (name.equals("Result")) return value instanceof ResultValue; + if (name.equals("DynamicStruct")) return value instanceof DynamicStructValue; + if (name.equals("Array") || name.equals("List")) return value instanceof List; + + if (value instanceof OresObject object) { + Ast.ClassDecl targetClass = findClass(name); + if (targetClass != null) return classIsA(object.klass, targetClass, new LinkedHashSet<>()); + Ast.InterfaceDecl targetInterface = findInterface(name); + if (targetInterface != null) { + return classImplements(object.klass, targetInterface, new LinkedHashSet<>(), new LinkedHashSet<>()); + } + } + + Ast.TypeAliasDecl alias = findTypeAlias(name); + if (alias != null && alias.genericParameters().isEmpty()) { + return oresTypeMatches(value, alias.target()); + } + + // Java host objects are intentionally not part of Oreslang nominal type identity. + // Interop must cross an explicit capability/adapter boundary. + return false; + } + + private String oresRuntimeTypeName(Object value) { + if (value instanceof OresObject object) return object.klass.name(); + if (value instanceof OptionValue) return "Option"; + if (value instanceof ResultValue) return "Result"; + if (value instanceof DynamicStructValue) return "DynamicStruct"; + if (value instanceof List) return "List"; + if (value instanceof String) return "string"; + if (value instanceof Boolean) return "bool"; + if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) return "int"; + if (value instanceof Float || value instanceof Double) return "float"; + if (value instanceof Complex) return "complex"; + if (value instanceof HostObjectFacade) return ""; + return ""; + } + + private boolean classIsA(Ast.ClassDecl actual, Ast.ClassDecl target, Set seen) { + if (actual == target || actual.name().equals(target.name())) return true; + if (!seen.add(actual)) return false; + for (Ast.TypeRef parentRef : actual.parents()) { + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent != null && classIsA(parent, target, seen)) return true; + } + return false; + } + + private boolean classImplements( + Ast.ClassDecl actual, + Ast.InterfaceDecl target, + Set seenClasses, + Set seenInterfaces) { + if (!seenClasses.add(actual)) return false; + for (Ast.TypeRef interfaceRef : actual.interfaces()) { + Ast.InterfaceDecl iface = findInterface(interfaceRef.name()); + if (iface != null && (iface == target || iface.name().equals(target.name()) + || interfaceExtends(iface, target, seenInterfaces))) return true; + } + for (Ast.TypeRef parentRef : actual.parents()) { + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent != null && classImplements(parent, target, seenClasses, seenInterfaces)) return true; + } + return false; + } + + private boolean interfaceExtends( + Ast.InterfaceDecl actual, Ast.InterfaceDecl target, Set seen) { + if (actual == target || actual.name().equals(target.name())) return true; + if (!seen.add(actual)) return false; + for (Ast.TypeRef parentRef : actual.parents()) { + Ast.InterfaceDecl parent = findInterface(parentRef.name()); + if (parent != null && interfaceExtends(parent, target, seen)) return true; + } + return false; + } + + private record ConditionResult(boolean matched, Map bindings) { + private static ConditionResult match() { return new ConditionResult(true, Map.of()); } + private static ConditionResult noMatch() { return new ConditionResult(false, Map.of()); } + } + + private Object binary(String op, Object left, Object right) { + return switch (op) { + case "+" -> add(left, right); case "-" -> numeric(left, right, '-'); case "*" -> numeric(left, right, '*'); + case "/" -> numeric(left, right, '/'); case "%" -> numeric(left, right, '%'); + case "==" -> Objects.equals(left, right); case "!=" -> !Objects.equals(left, right); + case "<" -> compare(left, right) < 0; case "<=" -> compare(left, right) <= 0; + case ">" -> compare(left, right) > 0; case ">=" -> compare(left, right) >= 0; + case "&" -> integralLong(left) & integralLong(right); + case "|" -> integralLong(left) | integralLong(right); + case "^" -> integralLong(left) ^ integralLong(right); + case "<<" -> integralLong(left) << shiftDistance(right); + case ">>" -> integralLong(left) >> shiftDistance(right); + case ">>>" -> integralLong(left) >>> shiftDistance(right); + default -> throw new IllegalArgumentException("unsupported operator " + op); + }; + } + + private Object add(Object left, Object right) { + if (left instanceof String && right instanceof String) return ((String) left) + right; + return numeric(left, right, '+'); + } + + private Object numeric(Object left, Object right, char op) { + if (left instanceof Complex || right instanceof Complex) { + Complex a = asComplex(left), b = asComplex(right); + return switch (op) { + case '+' -> a.add(b); case '-' -> a.sub(b); case '*' -> a.mul(b); case '/' -> a.div(b); + default -> throw new IllegalArgumentException("operator " + op + " is not supported for complex numbers"); + }; + } + if (!(left instanceof Number a) || !(right instanceof Number b)) throw new IllegalArgumentException("numeric operator requires numbers"); + boolean integral = isIntegral(a) && isIntegral(b) && op != '/'; + if (integral) { + long x = a.longValue(), y = b.longValue(); + return switch (op) { case '+' -> x + y; case '-' -> x - y; case '*' -> x * y; case '%' -> x % y; default -> throw new IllegalArgumentException("bad numeric operator"); }; + } + double x = a.doubleValue(), y = b.doubleValue(); + return switch (op) { case '+' -> x + y; case '-' -> x - y; case '*' -> x * y; case '/' -> x / y; case '%' -> x % y; default -> throw new IllegalArgumentException("bad numeric operator"); }; + } + + private long integralLong(Object value) { + if (!(value instanceof Number number) || !isIntegral(number)) { + throw new IllegalArgumentException("bitwise operator requires integer operands"); + } + return number.longValue(); + } + + private int shiftDistance(Object value) { + long distance = integralLong(value); + if (distance < 0 || distance > 63) { + throw new IllegalArgumentException("shift distance must be between 0 and 63"); + } + return (int) distance; + } + + private Object negate(Object value) { + if (value instanceof Complex c) return new Complex(-c.real, -c.imaginary); + if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) return -((Number) value).longValue(); + if (value instanceof Number number) return -number.doubleValue(); + throw new IllegalArgumentException("unary - requires a number"); + } + + private int compare(Object left, Object right) { + if (left instanceof Number a && right instanceof Number b) return Double.compare(a.doubleValue(), b.doubleValue()); + if (left instanceof String a && right instanceof String b) return a.compareTo(b); + throw new IllegalArgumentException("values are not comparable"); + } + + private boolean truth(Object value) { if (value instanceof Boolean b) return b; throw new IllegalArgumentException("condition must be bool"); } + private boolean isIntegral(Number value) { return value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long; } + private Complex asComplex(Object value) { if (value instanceof Complex c) return c; if (value instanceof Number n) return new Complex(n.doubleValue(),0); throw new IllegalArgumentException("value is not numeric"); } + private Object shapeReturnedValue(Ast.TypeRef declared, Object value, String callable) { + return shapeReturnedValue(declared, value, callable, new LinkedHashSet<>()); + } + + private Object shapeReturnedValue(Ast.TypeRef declared, Object value, String callable, Set resolving) { + if (declared == null) return value; + + Ast.TypeAliasDecl alias = findTypeAlias(declared.name()); + if (alias != null) { + if (alias.genericParameters().size() != declared.arguments().size()) { + throw new IllegalArgumentException("type alias '" + alias.name() + "' expects " + + alias.genericParameters().size() + " type argument(s), got " + declared.arguments().size()); + } + if (!resolving.add(alias)) throw new IllegalArgumentException("type alias cycle involving '" + alias.name() + "'"); + try { + Map substitutions = new HashMap<>(); + for (int i = 0; i < alias.genericParameters().size(); i++) { + substitutions.put(alias.genericParameters().get(i), declared.arguments().get(i)); + } + return shapeReturnedValue(substituteReturnType(alias.target(), substitutions), value, callable, resolving); + } finally { + resolving.remove(alias); + } + } + + if (declared.isUnion()) { + List failures = new ArrayList<>(); + for (Ast.TypeRef option : declared.arguments()) { + try { + return shapeReturnedValue(option, value, callable, new LinkedHashSet<>(resolving)); + } catch (IllegalArgumentException error) { + failures.add(error.getMessage()); + } + } + throw new IllegalArgumentException(callable + " return value does not match any union alternative: " + failures); + } + + if (declared.isTupleType()) { + List items = asSequence(value); + if (items.size() != declared.arguments().size()) { + throw new IllegalArgumentException(callable + " returned " + items.size() + + " tuple element(s), expected " + declared.arguments().size()); + } + Object[] fixed = items.toArray(); + for (int i = 0; i < fixed.length; i++) { + fixed[i] = shapeReturnedValue(declared.arguments().get(i), fixed[i], callable + " tuple[" + i + "]", resolving); + } + return java.util.Arrays.asList(fixed); + } + + if (declared.isRecordType()) { + for (Map.Entry member : declared.recordMembers().entrySet()) { + Object nested = destructureMember(value, member.getKey()); + shapeReturnedValue(member.getValue(), nested, callable + "." + member.getKey(), resolving); + } + return value; + } + + if (declared.name().equals("Array") || declared.name().equals("List")) { + if (declared.arguments().size() != 1) return value; + List items = asSequence(value); + ArrayList shaped = new ArrayList<>(items.size()); + for (int i = 0; i < items.size(); i++) { + shaped.add(shapeReturnedValue(declared.arguments().getFirst(), items.get(i), callable + "[" + i + "]", resolving)); + } + return shaped; + } + + if (declared.name().equals("DynamicStruct")) { + if (declared.arguments().size() != 1 || !(value instanceof DynamicStructValue dynamic)) { + throw returnTypeMismatch(callable, declared, value); + } + for (Map.Entry entry : dynamic.fields.entrySet()) { + shapeReturnedValue( + declared.arguments().getFirst(), + entry.getValue(), + callable + "[" + entry.getKey() + "]", + resolving); + } + return value; + } + + if (declared.name().equals("bool") || declared.name().equals("Bool")) { + if (!(value instanceof Boolean)) throw returnTypeMismatch(callable, declared, value); + return value; + } + if (declared.name().equals("string") || declared.name().equals("String")) { + if (!(value instanceof String)) throw returnTypeMismatch(callable, declared, value); + return value; + } + if (java.util.Set.of("i8","i16","i32","i64","u8","u16","u32","u64","int","uint","bigint").contains(declared.name())) { + if (!(value instanceof Number number) || !isIntegral(number)) throw returnTypeMismatch(callable, declared, value); + return value; + } + if (java.util.Set.of("f32","f64","float","decimal").contains(declared.name())) { + if (!(value instanceof Number)) throw returnTypeMismatch(callable, declared, value); + return value; + } + if (java.util.Set.of("complex64","complex128","complex").contains(declared.name())) { + if (!(value instanceof Number) && !(value instanceof Complex)) throw returnTypeMismatch(callable, declared, value); + return value; + } + if (declared.name().equals("void")) { + if (value != null) throw returnTypeMismatch(callable, declared, value); + return null; + } + + return value; + } + + private Ast.TypeRef substituteReturnType(Ast.TypeRef ref, Map substitutions) { + Ast.TypeRef replacement = substitutions.get(ref.name()); + if (replacement != null && ref.arguments().isEmpty() && !ref.inferArguments()) return replacement; + return new Ast.TypeRef( + ref.name(), + ref.arguments().stream().map(arg -> substituteReturnType(arg, substitutions)).toList(), + ref.inferArguments()); + } + + private IllegalArgumentException returnTypeMismatch(String callable, Ast.TypeRef declared, Object value) { + return new IllegalArgumentException(callable + " returned " + (value == null ? "null" : value.getClass().getSimpleName()) + + " but declared " + declared); + } + + private List asSequence(Object value) { if (value instanceof List l) return l; if (value instanceof Object[] a) return List.of(a); throw new IllegalArgumentException("value is not sequence-destructurable"); } + + private Object destructureMember(Object value, String name, Env env) { + if (value instanceof OresObject object) { + OwnedField ownedField = object.owner.findField( + object.klass, name, new LinkedHashSet<>()); + if (ownedField != null) { + object.owner.requireClassMemberVisible( + ownedField.field().visibility(), + ownedField.owner(), + env == null ? null : env.accessClass(), + "field", + ownedField.field().name()); + } + } + return destructureMember(value, name); + } + + private Object destructureMember(Object value, String name) { + if (value instanceof DynamicStructValue dynamic) { + if (!dynamic.fields.containsKey(name)) { + throw new IllegalArgumentException("object destructure missing member " + name); + } + return dynamic.fields.get(name); + } + if (value instanceof Map map) { + if (!map.containsKey(name)) throw new IllegalArgumentException("object destructure missing member " + name); + return map.get(name); + } + if (value instanceof OresObject object) { + if (!object.fields.containsKey(name)) throw new IllegalArgumentException("object destructure missing field " + name); + return object.fields.get(name); + } + throw new IllegalArgumentException("value is not object-destructurable"); + } + private String display(Object value) { return value instanceof Complex c ? c.toString() : String.valueOf(value); } + } + + @FunctionalInterface private interface Invokable { Object call(List arguments); } + private interface TailInvokable extends Invokable { } + + private record TailCallable(Evaluator owner, Invokable delegate) implements TailInvokable { + @Override public Object call(List arguments) { + return delegate.call(arguments); + } + } + + private static final class Env { + private static final Object MISSING = new Object(); + private final Env parent; + private final boolean descendantsNonLexical; + private final Ast.ClassDecl accessClass; + private final Map slots = new HashMap<>(); + private Env(Env parent) { + this( + parent, + parent != null && parent.descendantsNonLexical, + parent == null ? null : parent.accessClass); + } + private Env(Env parent, boolean descendantsNonLexical) { + this( + parent, + descendantsNonLexical, + parent == null || descendantsNonLexical ? null : parent.accessClass); + } + private Env(Env parent, boolean descendantsNonLexical, Ast.ClassDecl accessClass) { + this.parent = parent; + this.descendantsNonLexical = descendantsNonLexical; + this.accessClass = accessClass; + } + private boolean descendantsNonLexical() { return descendantsNonLexical; } + private Ast.ClassDecl accessClass() { return accessClass; } + private void define(String name, Object value, Ast.BindingKind kind) { + if (slots.putIfAbsent(name, new Slot(value, kind)) != null) throw new IllegalArgumentException("duplicate binding " + name); + } + private void reserve(String name, Ast.BindingKind kind) { + if (slots.putIfAbsent(name, new Slot(MISSING, kind)) != null) throw new IllegalArgumentException("duplicate binding " + name); + } + private void initialize(String name, Object value) { + Slot slot = slots.get(name); + if (slot == null) throw new IllegalArgumentException("unknown binding " + name); + slot.value = value; + } + private Object lookup(String name) { Slot s=slots.get(name); return s!=null?s.value:parent==null?MISSING:parent.lookup(name); } + private void assign(String name, Object value) { + Slot slot = slots.get(name); + if (slot != null) { + if (slot.kind != Ast.BindingKind.LET) throw new IllegalArgumentException("cannot reassign " + slot.kind.name().toLowerCase() + " binding " + name); + slot.value = value; + return; + } + if (parent != null) { parent.assign(name, value); return; } + throw new IllegalArgumentException("unknown binding " + name); + } + private Env snapshot() { + Env cp = new Env( + parent == null ? null : parent.snapshot(), + descendantsNonLexical, + accessClass); + cp.slots.putAll(slots); + return cp; + } + private boolean hasLiveMutexGuards() { + Set seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + for (Slot slot : slots.values()) { + if (containsLiveMutexGuard(slot.value, seen)) return true; + } + return parent != null && parent.hasLiveMutexGuards(); + } + + private static boolean containsLiveMutexGuard(Object value, Set seen) { + if (value == null) return false; + if (value instanceof OresMutex.Guard guard) return !guard.released(); + if (!seen.add(value)) return false; + + if (value instanceof OptionValue option) { + return option.present() && containsLiveMutexGuard(option.value(), seen); + } + if (value instanceof ResultValue result) { + return containsLiveMutexGuard(result.value(), seen); + } + if (value instanceof OresMutex.GuardFuture future) { + return future.isDone() + && !future.isCancelled() + && !future.isCompletedExceptionally() + && containsLiveMutexGuard(future.getNow(null), seen); + } + if (value instanceof OresObject object) { + for (Object field : object.fields.values()) { + if (containsLiveMutexGuard(field, seen)) return true; + } + return false; + } + if (value instanceof List list) { + for (Object item : list) if (containsLiveMutexGuard(item, seen)) return true; + return false; + } + if (value instanceof Set set) { + for (Object item : set) if (containsLiveMutexGuard(item, seen)) return true; + return false; + } + if (value instanceof DynamicStructValue dynamic) { + for (Map.Entry entry : dynamic.fields.entrySet()) { + if (containsLiveMutexGuard(entry.getKey(), seen) + || containsLiveMutexGuard(entry.getValue(), seen)) return true; + } + return false; + } + if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + if (containsLiveMutexGuard(entry.getKey(), seen) + || containsLiveMutexGuard(entry.getValue(), seen)) return true; + } + return false; + } + if (value instanceof Object[] array) { + for (Object item : array) if (containsLiveMutexGuard(item, seen)) return true; + } + return false; + } + + private void releaseMutexGuards(boolean failed) { + Set seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + for (Slot slot : slots.values()) releaseMutexGuardsInValue(slot.value, failed, seen); + } + + private static void releaseMutexGuardsInValue(Object value, boolean failed, Set seen) { + if (value == null) return; + if (value instanceof OresMutex.Guard guard) { + if (!guard.released()) { + if (failed) guard.fail(); + else guard.release(); + } + return; + } + if (!seen.add(value)) return; + + if (value instanceof OptionValue option) { + if (option.present()) releaseMutexGuardsInValue(option.value(), failed, seen); + return; + } + if (value instanceof ResultValue result) { + releaseMutexGuardsInValue(result.value(), failed, seen); + return; + } + if (value instanceof OresMutex.GuardFuture future) { + if (!future.isDone()) { + future.cancel(true); + return; + } + if (!future.isCancelled() && !future.isCompletedExceptionally()) { + releaseMutexGuardsInValue(future.getNow(null), failed, seen); + } + return; + } + if (value instanceof OresObject object) { + for (Object field : object.fields.values()) { + releaseMutexGuardsInValue(field, failed, seen); + } + return; + } + if (value instanceof List list) { + for (Object item : list) releaseMutexGuardsInValue(item, failed, seen); + return; + } + if (value instanceof Set set) { + for (Object item : set) releaseMutexGuardsInValue(item, failed, seen); + return; + } + if (value instanceof DynamicStructValue dynamic) { + for (Map.Entry entry : dynamic.fields.entrySet()) { + releaseMutexGuardsInValue(entry.getKey(), failed, seen); + releaseMutexGuardsInValue(entry.getValue(), failed, seen); + } + return; + } + if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + releaseMutexGuardsInValue(entry.getKey(), failed, seen); + releaseMutexGuardsInValue(entry.getValue(), failed, seen); + } + return; + } + if (value instanceof Object[] array) { + for (Object item : array) releaseMutexGuardsInValue(item, failed, seen); + } + } + } + + private static final class Slot { + private Object value; + private final Ast.BindingKind kind; + private Slot(Object value, Ast.BindingKind kind) { this.value=value; this.kind=kind; } + } + + private static final class ReturnSignal extends RuntimeException { + private final Object value; + private ReturnSignal(Object value) { super(null,null,false,false); this.value=value; } + } + + private static final class BreakSignal extends RuntimeException { + private BreakSignal() { super(null, null, false, false); } + } + + private static final class ContinueSignal extends RuntimeException { + private ContinueSignal() { super(null, null, false, false); } + } + + private enum StartupPhase { + CREATED, + LINKED, + INITIALIZING, + READY, + FAILED + } + + private record Complex(double real, double imaginary) implements OresMutex.SharedState { + @Override public Iterable sharedStateChildren(){return List.of();} + private Complex add(Complex o){return new Complex(real+o.real,imaginary+o.imaginary);} + private Complex sub(Complex o){return new Complex(real-o.real,imaginary-o.imaginary);} + private Complex mul(Complex o){return new Complex(real*o.real-imaginary*o.imaginary,real*o.imaginary+imaginary*o.real);} + private Complex div(Complex o){double d=o.real*o.real+o.imaginary*o.imaginary;return new Complex((real*o.real+imaginary*o.imaginary)/d,(imaginary*o.real-real*o.imaginary)/d);} + @Override public String toString(){return real+(imaginary<0?"":"+")+imaginary+"i";} + } + + private static final class DynamicStructValue implements OresMutex.SharedState { + private final LinkedHashMap fields; + private DynamicStructValue() { this.fields = new LinkedHashMap<>(); } + private DynamicStructValue(Map initial) { this.fields = new LinkedHashMap<>(initial); } + @Override public Iterable sharedStateChildren() { return fields.values(); } + @Override public String toString() { return "DynamicStruct" + fields; } + } + + private static final class OresObject implements OresMutex.SharedState { + private final Evaluator owner; + private final Ast.ClassDecl klass; + private final Map fields; + private OresObject(Evaluator owner, Ast.ClassDecl klass, Map fields) { + this.owner = owner; + this.klass = klass; + this.fields = fields; + } + @Override public Iterable sharedStateChildren(){return fields.values();} + @Override public String toString(){return klass.name()+fields;} + } + + private record ImportedBinding(Ast.ImportDecl declaration, String sourceName) { } + private record ImportedNamespace(Evaluator owner, Ast.ImportKind kind) { } + private record ModuleFacade(Evaluator owner, Ast.ModuleDecl module) { } + private record ClassFacade(Evaluator owner, Ast.ClassDecl klass) { } + private record HostClassFacade(String className, Object symbol, boolean constructible) { } + private record HostObjectFacade(Object value) { + @Override public String toString() { return String.valueOf(value); } + } + private static final class ChannelFactory { + private Object create(List args) { + requireOne(args, "Channel.new"); + if (!(args.getFirst() instanceof Number number)) { + throw new IllegalArgumentException("Channel.new capacity must be an integer"); + } + int capacity = Math.toIntExact(number.longValue()); + return new ChannelRuntime.Channel<>(capacity); + } + } + + private static final class SelectCaseFactory { + @SuppressWarnings({"rawtypes", "unchecked"}) + private Object read(List args) { + requireOne(args, "SelectCase.read"); + if (!(args.getFirst() instanceof ChannelRuntime.Channel channel)) { + throw new IllegalArgumentException("SelectCase.read expects Channel"); + } + return ChannelRuntime.read((ChannelRuntime.Channel) channel); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private Object write(List args) { + requireTwo(args, "SelectCase.write"); + if (!(args.getFirst() instanceof ChannelRuntime.Channel channel)) { + throw new IllegalArgumentException("SelectCase.write expects Channel as first argument"); + } + return ChannelRuntime.write( + (ChannelRuntime.Channel) channel, + args.get(1)); + } + + private Object defaultCase(List args) { + requireZero(args, "SelectCase.default"); + return ChannelRuntime.defaultCase(); + } + } + + private record SelectSetFactory(Evaluator owner) { + private Object create(List args) { + requireOne(args, "SelectSet.new"); + return owner.asSelectSet(args.getFirst()); + } + } + + private record SelectResultValue( + int index, + String operation, + Object value) implements OresMutex.SharedState { + @Override + public Iterable sharedStateChildren() { + return value == null ? List.of() : List.of(value); + } + } + + private record MutexFactory(boolean shared, OresContext context) { + private Object create(List args) { + requireOne(args, shared ? "SharedMutex.new" : "Mutex.new"); + if (shared) { + context.requireCapability(IsolatePolicy.Capability.SHARED_MEMORY, "SharedMutex.new"); + Object value = args.getFirst(); + if (!runtimeSharedSafe(value, java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()))) { + throw new IllegalArgumentException( + "SharedMutex runtime admission rejected non-shared-safe state"); + } + return OresMutex.shared(value); + } + return OresMutex.local(args.getFirst()); + } + + private static boolean runtimeSharedSafe(Object value, Set seen) { + if (value == null || value instanceof String || value instanceof Boolean || value instanceof Character + || value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long + || value instanceof Float || value instanceof Double || value instanceof java.math.BigInteger + || value instanceof java.math.BigDecimal || value instanceof Enum || value instanceof java.util.UUID + || value instanceof Complex || value instanceof ActorRuntime.ActorId || value instanceof ActorRuntime.ActorRef) { + return true; + } + + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard + || value instanceof CompletionStage || value instanceof Invokable + || value instanceof HostClassFacade || value instanceof HostObjectFacade) { + return false; + } + + // Nested shared locks require recursive publication and lock-order + // semantics that are intentionally not part of the current model. + if (value instanceof OresMutex.Shared) return false; + + if (!seen.add(value)) return true; + + if (value instanceof OptionValue option) { + return !option.present() || runtimeSharedSafe(option.value(), seen); + } + if (value instanceof ResultValue result) { + return runtimeSharedSafe(result.value(), seen); + } + if (value instanceof OptionUnwrapError) return true; + if (value instanceof ActorRuntime.Shared readonly) { + return runtimeSharedSafe(readonly.value(), seen); + } + if (value instanceof OresObject object) { + for (Object field : object.fields.values()) { + if (!runtimeSharedSafe(field, seen)) return false; + } + return true; + } + if (value instanceof DynamicStructValue dynamic) { + for (Map.Entry entry : dynamic.fields.entrySet()) { + if (!runtimeSharedSafe(entry.getKey(), seen) + || !runtimeSharedSafe(entry.getValue(), seen)) { + return false; + } + } + return true; + } + if (value instanceof List list) { + for (Object item : list) if (!runtimeSharedSafe(item, seen)) return false; + return true; + } + if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + if (!runtimeSharedSafe(entry.getKey(), seen) || !runtimeSharedSafe(entry.getValue(), seen)) return false; + } + return true; + } + if (value instanceof Object[] array) { + for (Object item : array) if (!runtimeSharedSafe(item, seen)) return false; + return true; + } + + // Guest code has no unrestricted host access. Reject unknown host + // values rather than silently turning SharedMutex into an escape + // hatch for Java references. + return false; + } + } + private record OptionValue(boolean present, Object value) implements OresMutex.SharedState { + @Override public Iterable sharedStateChildren(){return present ? List.of(value) : List.of();} + @Override public String toString(){return present ? "Some(" + value + ")" : "None";} + } + private record ResultValue(boolean ok, Object value) implements OresMutex.SharedState { + @Override public Iterable sharedStateChildren(){return List.of(value);} + @Override public String toString(){return ok ? "Ok(" + value + ")" : "Err(" + value + ")";} + } + private record OptionUnwrapError(String reason) { + @Override public String toString(){return "OptionUnwrapError(" + reason + ")";} + } + private static final class OresPanic extends RuntimeException { + private OresPanic(String message) { super(message, null, false, false); } + } + private static final class OresCastError extends RuntimeException { + private OresCastError(String message) { super(message, null, false, false); } + } + private record StdioFacade(OresContext context) { + private Object print(List args){context.requireCapability(IsolatePolicy.Capability.STDOUT,"stdio.print");requireOne(args,"stdio.print");context.output().print(String.valueOf(args.getFirst()));context.output().flush();return null;} + private Object println(List args){context.requireCapability(IsolatePolicy.Capability.STDOUT,"stdio.println");requireOne(args,"stdio.println");context.output().println(String.valueOf(args.getFirst()));return null;} + } + private record StdoutFacade(OresContext context) { + private Object write(List args){context.requireCapability(IsolatePolicy.Capability.STDOUT,"stdio.stdout.write");requireOne(args,"stdio.stdout.write");context.output().print(String.valueOf(args.getFirst()));context.output().flush();return null;} + private Object println(List args){context.requireCapability(IsolatePolicy.Capability.STDOUT,"stdio.stdout.println");requireOne(args,"stdio.stdout.println");context.output().println(String.valueOf(args.getFirst()));return null;} + } + private record ProcessFacade(OresContext context) { + private String contextId(){context.requireCapability(IsolatePolicy.Capability.PROCESS_INFO,"process.context_id");return context.contextId().toString();} + private Map descriptor(){context.requireCapability(IsolatePolicy.Capability.PROCESS_INFO,"process.descriptor");return context.processDescriptor();} + private Object shareReadonly(List args){context.requireCapability(IsolatePolicy.Capability.ACTOR_SHARE_READONLY,"process.share_readonly");requireOne(args,"process.share_readonly");return context.actors().shareReadonly(args.getFirst());} + private Map gc(List args){context.requireCapability(IsolatePolicy.Capability.GC_CONTROL,"process.gc");requireZero(args,"process.gc");return context.garbageCollector().collectProcess().asMap();} + } + private record ActorFacade(OresContext context) { + private Map gc(List args){requireZero(args,"actor.gc");return context.garbageCollector().collectCurrentActor().asMap();} + } + private static void requireZero(List args,String name){if(!args.isEmpty())throw new IllegalArgumentException(name+" expects no arguments");} + private static void requireOne(List args,String name){if(args.size()!=1)throw new IllegalArgumentException(name+" expects one argument");} + private static void requireTwo(List args,String name){if(args.size()!=2)throw new IllegalArgumentException(name+" expects two arguments");} + private static String requireStringArg(List args,String name){ + requireOne(args,name); + if(!(args.getFirst() instanceof String message)) throw new IllegalArgumentException(name+" expects a String message"); + return message; + } +} diff --git a/src/main/java/dev/oreslang/nodes/OresInteropRootNode.java b/src/main/java/dev/oreslang/nodes/OresInteropRootNode.java new file mode 100644 index 00000000..62ac55ad --- /dev/null +++ b/src/main/java/dev/oreslang/nodes/OresInteropRootNode.java @@ -0,0 +1,37 @@ +package dev.oreslang.nodes; + +import com.oracle.truffle.api.RootCallTarget; +import com.oracle.truffle.api.frame.VirtualFrame; +import com.oracle.truffle.api.nodes.DirectCallNode; +import com.oracle.truffle.api.nodes.RootNode; +import dev.oreslang.OresLanguage; +import dev.oreslang.runtime.OresNull; + +/** + * Adapts internal evaluator values to values that are legal at the polyglot + * guest/host boundary. Internal void/null is represented by OresNull externally. + */ +public final class OresInteropRootNode extends RootNode { + @Child private DirectCallNode delegate; + + public OresInteropRootNode(OresLanguage language, RootCallTarget delegateTarget) { + super(language); + this.delegate = DirectCallNode.create(delegateTarget); + } + + @Override + public String getName() { + return "ores-polyglot-eval"; + } + + @Override + public boolean isInternal() { + return true; + } + + @Override + public Object execute(VirtualFrame frame) { + Object value = delegate.call(frame.getArguments()); + return value == null ? OresNull.INSTANCE : value; + } +} diff --git a/src/main/java/dev/oreslang/parser/Lexer.java b/src/main/java/dev/oreslang/parser/Lexer.java new file mode 100644 index 00000000..427f5d9c --- /dev/null +++ b/src/main/java/dev/oreslang/parser/Lexer.java @@ -0,0 +1,147 @@ +package dev.oreslang.parser; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static dev.oreslang.parser.Token.Type.*; + +public final class Lexer { + private static final Map KEYWORDS = new HashMap<>(); + + static { + KEYWORDS.put("define", DEFINE); KEYWORDS.put("class", CLASS); KEYWORDS.put("module", MODULE); KEYWORDS.put("namespace", NAMESPACE); + KEYWORDS.put("import", IMPORT); KEYWORDS.put("from", FROM); KEYWORDS.put("as", AS); + KEYWORDS.put("extends", EXTENDS); KEYWORDS.put("implements", IMPLEMENTS); + KEYWORDS.put("try", TRY); KEYWORDS.put("catch", CATCH); KEYWORDS.put("finally", FINALLY); + KEYWORDS.put("end", END); KEYWORDS.put("fi", FI); KEYWORDS.put("if", IF); KEYWORDS.put("do", DO); + KEYWORDS.put("else", ELSE); KEYWORDS.put("then", THEN); KEYWORDS.put("new", NEW); KEYWORDS.put("stop", STOP); KEYWORDS.put("done", DONE); + KEYWORDS.put("await", AWAIT); KEYWORDS.put("async", ASYNC); KEYWORDS.put("nlex", NLEX); + KEYWORDS.put("nb", NB); KEYWORDS.put("select", SELECT); KEYWORDS.put("readch", READCH); KEYWORDS.put("writech", WRITECH); + KEYWORDS.put("actor", ACTOR); KEYWORDS.put("isoactor", ISOACTOR); KEYWORDS.put("def", DEF); KEYWORDS.put("fnc", FNC); KEYWORDS.put("routine", ROUTINE); + KEYWORDS.put("for", FOR); KEYWORDS.put("of", OF); KEYWORDS.put("loop", LOOP); KEYWORDS.put("block", BLOCK); KEYWORDS.put("break", BREAK); KEYWORDS.put("continue", CONTINUE); KEYWORDS.put("yield", YIELD); KEYWORDS.put("super", SUPER); KEYWORDS.put("elseif", ELSEIF); KEYWORDS.put("switch", SWITCH); KEYWORDS.put("match", MATCH); KEYWORDS.put("matches", MATCHES); KEYWORDS.put("is", IS); KEYWORDS.put("when", WHEN); KEYWORDS.put("case", CASE); KEYWORDS.put("default", DEFAULT); + KEYWORDS.put("type", TYPE); KEYWORDS.put("types", TYPES); KEYWORDS.put("typeof", TYPEOF); KEYWORDS.put("interface", INTERFACE); KEYWORDS.put("trait", TRAIT); KEYWORDS.put("struct", STRUCT); KEYWORDS.put("impl", IMPL); KEYWORDS.put("abstract", ABSTRACT); + KEYWORDS.put("void", VOID); KEYWORDS.put("static", STATIC); KEYWORDS.put("pub", PUB); KEYWORDS.put("private", PRIVATE); + KEYWORDS.put("return", RETURN); KEYWORDS.put("defer", DEFER); KEYWORDS.put("val", VAL); KEYWORDS.put("const", CONST); + KEYWORDS.put("let", LET); KEYWORDS.put("mut", MUT); KEYWORDS.put("self", SELF); KEYWORDS.put("true", TRUE); KEYWORDS.put("false", FALSE); + KEYWORDS.put("null", NULL); KEYWORDS.put("obj", OBJ); KEYWORDS.put("arr", ARR); + } + + private final String source; + private final List tokens = new ArrayList<>(); + private int start; + private int current; + private int line = 1; + private int column = 1; + private int startColumn = 1; + + public Lexer(String source) { this.source = source == null ? "" : source; } + + public List scan() { + while (!isAtEnd()) { + start = current; + startColumn = column; + scanToken(); + } + tokens.add(new Token(EOF, "", line, column)); + return List.copyOf(tokens); + } + + private void scanToken() { + char c = advance(); + switch (c) { + case '(' -> add(LPAREN); case ')' -> add(RPAREN); case '{' -> add(LBRACE); case '}' -> add(RBRACE); + case '[' -> add(LBRACKET); case ']' -> add(RBRACKET); case ',' -> add(COMMA); case '.' -> add(DOT); + case ';' -> add(SEMICOLON); case ':' -> add(COLON); case '?' -> add(QUESTION); case '@' -> add(AT); case '`' -> add(BACKTICK); case '+' -> add(PLUS); + case '*' -> add(STAR); case '%' -> add(PERCENT); case '|' -> add(PIPE); case '&' -> add(AMP); + case '^' -> add(CARET); case '~' -> add(TILDE); + case '-' -> add(match('>') ? ARROW : MINUS); + case '!' -> add(match('=') ? BANG_EQUAL : BANG); + case '=' -> add(match('>') ? FAT_ARROW : match('=') ? EQUAL_EQUAL : EQUAL); + case '<' -> add(match('=') ? LTE : LT); case '>' -> add(match('=') ? GTE : GT); + case '/' -> { + if (match('/')) while (peek() != '\n' && !isAtEnd()) advance(); + else if (match('*')) blockComment(); + else add(SLASH); + } + case ' ', '\r', '\t' -> { } + case '\n' -> newline(); + case '"', '\'' -> string(c); + default -> { + if (isDigit(c)) number(); + else if (isIdentStart(c)) identifier(); + else throw error("unexpected character '" + c + "'"); + } + } + } + + private void blockComment() { + int depth = 1; + while (depth > 0) { + if (isAtEnd()) throw error("unterminated block comment"); + char c = advance(); + if (c == '\n') newline(); + else if (c == '/' && match('*')) depth++; + else if (c == '*' && match('/')) depth--; + } + } + + private void string(char quote) { + StringBuilder value = new StringBuilder(); + while (!isAtEnd() && peek() != quote) { + char c = advance(); + if (c == '\n') { newline(); value.append('\n'); continue; } + if (c == '\\') { + if (isAtEnd()) throw error("unterminated string escape"); + char escaped = advance(); + value.append(switch (escaped) { + case 'n' -> '\n'; case 'r' -> '\r'; case 't' -> '\t'; case '"' -> '"'; case '\'' -> '\''; case '\\' -> '\\'; default -> escaped; + }); + } else value.append(c); + } + if (isAtEnd()) throw error("unterminated string"); + advance(); + tokens.add(new Token(STRING, value.toString(), line, startColumn)); + } + + private void number() { + while (isDigit(peek()) || peek() == '_') advance(); + boolean floating = false; + if (peek() == '.' && isDigit(peekNext())) { + floating = true; advance(); + while (isDigit(peek()) || peek() == '_') advance(); + } + if (peek() == 'e' || peek() == 'E') { + floating = true; advance(); + if (peek() == '+' || peek() == '-') advance(); + if (!isDigit(peek())) throw error("malformed exponent"); + while (isDigit(peek()) || peek() == '_') advance(); + } + if (peek() == 'i') { advance(); add(IMAG); } + else add(floating ? FLOAT : INT); + } + + private void identifier() { + while (isIdentPart(peek())) advance(); + String text = source.substring(start, current); + add(KEYWORDS.getOrDefault(text, IDENT)); + } + + private boolean isAtEnd() { return current >= source.length(); } + private char advance() { char c = source.charAt(current++); column++; return c; } + private boolean match(char expected) { + if (isAtEnd() || source.charAt(current) != expected) return false; + current++; column++; return true; + } + private char peek() { return isAtEnd() ? '\0' : source.charAt(current); } + private char peekNext() { return current + 1 >= source.length() ? '\0' : source.charAt(current + 1); } + private void newline() { line++; column = 1; } + private void add(Token.Type type) { tokens.add(new Token(type, source.substring(start, current), line, startColumn)); } + private boolean isDigit(char c) { return c >= '0' && c <= '9'; } + private boolean isIdentStart(char c) { return Character.isLetter(c) || c == '_'; } + private boolean isIdentPart(char c) { return isIdentStart(c) || isDigit(c); } + private IllegalArgumentException error(String message) { + return new IllegalArgumentException("Oreslang lexer error at " + line + ":" + startColumn + ": " + message); + } +} diff --git a/src/main/java/dev/oreslang/parser/Parser.java b/src/main/java/dev/oreslang/parser/Parser.java new file mode 100644 index 00000000..4af07f58 --- /dev/null +++ b/src/main/java/dev/oreslang/parser/Parser.java @@ -0,0 +1,2048 @@ +package dev.oreslang.parser; + +import dev.oreslang.ast.Ast; + +import java.util.ArrayList; +import java.util.List; + +import static dev.oreslang.parser.Token.Type.*; + +public final class Parser { + public static final String ROOT_MODULE = "__root__"; + + private final List tokens; + private int current; + private boolean suppressRefinementOperators; + + public Parser(List tokens) { + this.tokens = List.copyOf(tokens); + } + + public static Ast.Program parse(String source) { + return new Parser(new Lexer(source).scan()).parseProgram(); + } + + public Ast.Program parseProgram() { + String namespace = null; + if (match(NAMESPACE)) { + namespace = consume(IDENT, "expected flat namespace name").lexeme(); + if (check(DOT)) throw error(peek(), "namespaces cannot be nested or dotted"); + consume(SEMICOLON, "namespace declaration must end with ';'"); + } + + List imports = new ArrayList<>(); + while (match(IMPORT)) imports.add(parseImport()); + + List modules = new ArrayList<>(); + List rootDeclarations = new ArrayList<>(); + + while (!check(EOF)) { + List annotations = parseAnnotations(); + Modifiers modifiers = parseModifiers(); + + if (match(DEFINE)) { + if (modifiers.shared) { + throw error(previous(), "'shared' must modify an actor declaration; use 'shared actor '"); + } + boolean afterDefineAbstract = match(ABSTRACT); + if (match(MODULE)) { + if (modifiers.visibility != Ast.Visibility.PRIVATE || modifiers.async || modifiers.nonLexical || modifiers.isStatic || modifiers.isAbstract || modifiers.shared) { + throw error(previous(), "modules do not accept function/class modifiers"); + } + modules.add(parseModule(annotations)); + continue; + } + if (match(CLASS)) { + if (modifiers.nonLexical) throw error(previous(), "'nlex' applies only to fnc, routine, or lambda"); + rootDeclarations.add(parseClass(modifiers.isAbstract || afterDefineAbstract)); + continue; + } + if (match(INTERFACE)) { + if (modifiers.nonLexical) throw error(previous(), "'nlex' applies only to fnc, routine, or lambda"); + rootDeclarations.add(parseInterface(modifiers.visibility)); + continue; + } + throw error(previous(), "expected module, class, or interface after 'define'"); + } + + Ast.Decl declaration = parseDeclarationAfterModifiers(annotations, modifiers); + if (declaration == null) throw error(peek(), "expected module or top-level declaration"); + rootDeclarations.add(declaration); + } + + if (!rootDeclarations.isEmpty()) { + modules.add(new Ast.ModuleDecl(ROOT_MODULE, List.of(), rootDeclarations)); + } + if (modules.isEmpty()) throw error(peek(), "a source file must define at least one module or top-level declaration"); + return new Ast.Program(namespace, imports, modules); + } + + private Ast.ImportDecl parseImport() { + Ast.ImportKind kind; + List names = new ArrayList<>(); + boolean wildcard = false; + String namespace = null; + + if (match(STAR)) { + kind = Ast.ImportKind.ALL; + wildcard = true; + consume(AS, "'import *' requires 'as '"); + namespace = consume(IDENT, "expected import namespace").lexeme(); + } else { + if (isLegacyFnSpelling()) { + throw error(peek(), "function imports use 'import fnc', not 'import fn'"); + } + if (match(MODULE)) kind = Ast.ImportKind.MODULE; + else if (match(ACTOR)) kind = Ast.ImportKind.ACTOR; + else if (match(CLASS)) kind = Ast.ImportKind.CLASS; + else if (match(FNC)) kind = Ast.ImportKind.FUNCTION; + else if (match(INTERFACE)) kind = Ast.ImportKind.INTERFACE; + else if (match(TRAIT)) kind = Ast.ImportKind.TRAIT; + else if (match(STRUCT)) kind = Ast.ImportKind.STRUCT; + else if (match(TYPE)) kind = Ast.ImportKind.TYPE; + else if (match(TYPES)) kind = Ast.ImportKind.TYPES; + else throw error(peek(), "expected module, actor, class, fnc, interface, trait, struct, type, types, or * after import"); + + if (match(STAR)) { + wildcard = true; + consume(AS, "wildcard import requires 'as '"); + namespace = consume(IDENT, "expected import namespace").lexeme(); + } else { + parseImportSelection(kind, names); + } + + if (!wildcard && match(AS)) { + if (names.size() != 1) { + throw error(previous(), "named import aliases require exactly one selected name"); + } + namespace = consume(IDENT, "expected import alias").lexeme(); + } + } + + consume(FROM, "expected 'from' in import"); + String path = consume(STRING, "expected quoted import path").lexeme(); + if (path.isBlank()) throw error(previous(), "import path cannot be empty"); + consume(SEMICOLON, "expected ';' after import"); + return new Ast.ImportDecl(kind, names, wildcard, namespace, path); + } + + private void parseImportSelection(Ast.ImportKind kind, List names) { + Token.Type closing = null; + if (match(LBRACE)) closing = RBRACE; + else if (match(LPAREN)) closing = RPAREN; + + if (closing != null) { + if (check(closing)) throw error(peek(), "import selection cannot be empty"); + do names.add(consumeImportName(kind)); while (match(COMMA)); + consume(closing, closing == RBRACE + ? "expected '}' after imported names" + : "expected ')' after imported names"); + return; + } + + names.add(consumeImportName(kind)); + while (match(COMMA)) names.add(consumeImportName(kind)); + } + + private String consumeImportName(Ast.ImportKind kind) { + if (kind == Ast.ImportKind.FUNCTION) { + return consumeCallableName("expected imported function name"); + } + return consume(IDENT, "expected imported name").lexeme(); + } + + private Ast.ModuleDecl parseModule(List annotations) { + String name = consume(IDENT, "expected flat module name").lexeme(); + if (check(DOT)) throw error(peek(), "modules cannot be nested or dotted"); + List declarations = new ArrayList<>(); + while (!check(END) && !check(EOF)) declarations.add(parseModuleMember()); + consume(END, "expected 'end' to close module " + name); + return new Ast.ModuleDecl(name, annotations, declarations); + } + + private Ast.Decl parseModuleMember() { + List annotations = parseAnnotations(); + Modifiers modifiers = parseModifiers(); + + if (match(DEFINE)) { + if (modifiers.shared) { + throw error(previous(), "'shared' must modify an actor declaration; use 'shared actor '"); + } + boolean afterDefineAbstract = match(ABSTRACT); + if (match(CLASS)) { + if (modifiers.nonLexical) throw error(previous(), "'nlex' applies only to fnc, routine, or lambda"); + return parseClass(modifiers.isAbstract || afterDefineAbstract); + } + if (match(INTERFACE)) { + if (modifiers.nonLexical) throw error(previous(), "'nlex' applies only to fnc, routine, or lambda"); + return parseInterface(modifiers.visibility); + } + throw error(previous(), "expected class or interface after 'define'"); + } + + Ast.Decl declaration = parseDeclarationAfterModifiers(annotations, modifiers); + if (declaration != null) return declaration; + throw error(peek(), "expected function, routine, class, interface, type, or binding declaration"); + } + + private Ast.Decl parseDeclarationAfterModifiers(List annotations, Modifiers modifiers) { + if (isLegacyFnSpelling()) { + throw error(peek(), "functions are declared with 'fnc', not 'fn'"); + } + if (match(ACTOR, ISOACTOR)) { + Token actorToken = previous(); + boolean isolated = actorToken.type() == ISOACTOR; + if (isolated && modifiers.shared) { + throw error(actorToken, "'shared isoactor' is contradictory; use either actor/shared actor or isoactor"); + } + Ast.ActorKind actorKind = isolated ? Ast.ActorKind.PRIVATE : Ast.ActorKind.SHARED; + if (isLegacyFnSpelling()) { + throw error(peek(), "actor functions are declared with 'actor fnc', not 'actor fn'"); + } + if (match(FNC)) return parseFunction(annotations, modifiers, Ast.CallableKind.FNC, actorKind); + if (match(ROUTINE)) return parseFunction(annotations, modifiers, Ast.CallableKind.ROUTINE, actorKind); + if (modifiers.async || modifiers.nonLexical || modifiers.isStatic || modifiers.isAbstract) { + throw error(actorToken, "actor declarations do not accept async, nlex, static, or abstract modifiers"); + } + return parseActorClass(actorKind); + } + if (modifiers.shared) throw error(previous(), "'shared' must modify an actor declaration"); + if (match(FNC)) return parseFunction(annotations, modifiers, Ast.CallableKind.FNC); + if (match(ROUTINE)) return parseFunction(annotations, modifiers, Ast.CallableKind.ROUTINE); + if (modifiers.nonLexical) throw error(peek(), "'nlex' applies only to fnc, routine, or lambda"); + if (match(INTERFACE)) return parseInterface(modifiers.visibility); + if (match(TYPE)) return parseTypeAlias(); + if (isBindingKind(peek().type())) return parseModuleBinding(annotations, modifiers.visibility); + return null; + } + + private Ast.FunctionDecl parseFunction(List annotations, Modifiers modifiers, Ast.CallableKind kind) { + return parseFunction(annotations, modifiers, kind, Ast.ActorKind.NONE); + } + + private Ast.FunctionDecl parseFunction( + List annotations, + Modifiers modifiers, + Ast.CallableKind kind, + Ast.ActorKind actorKind) { + if (modifiers.isStatic) throw error(previous(), "'static fnc' is only valid inside a class"); + if (modifiers.isAbstract) throw error(previous(), "top-level/module callables cannot be abstract"); + String name = consumeCallableName("expected callable name"); + List generics = parseGenericParameters(); + + if (match(EQUAL)) { + consume(PIPE, "lambda-style callable declarations use '= |Type name, ...| -> [ReturnType] { ... }'"); + List params = parseDeclaredPipeParameters(); + consume(PIPE, "expected closing '|' in lambda-style callable declaration"); + consume(ARROW, "lambda-style callable declarations use the slim arrow '->'"); + Ast.TypeRef returnType = check(LBRACE) + ? Ast.TypeRef.simple("void") + : parseTypeRef(); + List body = parseBlock(); + return new Ast.FunctionDecl(name, kind, modifiers.visibility, modifiers.async, modifiers.nonLexical, actorKind, + generics, params, returnType, annotations, body); + } + + consume(LPAREN, "expected '(' after callable name or '=' for lambda-style declaration"); + java.util.Set structuralNames = structuralAnnotationNames(annotations); + List params = applyStructuralAnnotations(parseParametersUntil(RPAREN, structuralNames), annotations); + consume(RPAREN, "expected ')' after parameters"); + Ast.TypeRef returnType = parseReturnType(annotations); + List body = parseBlock(); + return new Ast.FunctionDecl(name, kind, modifiers.visibility, modifiers.async, modifiers.nonLexical, actorKind, generics, params, + returnType, annotations, body); + } + + private Ast.ClassDecl parseClass(boolean isAbstract) { + String name = consume(IDENT, "expected class name").lexeme(); + List generics = parseGenericParameters(); + List parents = match(EXTENDS) ? parseTypeRefList() : List.of(); + List interfaces = match(IMPLEMENTS, IMPL) ? parseTypeRefList() : List.of(); + consume(AS, "expected 'as' after class header"); + List fields = new ArrayList<>(); + List methods = new ArrayList<>(); + + while (!check(END) && !check(EOF)) { + List annotations = parseAnnotations(); + Modifiers mods = parseModifiers(); + if (isLegacyFnSpelling()) { + if (mods.isStatic) throw error(peek(), "static class functions use 'static fnc', not 'static fn'"); + throw error(peek(), "instance methods omit 'fn'/'fnc'; declare the method name directly"); + } + if (mods.nonLexical) throw error(peek(), "'nlex' is unnecessary on class members; methods/static fnc never capture enclosing local scopes"); + if (isBindingKind(peek().type())) { + if (mods.isStatic) throw error(peek(), "static data members are not implemented yet; static class functions use 'static fnc'"); + fields.add(parseField(annotations, mods.visibility)); + continue; + } + if (check(IDENT) && checkNext(COLON)) { + if (mods.isStatic) throw error(peek(), "static data members are not implemented yet; static class functions use 'static fnc'"); + fields.add(parseColonField(annotations, mods.visibility)); + continue; + } + if (mods.isStatic) { + consume(FNC, "static class functions must be declared with 'static fnc'"); + if (mods.isAbstract) throw error(previous(), "static class functions cannot be abstract"); + } else if (check(FNC)) { + throw error(peek(), "instance methods omit 'fnc'; use 'static fnc' only for class functions"); + } + methods.add(parseMethod(annotations, mods)); + } + consume(END, "expected 'end' to close class " + name); + return new Ast.ClassDecl(name, isAbstract, Ast.ActorKind.NONE, generics, parents, interfaces, fields, methods); + } + + private Ast.ClassDecl parseActorClass(Ast.ActorKind actorKind) { + String name = consume(IDENT, "expected actor name").lexeme(); + List generics = parseGenericParameters(); + List parents = match(EXTENDS) ? parseTypeRefList() : List.of(); + List interfaces = match(IMPLEMENTS, IMPL) ? parseTypeRefList() : List.of(); + + boolean braceStyle = match(LBRACE); + Token.Type terminator = braceStyle ? RBRACE : END; + List fields = new ArrayList<>(); + List methods = new ArrayList<>(); + + while (!check(terminator) && !check(EOF)) { + List annotations = parseAnnotations(); + Modifiers mods = parseModifiers(); + if (isLegacyFnSpelling()) { + if (mods.isStatic) throw error(peek(), "static actor functions use 'static fnc', not 'static fn'"); + throw error(peek(), "actor methods omit 'fn'/'fnc'; declare the method name directly"); + } + if (mods.shared) throw error(previous(), "'shared' is only valid on an actor declaration, not its members"); + if (mods.nonLexical) throw error(previous(), "'nlex' is unnecessary on actor members; actor methods already execute in the actor turn scope"); + + if (isBindingKind(peek().type())) { + if (mods.isStatic) throw error(peek(), "actor state cannot be static"); + if (mods.visibility == Ast.Visibility.PUBLIC) { + throw error(peek(), "actor state fields are private; expose state through actor methods"); + } + fields.add(parseField(annotations, mods.visibility)); + continue; + } + + if (mods.isAbstract) throw error(peek(), "actor methods cannot be abstract"); + if (mods.isStatic) { + consume(FNC, "static actor functions must be declared with 'static fnc'"); + } else { + match(FNC); + } + methods.add(parseMethod(annotations, mods)); + } + + consume(terminator, braceStyle + ? "expected '}' to close actor " + name + : "expected 'end' to close actor " + name); + return new Ast.ClassDecl(name, false, actorKind, generics, parents, interfaces, fields, methods); + } + + private Ast.InterfaceDecl parseInterface(Ast.Visibility visibility) { + String name = consume(IDENT, "expected interface name").lexeme(); + List generics = parseGenericParameters(); + List parents = match(EXTENDS) ? parseTypeRefList() : List.of(); + boolean braceStyle = match(LBRACE); + Token.Type terminator = braceStyle ? RBRACE : END; + + List members = new ArrayList<>(); + while (!check(terminator) && !check(EOF)) { + parseAnnotations(); + parseModifiers(); + + if (isLegacyFnSpelling()) { + throw error(peek(), "interface functions are declared with 'fnc', not 'fn'"); + } + if (match(FNC)) { + String memberName = consumeCallableName("expected interface function name"); + List memberGenerics = parseGenericParameters(); + consume(LPAREN, "expected '(' after interface function name"); + List params = parseParametersUntil(RPAREN); + consume(RPAREN, "expected ')' after interface parameters"); + Ast.TypeRef returns = match(FAT_ARROW) ? parseTypeRef() : Ast.TypeRef.simple("void"); + consumeMemberTerminator(terminator, "interface function signature should end with ';'"); + members.add(new Ast.InterfaceFunctionDecl(memberName, memberGenerics, params, returns)); + continue; + } + + if (check(IDENT) && checkNext(COLON)) { + String fieldName = advance().lexeme(); + consume(COLON, "expected ':' after interface field name"); + Ast.TypeRef type = parseTypeRef(); + consumeMemberTerminator(terminator, "interface field signature should end with ';'"); + members.add(new Ast.InterfaceFieldDecl(fieldName, type)); + continue; + } + + if (isBindingKind(peek().type())) advance(); + Ast.TypeRef type = parseTypeRef(); + String fieldName = consume(IDENT, "expected interface field name").lexeme(); + consumeMemberTerminator(terminator, "interface field signature should end with ';'"); + members.add(new Ast.InterfaceFieldDecl(fieldName, type)); + } + + consume(terminator, braceStyle ? "expected '}' to close interface " + name : "expected 'end' to close interface " + name); + return new Ast.InterfaceDecl(name, visibility, generics, parents, members); + } + + private List parseTypeRefList() { + List refs = new ArrayList<>(); + do refs.add(parseTypeRef()); while (match(COMMA)); + return refs; + } + + private Ast.FieldDecl parseField(List annotations, Ast.Visibility visibility) { + Ast.BindingKind kind = parseBindingKind(); + Ast.TypeRef type = null; + String name; + if (check(IDENT) && checkNext(EQUAL)) { + name = advance().lexeme(); + } else { + type = parseTypeRef(); + name = consume(IDENT, "expected field name").lexeme(); + } + Ast.Expr initializer = match(EQUAL) ? parseExpression() : null; + if (type == null && initializer == null) { + throw error(previous(), "inferred field '" + name + "' requires an initializer"); + } + consumeStatementTerminator("field declaration should end with ';'"); + return new Ast.FieldDecl(name, visibility, kind, type, annotations, initializer); + } + + private Ast.FieldDecl parseColonField(List annotations, Ast.Visibility visibility) { + String name = consume(IDENT, "expected field name").lexeme(); + consume(COLON, "expected ':' after field name"); + Ast.TypeRef type = parseTypeRef(); + Ast.Expr initializer = match(EQUAL) ? parseExpression() : null; + Ast.BindingKind kind = hasAnnotation(annotations, "FromJson") ? Ast.BindingKind.LET : Ast.BindingKind.VAL; + consumeClassFieldTerminator("field declaration should end with ';'"); + return new Ast.FieldDecl(name, visibility, kind, type, annotations, initializer); + } + + private Ast.FieldDecl parseModuleBinding(List annotations, Ast.Visibility visibility) { + Ast.BindingKind kind = parseBindingKind(); + Ast.TypeRef type = null; + String name; + if (check(IDENT) && checkNext(EQUAL)) name = advance().lexeme(); + else { + type = parseTypeRef(); + name = consume(IDENT, "expected binding name").lexeme(); + } + consume(EQUAL, "module bindings require an initializer"); + Ast.Expr initializer = parseExpression(); + consumeStatementTerminator("module binding should end with ';'"); + return new Ast.FieldDecl(name, visibility, kind, type, annotations, initializer); + } + + private Ast.MethodDecl parseMethod(List annotations, Modifiers mods) { + String name = parseMethodName(); + List generics = parseGenericParameters(); + consume(LPAREN, "expected '(' after method name"); + + Ast.TypeRef receiverType = null; + List params; + if (mods.isStatic && check(SELF)) throw error(peek(), "static class functions do not have a self receiver"); + if (match(SELF)) { + receiverType = parseTypeRef(); + consume(RPAREN, "expected ')' after explicit self receiver"); + consume(LPAREN, "explicit receiver form is method(self Type)(params)"); + params = parseParametersUntil(RPAREN); + consume(RPAREN, "expected ')' after method parameters"); + } else { + params = parseParametersUntil(RPAREN); + consume(RPAREN, "expected ')' after method parameters"); + } + + Ast.TypeRef returnType = parseReturnType(annotations); + List body; + if (mods.isAbstract) { + consumeStatementTerminator("abstract method should end with ';'"); + body = List.of(); + } else body = parseBlock(); + return new Ast.MethodDecl(name, mods.visibility, mods.isStatic, mods.isAbstract, mods.async, + receiverType, generics, params, returnType, annotations, body); + } + + private String parseMethodName() { + if (match(LBRACKET)) { + String namespace = consume(IDENT, "expected symbol namespace").lexeme(); + consume(DOT, "expected '.' in symbol method"); + String symbol = consume(IDENT, "expected symbol name").lexeme(); + consume(RBRACKET, "expected ']' after symbol method"); + if (!namespace.equals("Symbol")) throw error(previous(), "symbol methods must use Symbol."); + return namespace + "." + symbol; + } + return consumeCallableName("expected method name (methods omit 'fnc')"); + } + + private Ast.TypeAliasDecl parseTypeAlias() { + String name = consume(IDENT, "expected type alias name").lexeme(); + List generics = parseGenericParameters(); + consume(EQUAL, "expected '=' in type alias"); + Ast.TypeRef target = parseTypeRef(); + consumeStatementTerminator("type alias should end with ';'"); + return new Ast.TypeAliasDecl(name, generics, target); + } + + private List parseAnnotations() { + List result = new ArrayList<>(); + while (match(AT)) { + String name = consume(IDENT, "expected annotation name").lexeme(); + List args = new ArrayList<>(); + Token.Type close = null; + if (match(LT)) close = GT; + else if (match(LPAREN)) close = RPAREN; + if (close != null) { + if (!check(close)) do args.add(parseTypeRef()); while (match(COMMA)); + consume(close, "expected annotation terminator"); + } + result.add(new Ast.Annotation(name, args)); + } + return result; + } + + private Modifiers parseModifiers() { + Ast.Visibility visibility = Ast.Visibility.PRIVATE; + boolean async = false; + boolean nonLexical = false; + boolean isStatic = false; + boolean isAbstract = false; + boolean shared = false; + boolean visibilitySeen = false; + boolean asyncSeen = false; + boolean nonLexicalSeen = false; + boolean staticSeen = false; + boolean abstractSeen = false; + boolean sharedSeen = false; + + while (true) { + if (match(PUB)) { + if (visibilitySeen) throw error(previous(), "duplicate/conflicting visibility modifier"); + visibilitySeen = true; + visibility = Ast.Visibility.PUBLIC; + } else if (match(PRIVATE)) { + if (visibilitySeen) throw error(previous(), "duplicate/conflicting visibility modifier"); + visibilitySeen = true; + visibility = Ast.Visibility.PRIVATE; + } else if (match(ASYNC)) { + if (asyncSeen) throw error(previous(), "duplicate 'async' modifier"); + asyncSeen = true; + async = true; + } else if (match(NLEX)) { + if (nonLexicalSeen) throw error(previous(), "duplicate 'nlex' modifier"); + nonLexicalSeen = true; + nonLexical = true; + } else if (match(STATIC)) { + if (staticSeen) throw error(previous(), "duplicate 'static' modifier"); + staticSeen = true; + isStatic = true; + } else if (match(ABSTRACT)) { + if (abstractSeen) throw error(previous(), "duplicate 'abstract' modifier"); + abstractSeen = true; + isAbstract = true; + } else if (matchContextualShared()) { + if (sharedSeen) throw error(previous(), "duplicate 'shared' modifier"); + sharedSeen = true; + shared = true; + } else { + break; + } + } + return new Modifiers(visibility, async, nonLexical, isStatic, isAbstract, shared); + } + + private Ast.TypeRef parseReturnType(List annotations) { + Ast.TypeRef annotated = null; + for (Ast.Annotation annotation : annotations) { + if (annotation.name().equals("Ret")) { + if (annotation.arguments().size() != 1) throw error(previous(), "@Ret requires exactly one type"); + annotated = annotation.arguments().getFirst(); + } + } + + if (check(FAT_ARROW)) { + throw error(peek(), + "fat arrow '=>' is reserved for function types/interface callable signatures; " + + "named executable callables use ': ReturnType' or '-> ReturnType'"); + } + + Ast.TypeRef declared = null; + if (match(COLON) || match(ARROW)) { + declared = parseTypeRef(); + } + if (annotated != null && declared != null && !sameType(annotated, declared)) { + throw error(previous(), "@Ret type and declared return type disagree"); + } + return declared != null ? declared : annotated != null ? annotated : Ast.TypeRef.simple("void"); + } + + private List parseDeclaredPipeParameters() { + if (check(PIPE)) return List.of(); + List params = new ArrayList<>(); + do { + Ast.TypeRef type = parseTypeRef(); + boolean mutable = match(MUT); + String name = consume(IDENT, "lambda-style callable declaration parameters require 'Type name'").lexeme(); + params.add(new Ast.Param(type, name, false, mutable)); + } while (match(COMMA)); + return List.copyOf(params); + } + + private boolean sameType(Ast.TypeRef a, Ast.TypeRef b) { + return a.name().equals(b.name()) && a.arguments().equals(b.arguments()) && a.inferArguments() == b.inferArguments(); + } + + private List parseGenericParameters() { + if (!match(LT)) return List.of(); + List names = new ArrayList<>(); + do names.add(consume(IDENT, "expected generic parameter name").lexeme()); while (match(COMMA)); + consume(GT, "expected '>' after generic parameters"); + return names; + } + + private List parseParametersUntil(Token.Type terminator) { + return parseParametersUntil(terminator, java.util.Set.of()); + } + + private List parseParametersUntil(Token.Type terminator, java.util.Set annotationStructuralNames) { + if (check(terminator)) return List.of(); + List params = new ArrayList<>(); + do { + boolean structural = false; + + // Name-first structural spelling: y structural Foo + if (check(IDENT) && checkNextLexeme("structural")) { + String name = advance().lexeme(); + Token marker = consume(IDENT, "expected structural"); + if (!marker.lexeme().equals("structural")) throw error(marker, "expected structural"); + Ast.TypeRef type = parseTypeRef(); + boolean mutable = match(MUT); + params.add(new Ast.Param(type, name, true, mutable)); + continue; + } + + // Function annotation spelling: @AllowStructural(y) fnc x(y Foo) + if (check(IDENT) && annotationStructuralNames.contains(peek().lexeme()) && checkNext(IDENT)) { + String name = advance().lexeme(); + Ast.TypeRef type = parseTypeRef(); + boolean mutable = match(MUT); + params.add(new Ast.Param(type, name, true, mutable)); + continue; + } + + // Existing type-first spelling: @Structural Foo y + if (match(AT)) { + String annotation = consume(IDENT, "expected parameter annotation").lexeme(); + if (!annotation.equals("Structural")) throw error(previous(), "only @Structural is currently supported on parameters"); + structural = true; + } + Ast.TypeRef type = parseTypeRef(); + boolean mutable = match(MUT); + String name = consume(IDENT, "expected parameter name").lexeme(); + params.add(new Ast.Param(type, name, structural, mutable)); + } while (match(COMMA)); + return params; + } + + private java.util.Set structuralAnnotationNames(List annotations) { + java.util.Set allowed = new java.util.HashSet<>(); + for (Ast.Annotation annotation : annotations) { + if (!annotation.name().equals("AllowStructural")) continue; + for (Ast.TypeRef argument : annotation.arguments()) { + if (!argument.arguments().isEmpty() || argument.inferArguments() || argument.isStringLiteral()) { + throw error(previous(), "@AllowStructural arguments must be parameter names"); + } + allowed.add(argument.name()); + } + } + return java.util.Set.copyOf(allowed); + } + + private List applyStructuralAnnotations(List params, List annotations) { + java.util.Set allowed = new java.util.HashSet<>(structuralAnnotationNames(annotations)); + if (allowed.isEmpty()) return params; + java.util.Set found = new java.util.HashSet<>(); + List result = new ArrayList<>(params.size()); + for (Ast.Param param : params) { + boolean structural = param.structural() || allowed.contains(param.name()); + if (allowed.contains(param.name())) found.add(param.name()); + result.add(new Ast.Param(param.type(), param.name(), structural, param.mutable())); + } + if (!found.equals(allowed)) { + java.util.Set missing = new java.util.HashSet<>(allowed); + missing.removeAll(found); + throw error(previous(), "@AllowStructural names unknown parameter(s): " + missing); + } + return List.copyOf(result); + } + + private Ast.TypeRef parseTypeRef() { + Ast.TypeRef first = parseTypeAtom(); + if (!match(PIPE)) return first; + + List options = new ArrayList<>(); + options.add(first); + do options.add(parseTypeAtom()); while (match(PIPE)); + return Ast.TypeRef.union(options); + } + + private Ast.TypeRef parseTypeAtom() { + if (match(TYPE)) { + Ast.TypeRef marked = parseTypeAtom(); + if (marked.name().startsWith("$")) { + throw error(previous(), "'type' alias marker must prefix a named type"); + } + return marked; + } + + if (match(AMP)) { + boolean mutable = match(MUT); + return Ast.TypeRef.borrowed(parseTypeAtom(), mutable); + } + if (match(STRING)) return Ast.TypeRef.stringLiteral(previous().lexeme()); + + if (match(LBRACKET)) { + List elements = new ArrayList<>(); + if (!check(RBRACKET)) { + do elements.add(parseTypeRef()); while (match(COMMA)); + } + consume(RBRACKET, "expected ']' after finite tuple type"); + return Ast.TypeRef.tupleType(elements); + } + + if (match(LBRACE)) { + java.util.LinkedHashMap members = new java.util.LinkedHashMap<>(); + if (!check(RBRACE)) { + do { + String field = consumeStaticObjectKeyName("expected record type field name"); + consume(COLON, "expected ':' after record type field name"); + Ast.TypeRef fieldType = parseTypeRef(); + if (members.putIfAbsent(field, fieldType) != null) { + throw error(previous(), "duplicate record type field '" + field + "'"); + } + } while (match(COMMA)); + } + consume(RBRACE, "expected '}' after record type"); + return Ast.TypeRef.recordType(members); + } + + if (match(TYPEOF)) { + if (isLegacyFnSpelling()) { + throw error(peek(), "function types use 'typeof fnc(...) => ReturnType', not 'typeof fn(...)'"); + } + consume(FNC, "typeof function types use 'typeof fnc(...) => ReturnType'"); + return parseFunctionTypeSignature(); + } + + if (check(LPAREN) && looksLikeFunctionType()) return parseFunctionTypeSignature(); + if (match(LPAREN)) { + Ast.TypeRef grouped = parseTypeRef(); + consume(RPAREN, "expected ')' after grouped type"); + return grouped; + } + + String name; + if (match(VOID)) name = "void"; + else if (match(SELF)) name = "self"; + else if (match(NULL)) name = "null"; + else name = parseQualifiedName(); + + List args = new ArrayList<>(); + boolean infer = false; + if (match(LT)) { + if (match(GT)) infer = true; + else { + do args.add(parseTypeRef()); while (match(COMMA)); + consume(GT, "expected '>' after type arguments"); + } + } + return new Ast.TypeRef(name, args, infer); + } + + private Ast.TypeRef parseFunctionTypeSignature() { + consume(LPAREN, "expected '(' in function type"); + List params = new ArrayList<>(); + if (!check(RPAREN)) { + do { + Ast.TypeRef paramType = parseTypeRef(); + if (check(IDENT)) advance(); // optional documentation-only parameter name + params.add(paramType); + } while (match(COMMA)); + } + consume(RPAREN, "expected ')' after function type parameters"); + consume(FAT_ARROW, "function types use the fat arrow '=>'"); + Ast.TypeRef result = parseTypeRef(); + return Ast.TypeRef.functionType(params, result); + } + + private boolean looksLikeFunctionType() { + int depth = 0; + for (int i = current; i < tokens.size(); i++) { + Token.Type type = tokens.get(i).type(); + if (type == LPAREN) depth++; + else if (type == RPAREN) { + depth--; + if (depth == 0) return i + 1 < tokens.size() && tokens.get(i + 1).type() == FAT_ARROW; + } + } + return false; + } + + private String parseQualifiedName() { + StringBuilder name = new StringBuilder(consume(IDENT, "expected name").lexeme()); + while (match(DOT)) name.append('.').append(consume(IDENT, "expected name after '.'").lexeme()); + return name.toString(); + } + + private List parseBlock() { + consume(LBRACE, "expected '{'"); + List body = new ArrayList<>(); + while (!check(RBRACE) && !check(EOF)) body.add(parseStatement()); + consume(RBRACE, "expected '}'"); + return body; + } + + private Ast.Stmt parseStatement() { + if (looksLikeStaticSelectStatement()) return parseStaticSelectStatement(); + if (looksLikeImmediateChannelExpression()) { + Ast.Expr expression = parseExpression(); + consumeStatementTerminator("channel probe expression should end with ';'"); + return new Ast.ExprStmt(expression); + } + if (isBindingKind(peek().type()) && looksLikePrefixedDestructure()) { + Ast.BindingKind inherited = parseBindingKind(); + return parseDestructure(check(LBRACKET) ? Ast.DestructureKind.SEQUENCE : Ast.DestructureKind.OBJECT, inherited); + } + if (isBindingKind(peek().type())) return parseBindingStatement(); + if (check(LBRACKET) && looksLikeDestructure()) return parseDestructure(Ast.DestructureKind.SEQUENCE, null); + if (check(LBRACE) && looksLikeDestructure()) return parseDestructure(Ast.DestructureKind.OBJECT, null); + if (match(RETURN)) { + Ast.Expr value = check(SEMICOLON) || isSafeStatementBoundary() ? null : parseExpression(); + consumeStatementTerminator("return statement should end with ';'"); + return new Ast.ReturnStmt(value); + } + if (match(DEFER)) { + Ast.Expr expression = parseExpression(); + consumeStatementTerminator("defer statement should end with ';'"); + return new Ast.DeferStmt(expression); + } + if (check(BLOCK) && checkNext(LBRACE)) { + advance(); + return new Ast.BlockStmt(parseBlock()); + } + if (match(BREAK)) { + consumeStatementTerminator("break statement should end with ';'"); + return new Ast.BreakStmt(); + } + if (match(CONTINUE)) { + consumeStatementTerminator("continue statement should end with ';'"); + return new Ast.ContinueStmt(); + } + if (match(IF)) return parseIf(); + if (match(MATCH)) return parseMatch(); + if (match(SWITCH)) return parseSwitch(); + if (match(TRY)) return parseTry(); + if (check(LOOP) && (checkNext(LBRACE) || checkNext(DO))) { + advance(); + return new Ast.LoopStmt(parseLoopBody()); + } + if (match(FOR)) return parseFor(); + + Ast.Expr expression = parseExpression(); + consumeStatementTerminator("expression statement should end with ';'"); + return new Ast.ExprStmt(expression); + } + + private boolean looksLikeImmediateChannelExpression() { + if (!check(TRY) || current + 1 >= tokens.size()) return false; + Token.Type next = tokens.get(current + 1).type(); + return next == READCH || next == WRITECH || next == SELECT; + } + + private boolean looksLikeStaticSelectStatement() { + int i = current; + if (i >= tokens.size()) return false; + Token.Type first = tokens.get(i).type(); + if (first == NB || first == TRY) i++; + if (i >= tokens.size() || tokens.get(i).type() != SELECT) return false; + i++; + + if (i < tokens.size() && tokens.get(i).type() == FIRST) { + i++; + } else if (i < tokens.size() + && tokens.get(i).type() == IDENT + && (tokens.get(i).lexeme().equals("fair") + || tokens.get(i).lexeme().equals("random"))) { + i++; + } + return i < tokens.size() && tokens.get(i).type() == LBRACE; + } + + private Ast.SelectStmt parseStaticSelectStatement() { + Ast.WaitMode mode = Ast.WaitMode.BLOCKING; + if (match(NB)) mode = Ast.WaitMode.NONBLOCKING; + else if (match(TRY)) mode = Ast.WaitMode.IMMEDIATE; + + consume(SELECT, "expected 'select'"); + Ast.SelectPolicy policy = parseSelectPolicy(); + consume(LBRACE, "static select requires '{ ... }'; use 'select from cases' for dynamic select"); + + List arms = new ArrayList<>(); + while (!check(RBRACE) && !check(EOF)) { + if (match(CASE)) { + Ast.ChannelOperation operation; + Ast.Expr channel; + Ast.Expr value = null; + Ast.BindingKind bindingKind = null; + String bindingName = null; + + if (match(READCH)) { + operation = Ast.ChannelOperation.READ; + channel = parseExpression(); + consume(COLON, "readch select case requires ':'"); + if (isBindingKind(peek().type())) { + bindingKind = parseBindingKind(); + bindingName = consume(IDENT, "readch case binding requires a name").lexeme(); + match(SEMICOLON); + } + } else if (match(WRITECH)) { + operation = Ast.ChannelOperation.WRITE; + channel = parseExpression(); + consume(COMMA, "writech select case requires 'channel, value'"); + value = parseExpression(); + consume(COLON, "writech select case requires ':'"); + } else { + throw error(peek(), "select case must start with readch or writech"); + } + + List body = new ArrayList<>(); + while (!check(CASE) && !check(DEFAULT) && !check(RBRACE) && !check(EOF)) { + body.add(parseStatement()); + } + arms.add(new Ast.SelectArm( + operation, + channel, + value, + bindingKind, + bindingName, + body)); + continue; + } + + if (match(DEFAULT)) { + consume(COLON, "select default arm requires ':'"); + List body = new ArrayList<>(); + while (!check(CASE) && !check(DEFAULT) && !check(RBRACE) && !check(EOF)) { + body.add(parseStatement()); + } + arms.add(new Ast.SelectArm( + Ast.ChannelOperation.DEFAULT, + null, + null, + null, + null, + body)); + continue; + } + + throw error(peek(), "expected case/default inside select"); + } + + consume(RBRACE, "expected '}' after select"); + return new Ast.SelectStmt(mode, policy, arms); + } + + private Ast.SelectPolicy parseSelectPolicy() { + if (match(FIRST)) return Ast.SelectPolicy.PRIORITY; + if (check(IDENT) && peek().lexeme().equals("fair")) { + advance(); + return Ast.SelectPolicy.FAIR; + } + if (check(IDENT) && peek().lexeme().equals("random")) { + advance(); + return Ast.SelectPolicy.RANDOM; + } + return Ast.SelectPolicy.FAIR; + } + + private Ast.Stmt parseFor() { + if (!match(LPAREN)) { + if (looksLikeUnparenthesizedForOf()) { + Ast.BindingKind kind = isBindingKind(peek().type()) + ? parseBindingKind() + : Ast.BindingKind.VAL; + if (looksLikeForOfDestructurePattern()) { + List bindings = parseForSequenceBindings(kind); + consume(OF, "iterator destructuring uses 'for [a, b] of iterable'"); + Ast.Expr iterable = parseExpression(); + return new Ast.ForOfDestructureStmt(bindings, iterable, parseLoopBody()); + } + String name = consume(IDENT, "iterator for-loop requires a binding name").lexeme(); + consume(OF, "iterator for-loop shorthand uses 'for x of iterable'"); + Ast.Expr iterable = parseExpression(); + return new Ast.ForOfStmt(kind, name, iterable, parseLoopBody()); + } + + Ast.Stmt initializer = parseUnparenthesizedForInitializer(); + consume(SEMICOLON, "expected ';' after for initializer"); + Ast.Expr condition = check(SEMICOLON) ? null : parseExpression(); + consume(SEMICOLON, "expected ';' after for condition"); + Ast.Expr update = check(LBRACE) || check(DO) ? null : parseForUpdate(); + return new Ast.ForStmt(initializer, condition, update, parseLoopBody()); + } + + if (isBindingKind(peek().type())) { + Ast.BindingKind kind = parseBindingKind(); + if (check(LBRACKET)) { + List bindings = parseForSequenceBindings(kind); + consume(OF, "expected 'of' after for-of destructure pattern"); + Ast.Expr iterable = parseExpression(); + consume(RPAREN, "expected ')' after for-of header"); + return new Ast.ForOfDestructureStmt(bindings, iterable, parseLoopBody()); + } + if (check(IDENT) && checkNext(OF)) { + String name = advance().lexeme(); + consume(OF, "expected 'of' in for-of loop"); + Ast.Expr iterable = parseExpression(); + consume(RPAREN, "expected ')' after for-of header"); + return new Ast.ForOfStmt(kind, name, iterable, parseLoopBody()); + } + + Ast.TypeRef type = null; + String name; + if (check(IDENT) && checkNext(EQUAL)) name = advance().lexeme(); + else { + type = parseTypeRef(); + name = consume(IDENT, "expected loop initializer binding name").lexeme(); + } + consume(EQUAL, "for initializer binding requires '='"); + Ast.Expr initializer = parseExpression(); + Ast.BindingStmt init = new Ast.BindingStmt(kind, type, name, initializer); + consume(SEMICOLON, "expected ';' after for initializer"); + Ast.Expr condition = check(SEMICOLON) ? null : parseExpression(); + consume(SEMICOLON, "expected ';' after for condition"); + Ast.Expr update = check(RPAREN) ? null : parseForUpdate(); + consume(RPAREN, "expected ')' after for header"); + return new Ast.ForStmt(init, condition, update, parseLoopBody()); + } + + if (looksLikeTypedForInitializer()) { + Ast.TypeRef type = parseTypeRef(); + String name = consume(IDENT, "expected typed loop binding name").lexeme(); + consume(EQUAL, "typed for initializer requires '='"); + Ast.BindingStmt init = new Ast.BindingStmt( + Ast.BindingKind.LET, + type, + name, + parseExpression()); + consume(SEMICOLON, "expected ';' after for initializer"); + Ast.Expr condition = check(SEMICOLON) ? null : parseExpression(); + consume(SEMICOLON, "expected ';' after for condition"); + Ast.Expr update = check(RPAREN) ? null : parseForUpdate(); + consume(RPAREN, "expected ')' after for header"); + return new Ast.ForStmt(init, condition, update, parseLoopBody()); + } + + if (looksLikeForOfDestructurePattern()) { + List bindings = parseForSequenceBindings(Ast.BindingKind.VAL); + consume(OF, "expected 'of' after for-of destructure pattern"); + Ast.Expr iterable = parseExpression(); + consume(RPAREN, "expected ')' after for-of header"); + return new Ast.ForOfDestructureStmt(bindings, iterable, parseLoopBody()); + } + + if (check(IDENT) && checkNext(OF)) { + String name = advance().lexeme(); + consume(OF, "expected 'of' in for-of loop"); + Ast.Expr iterable = parseExpression(); + consume(RPAREN, "expected ')' after for-of header"); + return new Ast.ForOfStmt(Ast.BindingKind.VAL, name, iterable, parseLoopBody()); + } + + Ast.Stmt initializer = null; + if (!check(SEMICOLON)) initializer = new Ast.ExprStmt(parseExpression()); + consume(SEMICOLON, "expected ';' after for initializer"); + Ast.Expr condition = check(SEMICOLON) ? null : parseExpression(); + consume(SEMICOLON, "expected ';' after for condition"); + Ast.Expr update = check(RPAREN) ? null : parseForUpdate(); + consume(RPAREN, "expected ')' after for header"); + return new Ast.ForStmt(initializer, condition, update, parseLoopBody()); + } + + private boolean looksLikeUnparenthesizedForOf() { + int mark = current; + try { + if (isBindingKind(peek().type())) parseBindingKind(); + if (looksLikeForOfDestructurePattern()) return true; + return check(IDENT) && checkNext(OF); + } finally { + current = mark; + } + } + + private boolean looksLikeForOfDestructurePattern() { + if (!check(LBRACKET)) return false; + int depth = 0; + for (int i = current; i < tokens.size(); i++) { + Token.Type type = tokens.get(i).type(); + if (type == LBRACKET) depth++; + else if (type == RBRACKET) { + depth--; + if (depth == 0) { + return i + 1 < tokens.size() && tokens.get(i + 1).type() == OF; + } + } + } + return false; + } + + private Ast.Stmt parseUnparenthesizedForInitializer() { + if (check(SEMICOLON)) return null; + + if (isBindingKind(peek().type())) { + Ast.BindingKind kind = parseBindingKind(); + Ast.TypeRef type = null; + String name; + if (check(IDENT) && checkNext(EQUAL)) { + name = advance().lexeme(); + } else { + type = parseTypeRef(); + name = consume(IDENT, "expected loop initializer binding name").lexeme(); + } + consume(EQUAL, "for initializer binding requires '='"); + return new Ast.BindingStmt(kind, type, name, parseExpression()); + } + + if (looksLikeTypedForInitializer()) { + Ast.TypeRef type = parseTypeRef(); + String name = consume(IDENT, "expected typed loop binding name").lexeme(); + consume(EQUAL, "typed for initializer requires '='"); + return new Ast.BindingStmt(Ast.BindingKind.LET, type, name, parseExpression()); + } + + return new Ast.ExprStmt(parseExpression()); + } + + private boolean looksLikeTypedForInitializer() { + int mark = current; + try { + parseTypeRef(); + return check(IDENT) && checkNext(EQUAL); + } catch (IllegalArgumentException ignored) { + return false; + } finally { + current = mark; + } + } + + private Ast.Expr parseForUpdate() { + int mark = current; + if (check(IDENT)) { + String name = advance().lexeme(); + if (matchAdjacentPair(PLUS)) { + Ast.NameExpr target = new Ast.NameExpr(name); + return new Ast.AssignExpr( + target, + new Ast.BinaryExpr("+", new Ast.NameExpr(name), new Ast.LiteralExpr(1L))); + } + if (matchAdjacentPair(MINUS)) { + Ast.NameExpr target = new Ast.NameExpr(name); + return new Ast.AssignExpr( + target, + new Ast.BinaryExpr("-", new Ast.NameExpr(name), new Ast.LiteralExpr(1L))); + } + current = mark; + } + return parseExpression(); + } + + private List parseForSequenceBindings(Ast.BindingKind inheritedKind) { + consume(LBRACKET, "expected '[' to start for-of destructure pattern"); + if (check(RBRACKET)) throw error(peek(), "for-of destructure pattern cannot be empty"); + + List bindings = new ArrayList<>(); + Ast.BindingKind currentKind = inheritedKind == null ? Ast.BindingKind.VAL : inheritedKind; + do { + if (isBindingKind(peek().type())) currentKind = parseBindingKind(); + if (isDiscardToken(peek())) { + advance(); + bindings.add(Ast.DestructureBinding.discard()); + } else { + String name = consume(IDENT, "expected binding name in for-of destructure pattern").lexeme(); + bindings.add(new Ast.DestructureBinding(currentKind, name)); + } + } while (match(COMMA)); + + consume(RBRACKET, "expected ']' after for-of destructure pattern"); + return List.copyOf(bindings); + } + + private List parseLoopBody() { + if (check(LBRACE)) return parseBlock(); + if (!match(DO)) { + throw error(peek(), "loop body must use '{ ... }' or 'do ... done'"); + } + + List body = new ArrayList<>(); + while (!check(EOF) && !isBareDoneDelimiter()) { + body.add(parseStatement()); + } + consume(DONE, "expected 'done' to close loop body"); + return body; + } + + private boolean isBareDoneDelimiter() { + return check(DONE) && !reservedCallableNameFollowedByInvocation(current); + } + + private Ast.BindingStmt parseBindingStatement() { + Ast.BindingKind kind = parseBindingKind(); + Ast.TypeRef type = null; + String name; + if (check(IDENT) && checkNext(EQUAL)) name = advance().lexeme(); + else { + type = parseTypeRef(); + name = consume(IDENT, "expected binding name").lexeme(); + } + consume(EQUAL, "binding requires initializer"); + Ast.Expr initializer = parseExpression(); + consumeStatementTerminator("binding should end with ';'"); + return new Ast.BindingStmt(kind, type, name, initializer); + } + + private Ast.DestructureStmt parseDestructure(Ast.DestructureKind kind, Ast.BindingKind inheritedKind) { + Token.Type close; + if (kind == Ast.DestructureKind.SEQUENCE) { + consume(LBRACKET, "expected '['"); + close = RBRACKET; + } else { + consume(LBRACE, "expected '{'"); + close = RBRACE; + } + + if (check(close)) throw error(peek(), "destructure pattern cannot be empty"); + + List bindings = new ArrayList<>(); + Ast.BindingKind currentKind = inheritedKind; + do { + if (isBindingKind(peek().type())) currentKind = parseBindingKind(); + + if (isDiscardToken(peek())) { + if (kind == Ast.DestructureKind.OBJECT) { + throw error(peek(), "bare '_' discard is only valid in sequence destructuring"); + } + advance(); + bindings.add(Ast.DestructureBinding.discard()); + continue; + } + + if (currentKind == null) { + throw error(peek(), "destructure binding kind must be declared before the first binding"); + } + String name = consume(IDENT, "expected binding name in destructure").lexeme(); + bindings.add(new Ast.DestructureBinding(currentKind, name)); + } while (match(COMMA)); + + consume(close, kind == Ast.DestructureKind.SEQUENCE ? "expected ']'" : "expected '}'"); + consume(EQUAL, "expected '=' after destructure pattern"); + Ast.Expr initializer = parseExpression(); + consumeStatementTerminator("destructure should end with ';'"); + return new Ast.DestructureStmt(kind, bindings, initializer); + } + + private boolean looksLikeDestructure() { + if (current + 1 >= tokens.size()) return false; + Token first = tokens.get(current + 1); + return isBindingKind(first.type()) || isDiscardToken(first); + } + + private boolean looksLikePrefixedDestructure() { + if (current + 2 >= tokens.size()) return false; + Token.Type open = tokens.get(current + 1).type(); + Token.Type close; + if (open == LBRACKET) close = RBRACKET; + else if (open == LBRACE) close = RBRACE; + else return false; + + int depth = 0; + for (int i = current + 1; i < tokens.size(); i++) { + Token.Type type = tokens.get(i).type(); + if (type == open) depth++; + else if (type == close) { + depth--; + if (depth == 0) { + return i + 1 < tokens.size() && tokens.get(i + 1).type() == EQUAL; + } + } + } + return false; + } + + private boolean isDiscardToken(Token token) { + return token.type() == IDENT && token.lexeme().equals("_"); + } + + private Ast.IfStmt parseIf() { + List branches = new ArrayList<>(); + Ast.Expr condition = parseCondition(); + + // Brace form still obeys the universal Oreslang invariant: every if closes with fi. + // Braces delimit branch bodies; they never replace the structural terminator. + if (check(LBRACE)) { + branches.add(new Ast.IfBranch(condition, parseBlock())); + while (match(ELSEIF)) { + condition = parseCondition(); + branches.add(new Ast.IfBranch(condition, parseBlock())); + } + + List elseBody = List.of(); + if (match(ELSE)) elseBody = parseBlock(); + consume(FI, "expected 'fi' to close if"); + return new Ast.IfStmt(branches, elseBody); + } + + // Keyword-delimited form: if condition then ... elseif condition then ... else ... fi. + // 'do' remains accepted as a compatibility spelling for existing source. + match(SEMICOLON); + if (!match(THEN, DO)) throw error(peek(), "expected 'then' after if condition"); + List body = parseUntil(ELSEIF, ELSE, FI); + branches.add(new Ast.IfBranch(condition, body)); + + while (match(ELSEIF)) { + condition = parseCondition(); + match(SEMICOLON); + if (!match(THEN, DO)) throw error(peek(), "expected 'then' after elseif condition"); + body = parseUntil(ELSEIF, ELSE, FI); + branches.add(new Ast.IfBranch(condition, body)); + } + + List elseBody = List.of(); + if (match(ELSE)) elseBody = parseUntil(FI); + consume(FI, "expected 'fi' to close if"); + return new Ast.IfStmt(branches, elseBody); + } + + private Ast.Expr parseCondition() { + Ast.Expr expression = normalizeLegacyConditionPipe(parseLogicalOr()); + // Legacy condition-only comma means logical AND. Prefer && in new code. + while (match(COMMA)) { + expression = new Ast.BinaryExpr( + "&&", + expression, + normalizeLegacyConditionPipe(parseLogicalOr())); + } + return expression; + } + + private Ast.Expr normalizeLegacyConditionPipe(Ast.Expr expr) { + if (expr instanceof Ast.BinaryExpr binary) { + String operator = binary.operator().equals("|") ? "||" : binary.operator(); + return new Ast.BinaryExpr( + operator, + normalizeLegacyConditionPipe(binary.left()), + normalizeLegacyConditionPipe(binary.right())); + } + return expr; + } + + private Ast.MatchStmt parseMatch() { + boolean ordered = check(IDENT) && peek().lexeme().equals("first"); + if (ordered) advance(); + + boolean previousSuppression = suppressRefinementOperators; + suppressRefinementOperators = true; + Ast.Expr subject; + try { + subject = parseExpression(); + } finally { + suppressRefinementOperators = previousSuppression; + } + match(SEMICOLON); + + List arms = new ArrayList<>(); + while (!check(END) && !check(EOF)) { + Ast.Pattern pattern = match(ELSE) ? new Ast.WildcardPattern() : parsePattern(); + Ast.Expr guard = match(WHEN) ? parseExpression() : null; + if (check(FAT_ARROW)) { + throw error(peek(), "match implementations use the slim arrow '->'; '=>' is reserved for type definitions"); + } + consume(ARROW, "match arms use the slim arrow '->'"); + List body = parseBlock(); + arms.add(new Ast.MatchArm(pattern, guard, body)); + } + consume(END, "expected 'end' to close match"); + if (arms.isEmpty()) throw error(previous(), "match requires at least one arm"); + return new Ast.MatchStmt(subject, ordered, arms); + } + + private Ast.Pattern parsePattern() { + if (match(IS)) { + Ast.TypeRef target = parseTypeRef(); + String binding = check(IDENT) && !peek().lexeme().equals("_") ? advance().lexeme() : null; + return new Ast.TypePattern(target, binding); + } + if (match(INT)) return new Ast.LiteralPattern(Long.parseLong(previous().lexeme().replace("_", ""))); + if (match(FLOAT)) return new Ast.LiteralPattern(Double.parseDouble(previous().lexeme().replace("_", ""))); + if (match(STRING)) return new Ast.LiteralPattern(previous().lexeme()); + if (match(TRUE)) return new Ast.LiteralPattern(Boolean.TRUE); + if (match(FALSE)) return new Ast.LiteralPattern(Boolean.FALSE); + if (check(IDENT) && peek().lexeme().equals("_")) { + advance(); + return new Ast.WildcardPattern(); + } + if (check(IDENT)) { + String name = advance().lexeme(); + if (match(LPAREN)) { + List args = new ArrayList<>(); + if (!check(RPAREN)) { + do args.add(parsePattern()); while (match(COMMA)); + } + consume(RPAREN, "expected ')' after constructor pattern"); + return new Ast.ConstructorPattern(name, args); + } + // Upper-case bare names are zero-arity constructors; lower-case names bind. + if (!name.isEmpty() && Character.isUpperCase(name.charAt(0))) { + return new Ast.ConstructorPattern(name, List.of()); + } + return new Ast.BindingPattern(name); + } + throw error(peek(), "expected match pattern"); + } + + private Ast.SwitchStmt parseSwitch() { + Ast.Expr subject = parseExpression(); + match(SEMICOLON); + List cases = new ArrayList<>(); + List defaultBody = List.of(); + boolean sawDefault = false; + + while (!check(END) && !check(EOF)) { + if (match(CASE)) { + if (sawDefault) throw error(previous(), "switch case cannot appear after default"); + List constants = new ArrayList<>(); + do constants.add(parseExpression()); while (match(COMMA)); + if (check(FAT_ARROW)) { + throw error(peek(), "switch implementations use the slim arrow '->'; '=>' is reserved for type definitions"); + } + consume(ARROW, "switch cases use the slim arrow '->'"); + cases.add(new Ast.SwitchCase(constants, parseBlock())); + continue; + } + if (match(DEFAULT)) { + if (sawDefault) throw error(previous(), "switch can contain only one default arm"); + sawDefault = true; + if (check(FAT_ARROW)) { + throw error(peek(), "switch implementations use the slim arrow '->'; '=>' is reserved for type definitions"); + } + consume(ARROW, "switch default uses the slim arrow '->'"); + defaultBody = parseBlock(); + continue; + } + throw error(peek(), "expected 'case', 'default', or 'end' in switch"); + } + + consume(END, "expected 'end' to close switch"); + return new Ast.SwitchStmt(subject, cases, defaultBody); + } + + private Ast.TryStmt parseTry() { + List body = parseBlock(); + consume(CATCH, "expected catch after try block"); + consume(LPAREN, "expected '(' after catch"); + String error = consume(IDENT, "expected catch binding").lexeme(); + consume(RPAREN, "expected ')' after catch binding"); + List catchBody = parseBlock(); + List finallyBody = match(FINALLY) ? parseBlock() : List.of(); + return new Ast.TryStmt(body, error, catchBody, finallyBody); + } + + private List parseUntil(Token.Type... terminators) { + List body = new ArrayList<>(); + outer: while (!check(EOF)) { + for (Token.Type terminator : terminators) if (check(terminator)) break outer; + body.add(parseStatement()); + } + return body; + } + + public Ast.Expr parseExpression() { return parseAssignment(); } + + private Ast.Expr parseAssignment() { + Ast.Expr expr = parseConditional(); + if (match(EQUAL)) { + if (!(expr instanceof Ast.NameExpr) && !(expr instanceof Ast.MemberExpr) && !(expr instanceof Ast.IndexExpr)) { + throw error(previous(), "assignment target must be a local, field, or index"); + } + return new Ast.AssignExpr(expr, parseAssignment()); + } + return expr; + } + + private Ast.Expr parseConditional() { + Ast.Expr condition = parseLogicalOr(); + if (!match(QUESTION)) return condition; + Ast.Expr whenTrue = parseAssignment(); + consume(COLON, "expected ':' in ternary expression"); + Ast.Expr whenFalse = parseConditional(); + return new Ast.ConditionalExpr(condition, whenTrue, whenFalse); + } + + private Ast.Expr parseLogicalOr() { + Ast.Expr expr = parseLogicalXor(); + while (matchAdjacentPair(PIPE)) expr = new Ast.BinaryExpr("||", expr, parseLogicalXor()); + return expr; + } + + private Ast.Expr parseLogicalXor() { + Ast.Expr expr = parseLogicalAnd(); + while (matchAdjacentPair(CARET)) expr = new Ast.BinaryExpr("^^", expr, parseLogicalAnd()); + return expr; + } + + private Ast.Expr parseLogicalAnd() { + Ast.Expr expr = parseBitwiseOr(); + while (matchAdjacentPair(AMP)) expr = new Ast.BinaryExpr("&&", expr, parseBitwiseOr()); + return expr; + } + + private Ast.Expr parseBitwiseOr() { + Ast.Expr expr = parseBitwiseXor(); + while (matchSingleOperator(PIPE)) expr = new Ast.BinaryExpr("|", expr, parseBitwiseXor()); + return expr; + } + + private Ast.Expr parseBitwiseXor() { + Ast.Expr expr = parseBitwiseAnd(); + while (matchSingleOperator(CARET)) expr = new Ast.BinaryExpr("^", expr, parseBitwiseAnd()); + return expr; + } + + private Ast.Expr parseBitwiseAnd() { + Ast.Expr expr = parseEquality(); + while (matchSingleOperator(AMP)) expr = new Ast.BinaryExpr("&", expr, parseEquality()); + return expr; + } + + private Ast.Expr parseEquality() { + Ast.Expr expr = parseComparison(); + while (match(EQUAL_EQUAL, BANG_EQUAL)) { + String op = previous().lexeme(); + expr = new Ast.BinaryExpr(op, expr, parseComparison()); + } + return expr; + } + + private Ast.Expr parseComparison() { + Ast.Expr expr = parseShift(); + while (true) { + if (match(LT, LTE, GT, GTE)) { + String op = previous().lexeme(); + expr = new Ast.BinaryExpr(op, expr, parseShift()); + continue; + } + if (!suppressRefinementOperators && match(IS)) { + Ast.TypeRef target = parseTypeRef(); + String binding = check(IDENT) && !peek().lexeme().equals("_") ? advance().lexeme() : null; + expr = new Ast.TypeTestExpr(expr, target, binding); + continue; + } + if (!suppressRefinementOperators && match(MATCHES)) { + expr = new Ast.PatternTestExpr(expr, parsePattern()); + continue; + } + if (!suppressRefinementOperators && match(AS)) { + boolean optional = match(QUESTION); + expr = new Ast.CastExpr(expr, parseTypeRef(), + optional ? Ast.CastMode.OPTIONAL : Ast.CastMode.CHECKED); + continue; + } + break; + } + return expr; + } + + private Ast.Expr parseShift() { + Ast.Expr expr = parseAdditive(); + while (true) { + String op; + if (matchAdjacentTriple(GT)) op = ">>>"; + else if (matchAdjacentPair(LT)) op = "<<"; + else if (matchAdjacentPair(GT)) op = ">>"; + else break; + expr = new Ast.BinaryExpr(op, expr, parseAdditive()); + } + return expr; + } + + private Ast.Expr parseAdditive() { + Ast.Expr expr = parseMultiplicative(); + while (match(PLUS, MINUS)) { + String op = previous().lexeme(); + expr = new Ast.BinaryExpr(op, expr, parseMultiplicative()); + } + return expr; + } + + private Ast.Expr parseMultiplicative() { + Ast.Expr expr = parseUnary(); + while (match(STAR, SLASH, PERCENT)) { + String op = previous().lexeme(); + expr = new Ast.BinaryExpr(op, expr, parseUnary()); + } + return expr; + } + + private Ast.Expr parseUnary() { + if (match(BANG, TILDE, MINUS, PLUS)) return new Ast.UnaryExpr(previous().lexeme(), parseUnary()); + if (match(AMP)) { + boolean mutable = match(MUT); + return new Ast.UnaryExpr(mutable ? "&mut" : "&", parseUnary()); + } + if (match(AWAIT)) return new Ast.AwaitExpr(parseUnary()); + + if (match(NB)) { + if (match(READCH)) return parseChannelOperation(Ast.ChannelOperation.READ, Ast.WaitMode.NONBLOCKING); + if (match(WRITECH)) return parseChannelOperation(Ast.ChannelOperation.WRITE, Ast.WaitMode.NONBLOCKING); + if (match(SELECT)) return parseDynamicSelect(Ast.WaitMode.NONBLOCKING); + throw error(previous(), "'nb' must prefix readch, writech, or select"); + } + + if (check(TRY) && current + 1 < tokens.size()) { + Token.Type next = tokens.get(current + 1).type(); + if (next == READCH || next == WRITECH || next == SELECT) { + advance(); + if (match(READCH)) return parseChannelOperation(Ast.ChannelOperation.READ, Ast.WaitMode.IMMEDIATE); + if (match(WRITECH)) return parseChannelOperation(Ast.ChannelOperation.WRITE, Ast.WaitMode.IMMEDIATE); + consume(SELECT, "expected select"); + return parseDynamicSelect(Ast.WaitMode.IMMEDIATE); + } + } + + if (match(READCH)) return parseChannelOperation(Ast.ChannelOperation.READ, Ast.WaitMode.BLOCKING); + if (match(WRITECH)) return parseChannelOperation(Ast.ChannelOperation.WRITE, Ast.WaitMode.BLOCKING); + if (match(SELECT)) return parseDynamicSelect(Ast.WaitMode.BLOCKING); + + return parsePostfix(); + } + + private Ast.Expr parseChannelOperation( + Ast.ChannelOperation operation, + Ast.WaitMode mode) { + if (operation == Ast.ChannelOperation.DEFAULT) { + throw new AssertionError("default is not a standalone channel operation"); + } + + if (match(LPAREN)) { + Ast.Expr channel = parseExpression(); + Ast.Expr value = null; + if (operation == Ast.ChannelOperation.WRITE) { + consume(COMMA, "writech(channel, value) requires two arguments"); + value = parseExpression(); + } + consume(RPAREN, "expected ')' after channel operation"); + return new Ast.ChannelOpExpr(operation, mode, channel, value); + } + + Ast.Expr channel = parseUnary(); + Ast.Expr value = null; + if (operation == Ast.ChannelOperation.WRITE) { + consume(COMMA, "writech command form requires 'writech channel, value'"); + value = parseExpression(); + } + return new Ast.ChannelOpExpr(operation, mode, channel, value); + } + + private Ast.DynamicSelectExpr parseDynamicSelect(Ast.WaitMode mode) { + Ast.SelectPolicy policy = parseSelectPolicy(); + consume(FROM, + "dynamic select requires 'select from cases'; static select uses 'select { case ... }'"); + Ast.Expr cases = parseUnary(); + return new Ast.DynamicSelectExpr(mode, policy, cases); + } + + private Ast.Expr parsePostfix() { + Ast.Expr expr = parsePrimary(); + while (true) { + if (check(LT) && adjacent(previous(), peek()) && looksLikeTypeArgumentCall()) { + List typeArguments = parseCallTypeArguments(); + consume(LPAREN, "expected '(' after call type arguments"); + List args = parseArgumentsUntil(RPAREN); + consume(RPAREN, "expected ')' after arguments"); + expr = new Ast.CallExpr(expr, typeArguments, args); + } else if (match(LPAREN)) { + List args = parseArgumentsUntil(RPAREN); + consume(RPAREN, "expected ')' after arguments"); + expr = new Ast.CallExpr(expr, args); + } else if (match(DOT)) { + String member = consumeMemberName(); + expr = new Ast.MemberExpr(expr, member); + } else if (match(LBRACKET)) { + Ast.Expr index = parseExpression(); + consume(RBRACKET, "expected ']' after index"); + expr = new Ast.IndexExpr(expr, index); + } else break; + } + return expr; + } + + private List parseCallTypeArguments() { + consume(LT, "expected '<' before call type arguments"); + List arguments = new ArrayList<>(); + if (!check(GT)) { + do arguments.add(parseTypeRef()); while (match(COMMA)); + } + consume(GT, "expected '>' after call type arguments"); + return List.copyOf(arguments); + } + + private boolean looksLikeTypeArgumentCall() { + return looksLikeTypeArgumentCallAt(current); + } + + private boolean looksLikeTypeArgumentCallAt(int startIndex) { + int depth = 0; + for (int i = startIndex; i < tokens.size(); i++) { + Token.Type type = tokens.get(i).type(); + if (type == LT) depth++; + else if (type == GT) { + depth--; + if (depth == 0) return i + 1 < tokens.size() && tokens.get(i + 1).type() == LPAREN; + if (depth < 0) return false; + } else if (type == SEMICOLON || type == EQUAL || type == QUESTION || type == COLON || type == EOF) { + return false; + } + } + return false; + } + + private String consumeCallableName(String message) { + Token token = peek(); + if (token.type() == IDENT + || isReservedCallableName(token.type()) + || isContextualStatementCallableName(token.type())) { + advance(); + return token.lexeme(); + } + throw error(token, message); + } + + private boolean reservedCallableNameFollowedByInvocation(int nameIndex) { + int nextIndex = nameIndex + 1; + if (nextIndex >= tokens.size()) return false; + Token next = tokens.get(nextIndex); + if (next.type() == LPAREN) return true; + return next.type() == LT + && adjacent(tokens.get(nameIndex), next) + && looksLikeTypeArgumentCallAt(nextIndex); + } + + private static boolean isReservedCallableName(Token.Type type) { + return type == STOP || type == DO || type == DONE; + } + + private static boolean isContextualStatementCallableName(Token.Type type) { + return type == LOOP || type == BLOCK; + } + + private String consumeStaticObjectKeyName(String message) { + if (match(STRING)) return previous().lexeme(); + Token token = peek(); + if (isMemberNameToken(token.type())) { + advance(); + return token.lexeme(); + } + throw error(token, message); + } + + private String consumeMemberName() { + Token token = peek(); + if (isMemberNameToken(token.type())) { + if (isReservedCallableName(token.type()) + && !reservedCallableNameFollowedByInvocation(current)) { + throw error(token, "'" + token.lexeme() + + "' is reserved and may only be used as a function name in a call or as an object/map key"); + } + advance(); + return token.lexeme(); + } + throw error(token, "expected member name after '.'"); + } + + /** + * Reserved words remain illegal lexical identifiers, but member names live + * in a separate namespace. This permits APIs such as SharedMutex.new(...) + * without allowing declarations such as `val new = ...`. + */ + private static boolean isMemberNameToken(Token.Type type) { + return switch (type) { + case IDENT, + DEFINE, CLASS, MODULE, NAMESPACE, IMPORT, FROM, AS, EXTENDS, IMPLEMENTS, + TRY, CATCH, FINALLY, END, FI, IF, DO, ELSE, THEN, + NEW, STOP, DONE, AWAIT, ASYNC, NLEX, NB, SELECT, READCH, WRITECH, ACTOR, SHARED, DEF, FNC, ROUTINE, FOR, OF, LOOP, BLOCK, BREAK, CONTINUE, YIELD, SUPER, ELSEIF, SWITCH, MATCH, MATCHES, IS, WHEN, CASE, DEFAULT, FIRST, TYPE, TYPEOF, + INTERFACE, IMPL, ABSTRACT, VOID, STATIC, PUB, PRIVATE, STRUCTURAL, RETURN, DEFER, + VAL, CONST, LET, MUT, SELF, TRUE, FALSE, NULL, OBJ, ARR -> true; + default -> false; + }; + } + + private Ast.Expr parsePrimary() { + if (match(INT)) return new Ast.LiteralExpr(Long.parseLong(previous().lexeme().replace("_", ""))); + if (match(FLOAT)) return new Ast.LiteralExpr(Double.parseDouble(previous().lexeme().replace("_", ""))); + if (match(IMAG)) { + String raw = previous().lexeme().substring(0, previous().lexeme().length() - 1).replace("_", ""); + return new Ast.LiteralExpr(new Ast.Imaginary(Double.parseDouble(raw))); + } + if (match(STRING)) return new Ast.LiteralExpr(previous().lexeme()); + if (match(TRUE)) return new Ast.LiteralExpr(Boolean.TRUE); + if (match(FALSE)) return new Ast.LiteralExpr(Boolean.FALSE); + if (match(NULL)) throw error(previous(), "standalone null values are forbidden; use Option"); + if (match(SELF)) return new Ast.NameExpr("self"); + // 'actor' remains reserved, but in expression position it names the + // actor-local runtime namespace (actor.gc and future local primitives). + if (match(ACTOR)) return new Ast.NameExpr("actor"); + // loop/block are contextual statement keywords: parseStatement only + // consumes them when followed by '{'. In every expression position they + // remain valid references to same-named callables, including first-class + // function values (not only direct calls). + if (isContextualStatementCallableName(peek().type())) { + return new Ast.NameExpr(advance().lexeme()); + } + if (isReservedCallableName(peek().type()) + && reservedCallableNameFollowedByInvocation(current)) { + return new Ast.NameExpr(advance().lexeme()); + } + if (match(IDENT)) return new Ast.NameExpr(previous().lexeme()); + if (match(NEW)) { + Ast.TypeRef type = parseTypeRef(); + consume(LPAREN, "expected '(' after new type"); + List args = parseArgumentsUntil(RPAREN); + consume(RPAREN, "expected ')' after constructor arguments"); + return new Ast.NewExpr(type, args); + } + if (match(OBJ)) return parseObjectLiteral(); + if (match(ARR)) { + consume(LBRACKET, "expected '[' after arr"); + List items = parseArgumentsUntil(RBRACKET); + consume(RBRACKET, "expected ']' after arr literal"); + return new Ast.ListExpr(items); + } + if (match(NLEX)) { + if (check(PIPE)) return parsePipeLambda(true); + if (check(LPAREN) && looksLikeLambda()) return parseLambda(true); + throw error(previous(), "'nlex' in expression position must prefix a lambda"); + } + if (check(PIPE)) return parsePipeLambda(false); + if (check(LPAREN) && looksLikeLambda()) return parseLambda(false); + if (match(LPAREN)) { + Ast.Expr first = parseExpression(); + if (match(COMMA)) { + List items = new ArrayList<>(); + items.add(first); + do items.add(parseExpression()); while (match(COMMA)); + consume(RPAREN, "expected ')' after tuple"); + return new Ast.TupleExpr(items); + } + consume(RPAREN, "expected ')' after expression"); + return first; + } + if (match(LBRACKET)) { + List items = parseArgumentsUntil(RBRACKET); + consume(RBRACKET, "expected ']'"); + return new Ast.ListExpr(items); + } + throw error(peek(), "expected expression"); + } + + private Ast.ObjectExpr parseObjectLiteral() { + consume(LBRACE, "expected '{' after obj"); + List fields = new ArrayList<>(); + if (!check(RBRACE)) { + do { + Ast.ObjectField field; + if (match(BACKTICK)) { + Ast.Expr key = parseExpression(); + consume(BACKTICK, "expected closing backtick after dynamic object key"); + consume(COLON, "expected ':' after dynamic object key"); + field = Ast.ObjectField.dynamic(key, parseExpression()); + } else { + String name = consumeStaticObjectKeyName("expected object field name"); + consume(COLON, "expected ':' after object field name"); + field = Ast.ObjectField.named(name, parseExpression()); + } + fields.add(field); + } while (match(COMMA)); + } + consume(RBRACE, "expected '}' after obj literal"); + return new Ast.ObjectExpr(fields); + } + + private Ast.LambdaExpr parseLambda(boolean nonLexical) { + consume(LPAREN, "expected '('"); + List params = parseParametersUntil(RPAREN); + consume(RPAREN, "expected ')' after lambda parameters"); + consume(ARROW, "expected '->' after lambda parameters"); + if (!check(LBRACE)) throw error(peek(), "lambdas always require a block body; use '-> { ... }'"); + return new Ast.LambdaExpr(params, null, parseBlock(), nonLexical); + } + + private Ast.LambdaExpr parsePipeLambda(boolean nonLexical) { + consume(PIPE, "expected '|'"); + List params = new ArrayList<>(); + if (!check(PIPE)) { + do { + if (check(IDENT) && (checkNext(COMMA) || checkNext(PIPE))) { + String name = advance().lexeme(); + params.add(new Ast.Param(Ast.TypeRef.inferred(), name, false, false)); + } else { + Ast.TypeRef type = parseTypeRef(); + boolean mutable = match(MUT); + String name = consume(IDENT, "expected lambda parameter name").lexeme(); + params.add(new Ast.Param(type, name, false, mutable)); + } + } while (match(COMMA)); + } + consume(PIPE, "expected closing '|' after lambda parameters"); + consume(ARROW, "lambdas use the slim arrow '->'"); + if (!check(LBRACE)) throw error(peek(), "lambdas always require a block body; use '|args| -> { ... }'"); + return new Ast.LambdaExpr(params, null, parseBlock(), nonLexical); + } + + private boolean looksLikeLambda() { + int depth = 0; + for (int i = current; i < tokens.size(); i++) { + Token.Type type = tokens.get(i).type(); + if (type == LPAREN) depth++; + else if (type == RPAREN) { + depth--; + if (depth == 0) return i + 1 < tokens.size() && tokens.get(i + 1).type() == ARROW; + } + } + return false; + } + + private List parseArgumentsUntil(Token.Type terminator) { + if (check(terminator)) return List.of(); + List args = new ArrayList<>(); + do args.add(parseExpression()); while (match(COMMA)); + return args; + } + + private void consumeMemberTerminator(Token.Type structuralTerminator, String message) { + if (match(SEMICOLON) || check(structuralTerminator)) return; + throw error(peek(), message); + } + + private void consumeClassFieldTerminator(String message) { + if (match(SEMICOLON) || check(AT) || check(END) || check(PUB) || check(PRIVATE) + || check(STATIC) || check(ABSTRACT) || check(ASYNC) || check(LBRACKET) + || isBindingKind(peek().type()) + || (check(IDENT) && (checkNext(COLON) || checkNext(LPAREN)))) return; + throw error(peek(), message); + } + + private boolean hasAnnotation(List annotations, String name) { + for (Ast.Annotation annotation : annotations) if (annotation.name().equals(name)) return true; + return false; + } + + private void consumeStatementTerminator(String message) { + if (match(SEMICOLON) || isSafeStatementBoundary() || isImplicitNewlineTerminator()) return; + throw error(peek(), message); + } + + private boolean isImplicitNewlineTerminator() { + if (current == 0 || check(EOF)) return false; + return previous().line() < peek().line(); + } + + private boolean isSafeStatementBoundary() { + return check(RBRACE) || check(FI) || check(END) || check(ELSE) || check(ELSEIF) + || check(CATCH) || check(FINALLY) || isBareDoneDelimiter() || check(EOF); + } + + private Ast.BindingKind parseBindingKind() { + if (match(CONST)) return Ast.BindingKind.CONST; + if (match(VAL)) return Ast.BindingKind.VAL; + if (match(LET)) return Ast.BindingKind.LET; + throw error(peek(), "expected const, val, or let"); + } + + private boolean isBindingKind(Token.Type type) { return type == CONST || type == VAL || type == LET; } + + private boolean matchContextualShared() { + if (match(SHARED)) return true; + if (check(IDENT) && peek().lexeme().equals("shared")) { + advance(); + return true; + } + return false; + } + + private boolean match(Token.Type... types) { + for (Token.Type type : types) { + if (check(type)) { advance(); return true; } + } + return false; + } + + private Token consume(Token.Type type, String message) { + if (check(type)) return advance(); + throw error(peek(), message); + } + + private boolean check(Token.Type type) { return peek().type() == type; } + private boolean checkNext(Token.Type type) { return current + 1 < tokens.size() && tokens.get(current + 1).type() == type; } + + private boolean adjacent(Token left, Token right) { + return left.line() == right.line() && right.column() == left.column() + left.lexeme().length(); + } + + private boolean checkAdjacentPair(Token.Type type) { + return current + 1 < tokens.size() + && tokens.get(current).type() == type + && tokens.get(current + 1).type() == type + && adjacent(tokens.get(current), tokens.get(current + 1)); + } + + private boolean matchAdjacentPair(Token.Type type) { + if (!checkAdjacentPair(type)) return false; + advance(); + advance(); + return true; + } + + private boolean matchAdjacentTriple(Token.Type type) { + if (current + 2 >= tokens.size()) return false; + Token first = tokens.get(current); + Token second = tokens.get(current + 1); + Token third = tokens.get(current + 2); + if (first.type() != type || second.type() != type || third.type() != type + || !adjacent(first, second) || !adjacent(second, third)) return false; + advance(); + advance(); + advance(); + return true; + } + + private boolean matchSingleOperator(Token.Type type) { + if (!check(type) || checkAdjacentPair(type)) return false; + advance(); + return true; + } + private boolean isLegacyFnSpelling() { + return check(IDENT) && peek().lexeme().equals("fn"); + } + + private boolean checkNextLexeme(String lexeme) { + return current + 1 < tokens.size() && tokens.get(current + 1).type() == IDENT + && tokens.get(current + 1).lexeme().equals(lexeme); + } + private Token advance() { if (!check(EOF)) current++; return previous(); } + private Token peek() { return tokens.get(current); } + private Token previous() { return tokens.get(current - 1); } + + private IllegalArgumentException error(Token token, String message) { + return new IllegalArgumentException("Oreslang parse error at " + token.line() + ":" + token.column() + ": " + message); + } + + private record Modifiers(Ast.Visibility visibility, boolean async, boolean nonLexical, boolean isStatic, boolean isAbstract, boolean shared) { } +} diff --git a/src/main/java/dev/oreslang/parser/Token.java b/src/main/java/dev/oreslang/parser/Token.java new file mode 100644 index 00000000..c370e31b --- /dev/null +++ b/src/main/java/dev/oreslang/parser/Token.java @@ -0,0 +1,25 @@ +package dev.oreslang.parser; + +public record Token(Type type, String lexeme, int line, int column) { + public enum Type { + IDENT, + INT, + FLOAT, + IMAG, + STRING, + + DEFINE, CLASS, MODULE, NAMESPACE, IMPORT, FROM, AS, EXTENDS, IMPLEMENTS, + TRY, CATCH, FINALLY, END, FI, IF, DO, ELSE, THEN, + NEW, STOP, DONE, AWAIT, ASYNC, NLEX, NB, SELECT, READCH, WRITECH, ACTOR, ISOACTOR, SHARED, DEF, FNC, ROUTINE, FOR, OF, LOOP, BLOCK, BREAK, CONTINUE, YIELD, SUPER, ELSEIF, SWITCH, MATCH, MATCHES, IS, WHEN, CASE, DEFAULT, FIRST, TYPE, TYPEOF, + INTERFACE, TRAIT, STRUCT, TYPES, IMPL, ABSTRACT, VOID, STATIC, PUB, PRIVATE, STRUCTURAL, RETURN, DEFER, + VAL, CONST, LET, MUT, SELF, TRUE, FALSE, NULL, OBJ, ARR, + + LPAREN, RPAREN, LBRACE, RBRACE, LBRACKET, RBRACKET, + COMMA, DOT, SEMICOLON, COLON, QUESTION, AT, BACKTICK, + PLUS, MINUS, STAR, SLASH, PERCENT, PIPE, AMP, CARET, TILDE, BANG, + EQUAL, EQUAL_EQUAL, BANG_EQUAL, + LT, LTE, GT, GTE, + ARROW, FAT_ARROW, + EOF + } +} diff --git a/src/main/java/dev/oreslang/runtime/ActorRuntime.java b/src/main/java/dev/oreslang/runtime/ActorRuntime.java new file mode 100644 index 00000000..46072f54 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/ActorRuntime.java @@ -0,0 +1,3341 @@ +package dev.oreslang.runtime; + +import java.lang.reflect.Array; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; + +/** + * Host-side actor substrate used by the first interpreter. + * + * Oreslang follows the core Akka-style execution invariant: actors are + * multiplexed over dispatcher threads, but one actor processes its mailbox + * serially. Carrier-thread identity is never actor identity. + * + * PRIVATE and SHARED actors are deliberately bulkheaded onto different + * dispatchers. Private actors also receive a confined logical memory slice; + * shared actors may coordinate through explicitly synchronized shared cells. + */ +public final class ActorRuntime implements AutoCloseable { + private static final int MAX_MESSAGE_GRAPH_DEPTH = 256; + private static final int MAX_MESSAGE_GRAPH_NODES = 100_000; + private static final long CLOSE_WAIT_NANOS = TimeUnit.MILLISECONDS.toNanos(250); + private static final int INTERNAL_CONTINUATION_SLOTS = 1_024; + private static final ThreadLocal ACTOR_CARRIER = ThreadLocal.withInitial(() -> Boolean.FALSE); + private static final ThreadLocal CURRENT_ACTOR_EXECUTION = new ThreadLocal<>(); + + private record ActorExecutionContext( + ActorRuntime runtime, + ActorId actorId, + ActorKind kind, + IsolatePolicy policy, + Object executionDomain) { } + + @FunctionalInterface + public interface TurnExecutor { + void execute(Runnable turn); + + static TurnExecutor direct() { + return Runnable::run; + } + } + + public static boolean isActorCarrierThread() { + return Boolean.TRUE.equals(ACTOR_CARRIER.get()); + } + + public static boolean inActorExecution() { + return CURRENT_ACTOR_EXECUTION.get() != null; + } + + public static IsolatePolicy currentActorPolicy() { + ActorExecutionContext current = CURRENT_ACTOR_EXECUTION.get(); + return current == null ? null : current.policy(); + } + + public static ActorRuntime currentActorRuntime() { + ActorExecutionContext current = CURRENT_ACTOR_EXECUTION.get(); + return current == null ? null : current.runtime(); + } + + public static ActorKind currentActorKind() { + ActorExecutionContext current = CURRENT_ACTOR_EXECUTION.get(); + return current == null ? null : current.kind(); + } + + public static Object currentExecutionDomain() { + ActorExecutionContext current = CURRENT_ACTOR_EXECUTION.get(); + return current == null ? Thread.currentThread() : current.executionDomain(); + } + + /** + * Stable actor execution domain for actor-local runtime services, or null + * when the caller is not currently inside an actor mailbox turn. + */ + public static Object currentActorExecutionDomain() { + ActorExecutionContext current = CURRENT_ACTOR_EXECUTION.get(); + return current == null ? null : current.executionDomain(); + } + + public enum ActorKind { PRIVATE, SHARED } + + /** + * Structured cancellation requests actor shutdown and cascades through the + * actor's child tree. FORCE_ISOLATED additionally requires a host-owned + * isolation revoker that can make non-cooperating guest execution + * impossible before the call returns. + */ + public enum CancellationMode { STRUCTURED, FORCE_ISOLATED } + + public static final class ActorCancelledException extends CancellationException { + private final ActorId actorId; + + private ActorCancelledException(ActorId actorId, String message) { + super(message); + this.actorId = actorId; + } + + public ActorId actorId() { return actorId; } + } + + /** + * Internal control-plane unwind used at actor scheduler safepoints. + * It is an Error intentionally: Oreslang source catch handles ordinary + * runtime failures, but cancellation must not be absorbable by guest code. + * Evaluator finally/defer unwinding still runs before the actor turn exits. + */ + public static final class ActorCancellationSignal extends Error { + private ActorCancellationSignal(String message) { + super(message, null, false, false); + } + } + + public enum CarrierBackend { NATIVE_PTHREAD, JVM_THREAD_POOL } + + private static final String CARRIER_BACKEND_PROPERTY = "ores.runtime.carriers"; + + public record DispatcherConfig( + int privateParallelism, + int sharedParallelism, + int throughput, + int maxActors) { + public DispatcherConfig { + if (privateParallelism <= 0) throw new IllegalArgumentException("privateParallelism must be > 0"); + if (sharedParallelism <= 0) throw new IllegalArgumentException("sharedParallelism must be > 0"); + if (throughput <= 0) throw new IllegalArgumentException("throughput must be > 0"); + if (maxActors <= 0) throw new IllegalArgumentException("maxActors must be > 0"); + } + + public DispatcherConfig(int privateParallelism, int sharedParallelism, int throughput) { + this(privateParallelism, sharedParallelism, throughput, 16_384); + } + + public static DispatcherConfig defaults() { + int cpus = Math.max(2, Runtime.getRuntime().availableProcessors()); + return new DispatcherConfig(cpus, cpus, 64, 16_384); + } + } + + public record ActorId(UUID value) { + public ActorId { Objects.requireNonNull(value); } + public static ActorId create() { return new ActorId(UUID.randomUUID()); } + } + + public static final class ActorTerminatedException extends IllegalStateException { + private final ActorId actorId; + private final ActorKind actorKind; + + private ActorTerminatedException(ActorId actorId, ActorKind actorKind, Throwable cause) { + super("actor " + actorId + " (" + actorKind + ") is terminated", cause); + this.actorId = actorId; + this.actorKind = actorKind; + } + + public ActorId actorId() { return actorId; } + public ActorKind actorKind() { return actorKind; } + } + + /** + * Deeply immutable runtime-owned shared value. The backing graph is frozen + * once, quota-accounted once, and retained until runtime teardown. + */ + public final class Shared { + private final AtomicBoolean sharedClosed = new AtomicBoolean(); + private T value; + private long reservedBytes; + + private Shared(T value, long reservedBytes) { + this.value = value; + this.reservedBytes = reservedBytes; + } + + public T value() { + rejectPrivateActorSharedMemoryAccess("Shared.value"); + if (sharedClosed.get()) throw new IllegalStateException("Shared value belongs to a closed actor runtime"); + return value; + } + + private boolean ownedBy(ActorRuntime runtime) { + return ActorRuntime.this == runtime; + } + + private void closeFromRuntime() { + if (!sharedClosed.compareAndSet(false, true)) return; + long bytes = reservedBytes; + reservedBytes = 0L; + value = null; + releaseSharedRuntimeBytes(bytes); + sharedValues.remove(this); + } + } + + private final Map> actors = new ConcurrentHashMap<>(); + private final AtomicInteger actorCount = new AtomicInteger(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicLong privateMemoryBytes = new AtomicLong(); + private final AtomicLong sharedMemoryBytes = new AtomicLong(); + private final Object memoryBudgetLock = new Object(); + private final Object runtimeLifecycleLock = new Object(); + private final Set> syncCells = ConcurrentHashMap.newKeySet(); + private final Set> sharedValues = ConcurrentHashMap.newKeySet(); + private final IsolatePolicy policyCeiling; + private final DispatcherConfig dispatcherConfig; + private final TurnExecutor turnExecutor; + private volatile Consumer actorExitHook = ignored -> { }; + + /** + * Host/isolate boundary hook. Returning true means the execution domain + * rooted at the supplied actor (including descendants owned by that + * isolation boundary) has been revoked strongly enough that guest code + * cannot continue running. The default runtime has no such authority. + */ + private volatile BiPredicate forceCancellationHook = + (actorId, executionDomain) -> false; + + private final ExecutorService privateDispatcher; + private final ExecutorService sharedDispatcher; + private final ThreadLocal> currentActor = new ThreadLocal<>(); + private final ThreadLocal> currentSyncCell = new ThreadLocal<>(); + + public ActorRuntime() { + this(IsolatePolicy.developer(), DispatcherConfig.defaults(), TurnExecutor.direct()); + } + + public ActorRuntime(IsolatePolicy policyCeiling) { + this(policyCeiling, DispatcherConfig.defaults(), TurnExecutor.direct()); + } + + public ActorRuntime(IsolatePolicy policyCeiling, int maxActors) { + this( + policyCeiling, + new DispatcherConfig( + DispatcherConfig.defaults().privateParallelism(), + DispatcherConfig.defaults().sharedParallelism(), + DispatcherConfig.defaults().throughput(), + maxActors), + TurnExecutor.direct()); + } + + public ActorRuntime(IsolatePolicy policyCeiling, DispatcherConfig dispatcherConfig) { + this(policyCeiling, dispatcherConfig, TurnExecutor.direct()); + } + + public ActorRuntime( + IsolatePolicy policyCeiling, + DispatcherConfig dispatcherConfig, + TurnExecutor turnExecutor) { + this.policyCeiling = Objects.requireNonNull(policyCeiling); + this.dispatcherConfig = Objects.requireNonNull(dispatcherConfig); + this.turnExecutor = Objects.requireNonNull(turnExecutor); + this.privateDispatcher = newDispatcher( + dispatcherConfig.privateParallelism(), + dispatcherConfig.maxActors(), + "ores-private-actor-dispatcher-"); + this.sharedDispatcher = newDispatcher( + dispatcherConfig.sharedParallelism(), + dispatcherConfig.maxActors(), + "ores-shared-actor-dispatcher-"); + } + + public IsolatePolicy policyCeiling() { return policyCeiling; } + public DispatcherConfig dispatcherConfig() { return dispatcherConfig; } + public int maxActors() { return dispatcherConfig.maxActors(); } + + /** + * Physical carrier implementation currently backing actor turns. + * + *

This is diagnostic/control-plane information only. Actor identity is + * never carrier identity and source semantics do not depend on this value.

+ */ + public CarrierBackend carrierBackend() { + return privateDispatcher instanceof NativeCarrierExecutor + && sharedDispatcher instanceof NativeCarrierExecutor + ? CarrierBackend.NATIVE_PTHREAD + : CarrierBackend.JVM_THREAD_POOL; + } + + /** + * Installs a host-owned hook invoked exactly once when an actor execution + * domain is retired. Guest code cannot mutate this hook. + */ + public void setActorExitHook(Consumer actorExitHook) { + if (closed.get()) throw new IllegalStateException("actor runtime is closed"); + this.actorExitHook = Objects.requireNonNull(actorExitHook, "actorExitHook"); + } + + /** + * Install force-cancellation authority from an outer isolate/sandbox + * manager. Guest actor code cannot install or replace this hook. + */ + public void setForceCancellationHook( + BiPredicate forceCancellationHook) { + requireSupervisorContext("install force-cancellation authority"); + if (closed.get()) throw new IllegalStateException("actor runtime is closed"); + this.forceCancellationHook = Objects.requireNonNull( + forceCancellationHook, "forceCancellationHook"); + } + + public int actorCount() { return actorCount.get(); } + public long privateMemoryBytes() { return privateMemoryBytes.get(); } + public long sharedMemoryBytes() { return sharedMemoryBytes.get(); } + public long actorMemoryBytes() { return privateMemoryBytes.get() + sharedMemoryBytes.get(); } + + /** + * Logical actor-confined memory slice for one private actor. + * + * This is independent of carrier threads. Mailbox payloads and persistent + * actor-state allocations share one budget. The current JVM backend uses + * accounting plus alias isolation; a native/polyglot-isolate backend can map + * this same contract to a physically separate heap/arena. + */ + public final class ActorMemorySlice implements AutoCloseable { + private final ActorId owner; + private final long limitBytes; + private final AtomicLong usedBytes = new AtomicLong(); + private final AtomicBoolean sliceClosed = new AtomicBoolean(); + private final Set blocks = ConcurrentHashMap.newKeySet(); + + private ActorMemorySlice(ActorId owner, long limitBytes) { + this.owner = Objects.requireNonNull(owner); + this.limitBytes = limitBytes; + } + + public ActorId owner() { return owner; } + public long limitBytes() { return limitBytes; } + public long usedBytes() { return usedBytes.get(); } + public long remainingBytes() { return Math.max(0L, limitBytes - usedBytes.get()); } + public boolean closed() { return sliceClosed.get(); } + + /** + * Reserve persistent private-actor heap. Compiler/interpreter lowering + * should retain the reservation for as long as the state allocation is + * live and close it when that allocation dies. + */ + public MemoryReservation reserveHeap(long bytes) { + requireCurrentOwner(); + return reserve(bytes, "private actor heap"); + } + + /** + * Allocate actor-confined direct memory. The raw ByteBuffer is never + * exposed; all reads/writes verify the owning ActorId. This gives + * compiler-lowered private actor state a genuinely unshared backing + * region while actors remain multiplexed over carrier threads. + */ + public PrivateMemoryBlock allocatePrivateBytes(int bytes) { + requireCurrentOwner(); + if (bytes < 0) throw new IllegalArgumentException("private memory block size cannot be negative"); + MemoryReservation reservation = reserve(bytes, "private actor direct heap"); + try { + PrivateMemoryBlock block = new PrivateMemoryBlock(this, reservation, bytes); + blocks.add(block); + return block; + } catch (RuntimeException | Error failure) { + reservation.close(); + throw failure; + } + } + + private MemoryReservation reserveMailbox(Object isolatedMessage) { + return reserve(estimateFrozenBytes(isolatedMessage), "private actor mailbox"); + } + + private synchronized MemoryReservation reserve(long bytes, String purpose) { + if (bytes < 0) throw new IllegalArgumentException("memory reservation cannot be negative"); + if (sliceClosed.get()) throw new IllegalStateException("private actor memory slice is closed"); + if (bytes == 0) return new MemoryReservation(this, 0); + + long current = usedBytes.get(); + long next; + try { + next = Math.addExact(current, bytes); + } catch (ArithmeticException overflow) { + throw new IllegalStateException(purpose + " accounting overflow"); + } + if (next > limitBytes) { + throw new IllegalStateException(purpose + " limit exceeded for " + owner + + ": requested=" + bytes + " used=" + current + " limit=" + limitBytes); + } + + reservePrivateRuntimeBytes(bytes, owner, purpose); + usedBytes.set(next); + return new MemoryReservation(this, bytes); + } + + private void requireCurrentOwner() { + ActorCell cell = currentActor.get(); + if (cell == null || cell.kind != ActorKind.PRIVATE || !cell.ref.id().equals(owner)) { + throw new IllegalStateException( + "private actor memory slice may only be reserved by its owning actor"); + } + } + + private synchronized void release(long bytes) { + if (bytes == 0 || sliceClosed.get()) return; + long remaining = usedBytes.addAndGet(-bytes); + if (remaining < 0) { + usedBytes.addAndGet(bytes); + throw new IllegalStateException("private actor memory accounting underflow for " + owner); + } + try { + releasePrivateRuntimeBytes(bytes, owner); + } catch (RuntimeException failure) { + usedBytes.addAndGet(bytes); + throw failure; + } + } + + @Override + public synchronized void close() { + if (!sliceClosed.compareAndSet(false, true)) return; + for (PrivateMemoryBlock block : List.copyOf(blocks)) block.invalidateFromSlice(); + blocks.clear(); + long bytes = usedBytes.getAndSet(0); + if (bytes != 0) releasePrivateRuntimeBytes(bytes, owner); + } + + private void unregister(PrivateMemoryBlock block) { + blocks.remove(block); + } + } + + /** + * Owner-checked direct memory owned by exactly one private actor. + * + * No mutable buffer reference escapes this wrapper. Closing the block or + * terminating the actor overwrites the entire region before invalidation. + */ + public final class PrivateMemoryBlock implements AutoCloseable { + private final ActorMemorySlice slice; + private final MemoryReservation reservation; + private final int capacity; + private volatile ByteBuffer memory; + private final AtomicBoolean blockClosed = new AtomicBoolean(); + + private PrivateMemoryBlock( + ActorMemorySlice slice, + MemoryReservation reservation, + int bytes) { + this.slice = Objects.requireNonNull(slice); + this.reservation = Objects.requireNonNull(reservation); + this.capacity = bytes; + this.memory = ByteBuffer.allocateDirect(bytes).order(ByteOrder.LITTLE_ENDIAN); + } + + public int capacity() { return capacity; } + public ActorId owner() { return slice.owner(); } + public boolean closed() { return blockClosed.get(); } + + public byte readByte(int index) { + return openMemory().get(index); + } + + public void writeByte(int index, byte value) { + openMemory().put(index, value); + } + + public int readInt(int index) { + return openMemory().getInt(index); + } + + public void writeInt(int index, int value) { + openMemory().putInt(index, value); + } + + public long readLong(int index) { + return openMemory().getLong(index); + } + + public void writeLong(int index, long value) { + openMemory().putLong(index, value); + } + + public double readDouble(int index) { + return openMemory().getDouble(index); + } + + public void writeDouble(int index, double value) { + openMemory().putDouble(index, value); + } + + public byte[] copyOut() { + ByteBuffer live = openMemory(); + byte[] out = new byte[capacity]; + ByteBuffer duplicate = live.duplicate(); + duplicate.clear(); + duplicate.get(out); + return out; + } + + public void copyIn(byte[] bytes) { + Objects.requireNonNull(bytes); + ByteBuffer live = openMemory(); + if (bytes.length != capacity) { + throw new IllegalArgumentException( + "private memory copy size mismatch: expected " + capacity + + " bytes but got " + bytes.length); + } + ByteBuffer duplicate = live.duplicate(); + duplicate.clear(); + duplicate.put(bytes); + } + + private ByteBuffer openMemory() { + if (blockClosed.get() || slice.closed()) { + throw new IllegalStateException("private actor memory block is closed"); + } + slice.requireCurrentOwner(); + ByteBuffer live = memory; + if (live == null) throw new IllegalStateException("private actor memory block is closed"); + return live; + } + + private void zeroAndDetachMemory() { + ByteBuffer live = memory; + if (live == null) return; + ByteBuffer duplicate = live.duplicate(); + duplicate.clear(); + while (duplicate.hasRemaining()) duplicate.put((byte) 0); + memory = null; + } + + private void invalidateFromSlice() { + if (!blockClosed.compareAndSet(false, true)) return; + zeroAndDetachMemory(); + } + + @Override + public void close() { + openMemory(); // owner + liveness check before invalidation + if (!blockClosed.compareAndSet(false, true)) return; + zeroAndDetachMemory(); + slice.unregister(this); + reservation.close(); + } + } + + public final class MemoryReservation implements AutoCloseable { + private final ActorMemorySlice slice; + private final long bytes; + private final AtomicBoolean released = new AtomicBoolean(); + + private MemoryReservation(ActorMemorySlice slice, long bytes) { + this.slice = Objects.requireNonNull(slice); + this.bytes = bytes; + } + + public ActorId owner() { return slice.owner(); } + public long bytes() { return bytes; } + + @Override + public void close() { + if (released.compareAndSet(false, true)) slice.release(bytes); + } + } + + private record MessageEnvelope( + Object value, + Runnable release, + Runnable continuation) implements AutoCloseable { + private static MessageEnvelope message(Object value, Runnable release) { + return new MessageEnvelope(value, release, null); + } + + private static MessageEnvelope continuation(Runnable continuation) { + return new MessageEnvelope( + null, + null, + Objects.requireNonNull(continuation, "continuation")); + } + + private boolean isContinuation() { + return continuation != null; + } + + @Override + public void close() { + if (release != null) release.run(); + } + } + + /** + * Explicit synchronized shared-memory cell. + * + * Actor fields do not use this: a mailbox turn already provides exclusive + * mutation of actor-owned state. SyncCell is for state intentionally shared + * by multiple SHARED actors. + */ + public final class SyncCell implements AutoCloseable { + private final ReentrantLock lock = new ReentrantLock(true); + private final AtomicBoolean cellClosed = new AtomicBoolean(); + private T value; + private long reservedBytes; + + @SuppressWarnings("unchecked") + private SyncCell(T initialValue) { + Object frozen = freeze(initialValue); + rejectSharedMutableHandles(frozen, new IdentityHashMap<>(), 0); + long bytes = estimateFrozenBytes(frozen); + reserveSharedRuntimeBytes(bytes, "shared SyncCell"); + this.value = (T) frozen; + this.reservedBytes = bytes; + } + + public boolean closed() { return cellClosed.get(); } + + private boolean ownedBy(ActorRuntime runtime) { + return ActorRuntime.this == runtime; + } + + public T snapshot() { + rejectPrivateActorSharedMemoryAccess("SyncCell.snapshot"); + boolean entered = enterSyncCell(this); + lock.lock(); + try { + requireOpen(); + return value; + } finally { + lock.unlock(); + exitSyncCell(entered); + } + } + + public R read(Function reader) { + Objects.requireNonNull(reader); + requireSharedActorTurn(); + boolean entered = enterSyncCell(this); + lock.lock(); + try { + requireOpen(); + Object frozen = freeze(reader.apply(value)); + rejectSharedMutableHandles(frozen, new IdentityHashMap<>(), 0); + @SuppressWarnings("unchecked") + R result = (R) frozen; + return result; + } finally { + lock.unlock(); + exitSyncCell(entered); + } + } + + @SuppressWarnings("unchecked") + public T update(UnaryOperator updater) { + Objects.requireNonNull(updater); + requireSharedActorTurn(); + boolean entered = enterSyncCell(this); + lock.lock(); + try { + requireOpen(); + Object frozen = freeze(updater.apply(value)); + rejectSharedMutableHandles(frozen, new IdentityHashMap<>(), 0); + requireOpen(); + if (closed.get()) throw new IllegalStateException("actor runtime is closed"); + long nextBytes = estimateFrozenBytes(frozen); + long delta = nextBytes - reservedBytes; + if (delta > 0) reserveSharedRuntimeBytes(delta, "shared SyncCell update"); + value = (T) frozen; + if (delta < 0) releaseSharedRuntimeBytes(-delta); + reservedBytes = nextBytes; + return value; + } finally { + lock.unlock(); + exitSyncCell(entered); + } + } + + private void requireOpen() { + if (cellClosed.get()) throw new IllegalStateException("SyncCell is closed"); + } + + @Override + public void close() { + rejectPrivateActorSharedMemoryAccess("SyncCell.close"); + boolean entered = enterSyncCell(this); + try { + closeFromRuntime(); + } finally { + exitSyncCell(entered); + } + } + + private void closeFromRuntime() { + lock.lock(); + try { + if (!cellClosed.compareAndSet(false, true)) return; + long bytes = reservedBytes; + reservedBytes = 0L; + value = null; + releaseSharedRuntimeBytes(bytes); + syncCells.remove(this); + } finally { + lock.unlock(); + } + } + + private void invalidateFromRuntime() { + cellClosed.set(true); + syncCells.remove(this); + } + } + + @FunctionalInterface + public interface Behavior { + void onMessage(M message, ActorContext context) throws Exception; + } + + /** + * Compiler/interpreter callback for a one-shot source-level actor callable. + * Guest code never receives this host callback object. + */ + @FunctionalInterface + public interface Invocation { + R run(M message, ActorContext context) throws Exception; + } + + /** + * Compiler-facing actor constructor. The actor context is available before + * state initialization, so private actor fields can reserve/allocate in the + * actor's confined memory slice rather than being captured from the caller. + */ + @FunctionalInterface + public interface BehaviorFactory { + Behavior create(ActorContext context) throws Exception; + } + + public interface ActorContext { + ActorRef self(); + ActorRuntime runtime(); + IsolatePolicy policy(); + ActorKind kind(); + Optional privateMemory(); + } + + /** + * Opaque runtime handle used by Future/channel completion plumbing to + * re-enter one actor through its serialized mailbox lane. + * + *

The handle carries no guest-callable callback surface. Completion + * threads may only enqueue a runtime continuation; they never execute guest + * code directly.

+ */ + public final class ContinuationTarget { + private final ActorId actorId; + + private ContinuationTarget(ActorId actorId) { + this.actorId = Objects.requireNonNull(actorId, "actorId"); + } + + public ActorId actorId() { + return actorId; + } + + private boolean enqueue(Runnable continuation) { + return enqueueContinuation(actorId, continuation); + } + } + + public ContinuationTarget captureCurrentContinuationTarget() { + ActorCell cell = currentActor.get(); + if (cell == null) { + throw new IllegalStateException( + "deferred actor continuation requires an executing actor turn"); + } + return new ContinuationTarget(cell.ref.id()); + } + + /** + * Bind a cancellable runtime Future to the lifetime of the currently + * executing actor. Channel/select registrations created by nb forms use + * this so structured actor teardown detaches them even when guest code + * drops the Future without explicitly cancelling it. + * + *

Outside an actor turn the Future is returned unchanged; root/external + * execution domains own their lifetimes separately.

+ */ + public OresFuture ownCurrentActorFuture(OresFuture future) { + Objects.requireNonNull(future, "future"); + ActorCell cell = currentActor.get(); + if (cell == null) return future; + ownFuture(cell, future); + return future; + } + + private boolean ownFuture(ActorCell cell, OresFuture future) { + if (cell.stopped.get() || cell.finalized || closed.get()) { + future.cancel(false); + return false; + } + + cell.pendingOperations.add(future); + if (cell.stopped.get() || cell.finalized || closed.get()) { + cell.pendingOperations.remove(future); + future.cancel(false); + return false; + } + + future.whenCompleteRuntime((ignored, failure) -> + cell.pendingOperations.remove(future)); + return true; + } + + /** + * Register scheduler plumbing only. Future completion enqueues the supplied + * continuation back to the captured actor; the callback body itself is not + * run on the producer/completion thread. + */ + public void enqueueOnCompletion( + OresFuture future, + ContinuationTarget target, + BiConsumer continuation) { + Objects.requireNonNull(future, "future"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(continuation, "continuation"); + + ActorCell cell = actors.get(target.actorId()); + if (cell == null || !ownFuture(cell, future)) return; + + future.whenCompleteRuntime((value, failure) -> + target.enqueue(() -> continuation.accept(value, failure))); + } + + private boolean enqueueContinuation(ActorId actorId, Runnable continuation) { + Objects.requireNonNull(actorId, "actorId"); + Objects.requireNonNull(continuation, "continuation"); + ActorCell cell = actors.get(actorId); + if (cell == null || cell.stopped.get() || closed.get()) return false; + if (!cell.reserveContinuationSlot()) { + cell.fail(new IllegalStateException( + "actor internal continuation queue overflow for " + actorId)); + return false; + } + + boolean admitted = false; + try { + synchronized (cell.lifecycleLock) { + if (cell.stopped.get() || cell.finalized || closed.get()) return false; + admitted = cell.mailbox.tryWrite( + MessageEnvelope.continuation(continuation)); + } + if (admitted) cell.schedule(); + return admitted; + } finally { + if (!admitted) cell.releaseMailboxSlot(true); + } + } + + public final class ActorRef { + private final ActorId id; + private final ActorKind kind; + private final AtomicReference terminationCause = new AtomicReference<>(); + + private ActorRef(ActorId id, ActorKind kind) { + this.id = id; + this.kind = kind; + } + + public ActorId id() { return id; } + public ActorKind kind() { return kind; } + private boolean ownedBy(ActorRuntime runtime) { return ActorRuntime.this == runtime; } + public boolean isAlive() { return ActorRuntime.this.isAlive(this); } + public Optional failure() { return Optional.ofNullable(terminationCause.get()); } + + public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + Objects.requireNonNull(unit); + if (timeout < 0) throw new IllegalArgumentException("timeout must be non-negative"); + ActorCell cell = actors.get(id); + if (cell == null) return true; + cell.awaitFinalized(unit.toNanos(timeout)); + return cell.finalized(); + } + + public void send(M message) { + ActorRuntime.this.send(this, message); + } + + public void stop() { + ActorRuntime.this.stop(this); + } + + /** + * Structured cancellation is non-blocking: it revokes future mailbox + * work immediately and cascades to descendants. Running trusted actor + * code observes cancellation at the next scheduler boundary. + */ + public boolean cancel() { + return ActorRuntime.this.cancel(this, CancellationMode.STRUCTURED); + } + + /** + * Force cancellation is valid only when the host configured an + * isolation revoker (for example, an untrusted secondary Graal/native + * isolate). It never relies on guest cooperation. + */ + public boolean forceCancel() { + return ActorRuntime.this.cancel(this, CancellationMode.FORCE_ISOLATED); + } + + public Optional parentId() { + ActorCell cell = actors.get(id); + return cell == null || cell.parent == null + ? Optional.empty() + : Optional.of(cell.parent.ref.id()); + } + + public List childIds() { + ActorCell cell = actors.get(id); + if (cell == null) return List.of(); + return cell.children.stream().map(child -> child.ref.id()).toList(); + } + + @Override + public String toString() { + return "ActorRef[" + kind + ":" + id.value() + "]"; + } + } + + private void requireCallerRuntimeAffinity(String operation) { + ActorRuntime caller = currentActorRuntime(); + if (caller != null && caller != this) { + throw new SecurityException( + "actor cannot " + operation + " through another ActorRuntime"); + } + } + + private static void requireSupervisorContext(String operation) { + if (inActorExecution()) { + throw new SecurityException( + "actor code cannot " + operation + "; this operation belongs to the host/supervisor"); + } + } + + private void requireActorLifecycleAuthority( + ActorRef target, + String operation) { + ActorCell caller = currentActor.get(); + if (caller == null) return; // host/supervisor authority + + ActorCell targetCell = actors.get(target.id()); + if (targetCell == null) return; // already terminated; operation is a no-op + if (caller == targetCell) return; + + for (ActorCell ancestor = targetCell.parent; + ancestor != null; + ancestor = ancestor.parent) { + if (ancestor == caller) return; + } + + throw new SecurityException( + "actor " + caller.ref.id() + + " cannot " + operation + " unrelated actor " + + target.id() + + "; actor lifecycle authority is limited to self and structured descendants"); + } + + private IsolatePolicy defaultSpawnPolicy() { + IsolatePolicy caller = currentActorPolicy(); + return caller == null ? policyCeiling : caller; + } + + private void requireWithinCallerPolicy(IsolatePolicy child) { + IsolatePolicy caller = currentActorPolicy(); + if (caller == null) return; + + if (!caller.capabilities().containsAll(child.capabilities())) { + java.util.Set excess = child.capabilities().isEmpty() + ? java.util.EnumSet.noneOf(IsolatePolicy.Capability.class) + : java.util.EnumSet.copyOf(child.capabilities()); + excess.removeAll(caller.capabilities()); + throw new SecurityException("child actor policy exceeds caller actor capabilities: " + excess); + } + if (child.maxHeapBytes() > caller.maxHeapBytes()) { + throw new SecurityException("child actor maxHeapBytes exceeds caller actor policy"); + } + if (child.maxMailboxMessages() > caller.maxMailboxMessages()) { + throw new SecurityException("child actor mailbox limit exceeds caller actor policy"); + } + if (child.maxWallTime().compareTo(caller.maxWallTime()) > 0) { + throw new SecurityException("child actor wall-time limit exceeds caller actor policy"); + } + if (caller.adversarial() && !child.adversarial()) { + throw new SecurityException("child actor cannot weaken an adversarial caller policy"); + } + } + + /** Backward-compatible default: an unqualified runtime actor is private. */ + public ActorRef spawn(Supplier> behaviorFactory) { + return spawnPrivate(defaultSpawnPolicy(), behaviorFactory); + } + + public ActorRef spawn( + IsolatePolicy policy, + Supplier> behaviorFactory) { + return spawnPrivate(policy, behaviorFactory); + } + + public ActorRef spawnPrivate(Supplier> behaviorFactory) { + return spawnPrivate(policyCeiling, behaviorFactory); + } + + /** + * Trusted-host compatibility path. Compiler-generated actors should prefer + * the context-aware BehaviorFactory overload. + */ + public ActorRef spawnPrivate( + IsolatePolicy policy, + Supplier> behaviorFactory) { + requireSupervisorContext("use trusted Supplier private actor construction"); + Objects.requireNonNull(behaviorFactory); + requireTrustedSupplierPolicy(policy); + return spawnInternal(ActorKind.PRIVATE, policy, context -> behaviorFactory.get(), true); + } + + /** + * Isolation-safe private actor construction. The factory itself must be + * stateless/capture-free; mutable actor state must be created after the + * actor context is installed and stored in actor-owned memory. + */ + public ActorRef spawnPrivate(BehaviorFactory behaviorFactory) { + return spawn(ActorKind.PRIVATE, defaultSpawnPolicy(), behaviorFactory); + } + + public ActorRef spawnPrivate( + IsolatePolicy policy, + BehaviorFactory behaviorFactory) { + return spawn(ActorKind.PRIVATE, policy, behaviorFactory); + } + + /** + * Explicit host-only escape hatch for tests/embedding code that needs a + * context-aware factory with captured Java objects. Never used by Oreslang + * compiler lowering and forbidden for adversarial policies. + */ + public ActorRef spawnPrivateTrusted(BehaviorFactory behaviorFactory) { + return spawnPrivateTrusted(defaultSpawnPolicy(), behaviorFactory); + } + + public ActorRef spawnPrivateTrusted( + IsolatePolicy policy, + BehaviorFactory behaviorFactory) { + requireSupervisorContext("use trusted captured private actor construction"); + Objects.requireNonNull(behaviorFactory); + requireTrustedSupplierPolicy(policy); + return spawnInternal(ActorKind.PRIVATE, policy, behaviorFactory, true); + } + + public ActorRef spawnShared(Supplier> behaviorFactory) { + return spawnShared(defaultSpawnPolicy(), behaviorFactory); + } + + public ActorRef spawnShared( + IsolatePolicy policy, + Supplier> behaviorFactory) { + requireSupervisorContext("use trusted Supplier shared actor construction"); + Objects.requireNonNull(behaviorFactory); + requireTrustedSupplierPolicy(policy); + return spawnInternal(ActorKind.SHARED, policy, context -> behaviorFactory.get(), true); + } + + public ActorRef spawnShared(BehaviorFactory behaviorFactory) { + return spawn(ActorKind.SHARED, defaultSpawnPolicy(), behaviorFactory); + } + + public ActorRef spawnShared( + IsolatePolicy policy, + BehaviorFactory behaviorFactory) { + return spawn(ActorKind.SHARED, policy, behaviorFactory); + } + + /** + * Explicit host-only escape hatch for context-aware shared actor factories + * that intentionally capture host objects. Compiler lowering must never use + * this path. Adversarial policies reject it. + */ + public ActorRef spawnSharedTrusted(BehaviorFactory behaviorFactory) { + return spawnSharedTrusted(defaultSpawnPolicy(), behaviorFactory); + } + + public ActorRef spawnSharedTrusted( + IsolatePolicy policy, + BehaviorFactory behaviorFactory) { + requireSupervisorContext("use trusted captured shared actor construction"); + Objects.requireNonNull(behaviorFactory); + requireTrustedSupplierPolicy(policy); + return spawnInternal(ActorKind.SHARED, policy, behaviorFactory, true); + } + + /** Compatibility path for trusted host callers. */ + public ActorRef spawn( + ActorKind kind, + IsolatePolicy policy, + Supplier> behaviorFactory) { + requireSupervisorContext("use trusted Supplier actor construction"); + Objects.requireNonNull(behaviorFactory); + requireTrustedSupplierPolicy(policy); + return spawnInternal(kind, policy, context -> behaviorFactory.get(), true); + } + + private static void requireStatelessActorFactory(Object factory) { + for (Class type = factory.getClass(); + type != null && type != Object.class; + type = type.getSuperclass()) { + for (java.lang.reflect.Field field : type.getDeclaredFields()) { + int modifiers = field.getModifiers(); + boolean isStatic = java.lang.reflect.Modifier.isStatic(modifiers); + boolean isFinal = java.lang.reflect.Modifier.isFinal(modifiers); + + if (!isStatic) { + throw new SecurityException( + "actor BehaviorFactory must be stateless; captured host state must enter through explicit actor messages/capabilities"); + } + if (!isFinal) { + throw new SecurityException( + "actor BehaviorFactory declares mutable static JVM state '" + + field.getName() + "'; actor construction cannot share static state"); + } + if (!field.trySetAccessible()) { + throw new SecurityException( + "actor BehaviorFactory contains inaccessible static state: " + field.getName()); + } + final Object value; + try { + value = field.get(null); + } catch (IllegalAccessException impossible) { + throw new SecurityException( + "cannot inspect actor BehaviorFactory static state: " + field.getName(), + impossible); + } + if (!isPrivateStaticConstant(value)) { + throw new SecurityException( + "actor BehaviorFactory declares shared static object '" + + field.getName() + + "'; only immutable scalar constants are allowed"); + } + } + } + } + + private void validatePrivateBehaviorState(ActorId owner, Behavior behavior) { + for (Class type = behavior.getClass(); + type != null && type != Object.class; + type = type.getSuperclass()) { + for (java.lang.reflect.Field field : type.getDeclaredFields()) { + int modifiers = field.getModifiers(); + boolean isStatic = java.lang.reflect.Modifier.isStatic(modifiers); + boolean isFinal = java.lang.reflect.Modifier.isFinal(modifiers); + + if (isStatic) { + if (!isFinal) { + throw new SecurityException( + "private actor behavior class declares mutable static JVM state '" + + field.getName() + "'; private actors cannot share static state"); + } + if (!field.trySetAccessible()) { + throw new SecurityException( + "private actor behavior contains inaccessible static state: " + field.getName()); + } + final Object staticValue; + try { + staticValue = field.get(null); + } catch (IllegalAccessException impossible) { + throw new SecurityException( + "cannot inspect private actor static state: " + field.getName(), + impossible); + } + if (!isPrivateStaticConstant(staticValue)) { + throw new SecurityException( + "private actor behavior class declares shared static object '" + + field.getName() + + "'; only immutable scalar constants are allowed"); + } + continue; + } + + if (!isFinal) { + throw new SecurityException( + "private actor behavior field '" + field.getName() + + "' is mutable JVM state; persistent mutable state must use context.privateMemory()"); + } + if (!field.trySetAccessible()) { + throw new SecurityException( + "private actor behavior contains inaccessible captured state: " + field.getName()); + } + final Object value; + try { + value = field.get(behavior); + } catch (IllegalAccessException impossible) { + throw new SecurityException( + "cannot inspect private actor behavior capture: " + field.getName(), + impossible); + } + validatePrivateBehaviorCapture(owner, field.getName(), value); + } + } + } + + private static boolean isPrivateStaticConstant(Object value) { + return value == null + || isScalar(value) + || value instanceof Class; + } + + private void validatePrivateBehaviorCapture(ActorId owner, String fieldName, Object value) { + if (value == null || isScalar(value) || value instanceof Class) return; + + if (value instanceof PrivateMemoryBlock block) { + if (!block.owner().equals(owner)) { + throw new SecurityException( + "private actor behavior captured another actor's memory block in " + fieldName); + } + return; + } + if (value instanceof ActorMemorySlice slice) { + if (!slice.owner().equals(owner)) { + throw new SecurityException( + "private actor behavior captured another actor's memory slice in " + fieldName); + } + return; + } + if (value instanceof MemoryReservation reservation) { + if (!reservation.owner().equals(owner)) { + throw new SecurityException( + "private actor behavior captured another actor's memory reservation in " + fieldName); + } + return; + } + if (value instanceof ActorRef ref) { + if (!ref.ownedBy(this)) { + throw new SecurityException( + "private actor behavior captured an ActorRef from another runtime in " + fieldName); + } + return; + } + if (value instanceof ActorContext actorContext) { + if (!actorContext.self().id().equals(owner) || actorContext.runtime() != this) { + throw new SecurityException( + "private actor behavior captured a foreign actor context in " + fieldName); + } + return; + } + + throw new SecurityException( + "private actor behavior captured mutable/non-private JVM state in " + + fieldName + " (" + value.getClass().getName() + + "); allocate persistent state through context.privateMemory()"); + } + + private void requireTrustedSupplierPolicy(IsolatePolicy policy) { + Objects.requireNonNull(policy); + if (policy.adversarial()) { + throw new SecurityException( + "adversarial actors require the context-aware BehaviorFactory path; " + + "Supplier factories can capture host/shared mutable references"); + } + } + + /** + * Creates the actor identity and private memory slice immediately. Behavior + * initialization later runs on that actor's dispatcher with the actor + * context already installed. + */ + public ActorRef spawn( + ActorKind kind, + IsolatePolicy policy, + BehaviorFactory behaviorFactory) { + return spawnInternal(kind, policy, behaviorFactory, false); + } + + private ActorRef spawnInternal( + ActorKind kind, + IsolatePolicy policy, + BehaviorFactory behaviorFactory, + boolean trustedFactory) { + requireCallerRuntimeAffinity("spawn actors"); + Objects.requireNonNull(kind); + Objects.requireNonNull(policy); + Objects.requireNonNull(behaviorFactory); + requireWithinCeiling(policy); + IsolatePolicy effectivePolicy = kind == ActorKind.PRIVATE + ? policy.withoutCapabilities( + IsolatePolicy.Capability.SHARED_MEMORY, + IsolatePolicy.Capability.ACTOR_SHARE_READONLY, + IsolatePolicy.Capability.JAVA_INTEROP) + : policy; + requireWithinCallerPolicy(effectivePolicy); + if (kind == ActorKind.SHARED) { + effectivePolicy.require(IsolatePolicy.Capability.SHARED_MEMORY, "shared actor spawn"); + } + if (!trustedFactory) { + requireStatelessActorFactory(behaviorFactory); + } + + ActorCell parent = currentActor.get(); + synchronized (runtimeLifecycleLock) { + if (closed.get()) throw new IllegalStateException("actor runtime is closed"); + reserveActorSlot(); + + ActorId id = ActorId.create(); + ActorRef ref = new ActorRef<>(id, kind); + ActorCell cell = null; + boolean linkedToParent = false; + try { + cell = new ActorCell<>( + ref, + kind, + effectivePolicy, + behaviorFactory, + trustedFactory, + parent); + + if (parent != null) { + synchronized (parent.lifecycleLock) { + if (parent.stopped.get() || parent.finalized) { + throw new CancellationException( + "cannot spawn a child from a stopping actor " + parent.ref.id()); + } + parent.children.add(cell); + linkedToParent = true; + actors.put(id, cell); + } + } else { + actors.put(id, cell); + } + return ref; + } catch (RuntimeException | Error failure) { + if (linkedToParent && cell != null) parent.children.remove(cell); + actorCount.decrementAndGet(); + throw failure; + } + } + } + + /** + * Compiler-only lowering target for source-level actor/isoactor callables. + * + * A fresh actor receives exactly one transported message, computes one + * data-only result, then terminates. Synchronous nesting from an actor turn + * is rejected so a bounded dispatcher cannot be starved by callers waiting + * on actors scheduled onto the same runtime. + */ + public R invoke( + ActorKind kind, + M message, + Invocation invocation) { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(invocation, "invocation"); + requireCallerRuntimeAffinity("invoke actor callables"); + if (inActorExecution()) { + throw new IllegalStateException( + "synchronous actor-callable invocation from an actor turn is forbidden; " + + "use mailbox-oriented actor composition"); + } + + IsolatePolicy policy = defaultSpawnPolicy(); + CompletableFuture completion = new CompletableFuture<>(); + ActorRef ref = spawnInternal( + kind, + policy, + factoryContext -> (delivered, turnContext) -> { + try { + @SuppressWarnings("unchecked") + R frozen = (R) freeze(invocation.run(delivered, turnContext)); + completion.complete(frozen); + } catch (VirtualMachineError fatal) { + completion.completeExceptionally(fatal); + throw fatal; + } catch (ThreadDeath fatal) { + completion.completeExceptionally(fatal); + throw fatal; + } catch (LinkageError fatal) { + completion.completeExceptionally(fatal); + throw fatal; + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } finally { + turnContext.self().stop(); + } + }, + true); + + try { + try { + send(ref, message); + } catch (ActorTerminatedException terminatedBeforeDelivery) { + Throwable startupFailure = ref.terminationCause.get(); + if (startupFailure instanceof RuntimeException runtime) throw runtime; + if (startupFailure instanceof Error error) throw error; + if (startupFailure != null) throw new RuntimeException(startupFailure); + throw terminatedBeforeDelivery; + } + long timeoutNanos; + try { + timeoutNanos = policy.maxWallTime().toNanos(); + } catch (ArithmeticException overflow) { + timeoutNanos = Long.MAX_VALUE; + } + if (timeoutNanos <= 0) timeoutNanos = 1; + + long startedAt = System.nanoTime(); + R result = null; + ExecutionException executionFailure = null; + try { + result = completion.get(timeoutNanos, TimeUnit.NANOSECONDS); + } catch (ExecutionException failed) { + // The guest result/failure is not externally complete until + // the one-shot actor has unwound and its carrier has crossed + // back out of TurnExecutor (TruffleContext.leave in OresVM). + executionFailure = failed; + } + + long elapsed = Math.max(0L, System.nanoTime() - startedAt); + long remaining = timeoutNanos == Long.MAX_VALUE + ? Long.MAX_VALUE + : Math.max(0L, timeoutNanos - elapsed); + if (!ref.awaitTermination(remaining, TimeUnit.NANOSECONDS)) { + throw new TimeoutException( + "actor callable completed its guest result but did not leave its carrier before the wall-time deadline"); + } + + if (executionFailure != null) throw executionFailure; + return result; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new CancellationException("actor callable invocation interrupted"); + } catch (TimeoutException timedOut) { + throw new IllegalStateException( + "actor callable exceeded max wall time " + policy.maxWallTime(), + timedOut); + } catch (ExecutionException failed) { + Throwable cause = failed.getCause(); + if (cause instanceof RuntimeException runtime) throw runtime; + if (cause instanceof Error error) throw error; + throw new RuntimeException(cause); + } finally { + if (ref.isAlive()) { + try { + stop(ref); + } catch (IllegalStateException alreadyStopping) { + if (ref.isAlive()) throw alreadyStopping; + } + } + } + } + + private void reserveActorSlot() { + while (true) { + int current = actorCount.get(); + if (current >= dispatcherConfig.maxActors()) { + throw new IllegalStateException( + "actor runtime limit exceeded: maximum " + dispatcherConfig.maxActors()); + } + if (actorCount.compareAndSet(current, current + 1)) return; + } + } + + private void unregisterActor(ActorCell cell) { + if (actors.remove(cell.ref.id(), cell)) { + int remaining = actorCount.decrementAndGet(); + if (remaining < 0) { + actorCount.incrementAndGet(); + throw new IllegalStateException("actor count accounting underflow"); + } + } + } + + public SyncCell syncCell(T initialValue) { + requireCallerRuntimeAffinity("create shared SyncCell values"); + if (closed.get()) throw new IllegalStateException("actor runtime is closed"); + IsolatePolicy callerPolicy = currentActorPolicy(); + if (callerPolicy != null) { + callerPolicy.require(IsolatePolicy.Capability.SHARED_MEMORY, "SyncCell"); + } else { + policyCeiling.require(IsolatePolicy.Capability.SHARED_MEMORY, "SyncCell"); + } + rejectPrivateActorSharedMemoryAccess("SyncCell creation"); + SyncCell cell = new SyncCell<>(initialValue); + syncCells.add(cell); + if (closed.get()) { + cell.close(); + throw new IllegalStateException("actor runtime is closed"); + } + return cell; + } + + private boolean enterSyncCell(SyncCell cell) { + SyncCell held = currentSyncCell.get(); + if (held == null) { + currentSyncCell.set(cell); + return true; + } + if (held != cell) { + throw new IllegalStateException( + "nested synchronization across different SyncCell values is forbidden; " + + "snapshot values first or use one shared cell"); + } + return false; + } + + private void exitSyncCell(boolean entered) { + if (entered) currentSyncCell.remove(); + } + + private void rejectPrivateActorSharedMemoryAccess(String operation) { + ActorCell current = currentActor.get(); + if (current != null && current.kind == ActorKind.PRIVATE) { + throw new IllegalStateException("private actors cannot access synchronized shared memory via " + operation); + } + } + + private void requireSharedActorTurn() { + ActorCell cell = currentActor.get(); + if (cell == null || cell.kind != ActorKind.SHARED) { + throw new IllegalStateException("shared state mutation requires a shared actor mailbox turn"); + } + } + + private void reservePrivateRuntimeBytes(long bytes, ActorId owner, String purpose) { + synchronized (memoryBudgetLock) { + long privateBytes = privateMemoryBytes.get(); + long sharedBytes = sharedMemoryBytes.get(); + long total; + try { + total = Math.addExact(Math.addExact(privateBytes, sharedBytes), bytes); + } catch (ArithmeticException overflow) { + throw new IllegalStateException(purpose + " aggregate accounting overflow"); + } + if (total > policyCeiling.maxHeapBytes()) { + throw new IllegalStateException(purpose + " aggregate runtime limit exceeded for " + owner + + ": requested=" + bytes + " privateUsed=" + privateBytes + + " sharedUsed=" + sharedBytes + " runtimeLimit=" + policyCeiling.maxHeapBytes()); + } + privateMemoryBytes.addAndGet(bytes); + } + } + + private void releasePrivateRuntimeBytes(long bytes, ActorId owner) { + if (bytes == 0) return; + synchronized (memoryBudgetLock) { + long current = privateMemoryBytes.get(); + if (bytes > current) { + throw new IllegalStateException( + "private actor aggregate memory accounting underflow for " + owner + + ": release=" + bytes + " privateUsed=" + current); + } + privateMemoryBytes.set(current - bytes); + } + } + + private void reserveSharedRuntimeBytes(long bytes, String purpose) { + if (closed.get()) throw new IllegalStateException("actor runtime is closed"); + if (bytes < 0) throw new IllegalArgumentException("shared memory reservation cannot be negative"); + if (bytes == 0) return; + synchronized (memoryBudgetLock) { + long privateBytes = privateMemoryBytes.get(); + long sharedBytes = sharedMemoryBytes.get(); + long total; + try { + total = Math.addExact(Math.addExact(privateBytes, sharedBytes), bytes); + } catch (ArithmeticException overflow) { + throw new IllegalStateException(purpose + " aggregate accounting overflow"); + } + if (total > policyCeiling.maxHeapBytes()) { + throw new IllegalStateException(purpose + " aggregate runtime limit exceeded" + + ": requested=" + bytes + " privateUsed=" + privateBytes + + " sharedUsed=" + sharedBytes + " runtimeLimit=" + policyCeiling.maxHeapBytes()); + } + sharedMemoryBytes.addAndGet(bytes); + } + } + + private void releaseSharedRuntimeBytes(long bytes) { + if (bytes == 0 || closed.get()) return; + long remaining = sharedMemoryBytes.addAndGet(-bytes); + if (remaining < 0) { + sharedMemoryBytes.set(0); + throw new IllegalStateException("shared actor memory accounting underflow"); + } + } + + private void requireWithinCeiling(IsolatePolicy child) { + if (!policyCeiling.capabilities().containsAll(child.capabilities())) { + java.util.Set excess = java.util.EnumSet.copyOf(child.capabilities()); + excess.removeAll(policyCeiling.capabilities()); + throw new SecurityException("child actor policy exceeds parent capabilities: " + excess); + } + if (child.maxHeapBytes() > policyCeiling.maxHeapBytes()) { + throw new SecurityException("child actor maxHeapBytes exceeds parent policy"); + } + if (child.maxMailboxMessages() > policyCeiling.maxMailboxMessages()) { + throw new SecurityException("child actor mailbox limit exceeds parent policy"); + } + if (child.maxWallTime().compareTo(policyCeiling.maxWallTime()) > 0) { + throw new SecurityException("child actor wall-time limit exceeds parent policy"); + } + if (policyCeiling.adversarial() && !child.adversarial()) { + throw new SecurityException("child actor cannot weaken an adversarial parent policy"); + } + } + + public boolean isAlive(ActorRef ref) { + Objects.requireNonNull(ref); + if (!ref.ownedBy(this)) return false; + ActorCell cell = actors.get(ref.id()); + return cell != null && !cell.stopped.get(); + } + + public void stop(ActorRef ref) { + requireCallerRuntimeAffinity("stop actors"); + Objects.requireNonNull(ref); + if (!ref.ownedBy(this)) { + throw new IllegalArgumentException("ActorRef belongs to a different ActorRuntime"); + } + requireActorLifecycleAuthority(ref, "stop"); + ActorCell cell = actors.get(ref.id()); + if (cell == null) return; + + cell.stop(); + + // Only a host/supervisor stop is a synchronization point. Actor turns + // may stop self/descendants but must never park a bounded carrier while + // waiting for another actor to finalize. + if (currentActor.get() != null) return; + + try { + cell.awaitFinalized(CLOSE_WAIT_NANOS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "interrupted while waiting for actor " + ref.id() + " to finalize", + interrupted); + } + if (!cell.finalized()) { + throw new IllegalStateException( + "actor " + ref.id() + + " or one of its child actors did not finalize within the stop deadline"); + } + } + + public boolean cancel(ActorRef ref) { + return cancel(ref, CancellationMode.STRUCTURED); + } + + public boolean cancel(ActorRef ref, CancellationMode mode) { + requireCallerRuntimeAffinity("cancel actors"); + Objects.requireNonNull(ref, "ref"); + Objects.requireNonNull(mode, "mode"); + if (mode == CancellationMode.FORCE_ISOLATED) { + requireSupervisorContext("force-cancel isolated actors"); + } + if (!ref.ownedBy(this)) { + throw new IllegalArgumentException("ActorRef belongs to a different ActorRuntime"); + } + requireActorLifecycleAuthority(ref, "cancel"); + + ActorCell cell = actors.get(ref.id()); + if (cell == null || cell.finalized()) return false; + + ActorCancelledException cancellation = new ActorCancelledException( + ref.id(), + "actor " + ref.id() + " was cancelled"); + + if (mode == CancellationMode.FORCE_ISOLATED) { + // The outer isolation boundary must be revoked first. If this + // cannot be proven, force cancellation has no logical side effect. + boolean revoked = forceCancellationHook.test( + ref.id(), cell.executionDomain); + if (!revoked) { + throw new IllegalStateException( + "force cancellation requires a host-owned isolated execution domain; " + + "ordinary shared/private actor carriers can only be cancelled structurally"); + } + } + + cell.cancel(cancellation); + return true; + } + + private ActorTerminatedException terminated(ActorRef ref) { + return new ActorTerminatedException(ref.id(), ref.kind(), ref.terminationCause.get()); + } + + @SuppressWarnings("unchecked") + public void send(ActorRef ref, M message) { + requireCallerRuntimeAffinity("send messages"); + if (closed.get()) throw new IllegalStateException("actor runtime is closed"); + Objects.requireNonNull(ref); + if (!ref.ownedBy(this)) { + throw new IllegalArgumentException("ActorRef belongs to a different ActorRuntime"); + } + ActorCell cell = (ActorCell) actors.get(ref.id()); + if (cell == null || cell.stopped.get()) throw terminated(ref); + if (!cell.reserveMailboxSlot()) { + throw new IllegalStateException("actor mailbox limit exceeded for " + ref.id()); + } + boolean mailboxSlotTransferred = false; + try { + validateMessageGraph(message); + requireMutexTransport(cell, message, new IdentityHashMap<>(), 0); + requireOwnedActorRefs(message, new IdentityHashMap<>(), 0); + long runtimeRemaining = Math.max(0L, policyCeiling.maxHeapBytes() - actorMemoryBytes()); + if (cell.kind == ActorKind.SHARED) { + requireOwnedSharedHandles(message, new IdentityHashMap<>(), 0); + long actorRemaining = Math.max(0L, cell.policy.maxHeapBytes() - cell.sharedMailboxBytes.get()); + long allowed = Math.min(actorRemaining, runtimeRemaining); + try { + estimateSharedTransportBytes(message, new IdentityHashMap<>(), 0, allowed); + } catch (IllegalStateException tooLarge) { + throw new IllegalStateException( + "shared actor mailbox memory limit exceeded for " + ref.id() + ": " + tooLarge.getMessage(), + tooLarge); + } + } else { + long actorRemaining = cell.memorySlice.remainingBytes(); + try { + estimatePrivateTransportBytes(message, new IdentityHashMap<>(), 0, actorRemaining); + } catch (IllegalStateException tooLarge) { + throw new IllegalStateException( + "private actor mailbox limit exceeded for " + ref.id() + ": " + tooLarge.getMessage(), + tooLarge); + } + try { + estimatePrivateTransportBytes(message, new IdentityHashMap<>(), 0, runtimeRemaining); + } catch (IllegalStateException aggregateExceeded) { + throw new IllegalStateException( + "private actor aggregate runtime limit exceeded for " + ref.id() + + ": " + aggregateExceeded.getMessage(), + aggregateExceeded); + } + } + + if (closed.get()) throw new IllegalStateException("actor runtime is closed"); + + Object prepared = cell.kind == ActorKind.PRIVATE ? isolateCopy(message) : freezeForTransport(message); + Runnable release; + if (cell.kind == ActorKind.PRIVATE) { + MemoryReservation reservation; + try { + reservation = cell.memorySlice.reserveMailbox(prepared); + } catch (IllegalStateException exceeded) { + throw new IllegalStateException( + "private actor mailbox limit exceeded for " + ref.id() + ": " + exceeded.getMessage(), + exceeded); + } + release = reservation::close; + } else { + long bytes = estimateSharedMailboxBytes(prepared, new IdentityHashMap<>(), 0); + cell.reserveSharedMailbox(bytes); + release = () -> cell.releaseSharedMailbox(bytes); + } + + MessageEnvelope envelope = MessageEnvelope.message(prepared, release); + List> sharedMutexReservations = List.of(); + boolean admitted = false; + try { + synchronized (runtimeLifecycleLock) { + if (closed.get()) throw new IllegalStateException("actor runtime is closed"); + if (cell.kind == ActorKind.SHARED) { + sharedMutexReservations = reserveSharedMutexBindings(prepared); + } + synchronized (cell.lifecycleLock) { + if (cell.stopped.get()) { + throw terminated(ref); + } + if (!cell.mailbox.tryWrite(envelope)) { + throw new IllegalStateException("actor mailbox limit exceeded for " + ref.id()); + } + mailboxSlotTransferred = true; + admitted = true; + commitSharedMutexBindings(sharedMutexReservations); + } + } + } finally { + if (!admitted) { + abortSharedMutexBindings(sharedMutexReservations); + envelope.close(); + } + } + cell.schedule(); + } finally { + if (!mailboxSlotTransferred) cell.releaseMailboxSlot(false); + } + } + + /** + * Cooperative scheduler hook used by compiler-injected loop safepoints. + * Carrier threads remain an implementation detail. + */ + public void schedulerSafepoint() { + if (closed.get()) throw new ActorCancellationSignal("actor runtime is closing"); + ActorCell cell = currentActor.get(); + if (cell != null && cell.stopped.get()) { + throw new ActorCancellationSignal("actor execution stopped"); + } + + // Carrier identity is not actor identity. A shared carrier thread may + // be interrupted by executor shutdown, host code, or unrelated runtime + // machinery; that interrupt must never be reinterpreted as cancellation + // of whichever actor happens to be multiplexed onto the carrier now. + // Structured actor cancellation is represented by ActorCell/runtime + // state above. Non-cooperative untrusted termination belongs to the + // host-owned revocable isolate boundary used by FORCE_ISOLATED. + Thread.yield(); + } + + @SuppressWarnings("unchecked") + public Shared shareReadonly(T value) { + requireCallerRuntimeAffinity("share readonly values"); + if (closed.get()) throw new IllegalStateException("actor runtime is closed"); + IsolatePolicy callerPolicy = currentActorPolicy(); + if (callerPolicy != null) { + callerPolicy.require(IsolatePolicy.Capability.ACTOR_SHARE_READONLY, "shareReadonly"); + } else { + policyCeiling.require(IsolatePolicy.Capability.ACTOR_SHARE_READONLY, "shareReadonly"); + } + rejectPrivateActorSharedMemoryAccess("shareReadonly"); + requireOwnedSharedHandles(value, new IdentityHashMap<>(), 0); + Object frozen = freeze(value); + rejectSharedMutableHandles(frozen, new IdentityHashMap<>(), 0); + long bytes = estimateFrozenBytes(frozen); + reserveSharedRuntimeBytes(bytes, "shared readonly value"); + Shared shared = new Shared<>((T) frozen, bytes); + sharedValues.add(shared); + if (closed.get()) { + shared.closeFromRuntime(); + throw new IllegalStateException("actor runtime is closed"); + } + return shared; + } + + private void requireMutexTransport( + ActorCell target, + Object value, + IdentityHashMap visiting, + int depth) { + requireGraphDepth(depth); + if (value == null || isScalar(value) || value instanceof ActorRuntime.ActorRef) return; + if (value instanceof ChannelRuntime.Channel + || value instanceof ChannelRuntime.SelectCase + || value instanceof ChannelRuntime.SelectSet) { + throw new IllegalArgumentException( + "Channel/SelectSet capabilities are execution-domain local and cannot cross actor mailboxes; " + + "send data through ActorRef/mailbox transport instead"); + } + if (value instanceof OresMutex.Local) { + throw new IllegalArgumentException("Mutex is actor-local state and cannot cross actor mailboxes"); + } + if (value instanceof OresMutex.Guard) { + throw new IllegalArgumentException("MutexGuard is lexical and cannot cross actor mailboxes"); + } + if (value instanceof OresMutex.Shared sharedMutex) { + if (target.kind != ActorKind.SHARED) { + throw new SecurityException("private actors cannot receive SharedMutex"); + } + ActorKind senderKind = currentActorKind(); + if (senderKind == ActorKind.PRIVATE) { + throw new SecurityException("private actors cannot send SharedMutex"); + } + IsolatePolicy senderPolicy = currentActorPolicy(); + if (senderPolicy != null) { + senderPolicy.require(IsolatePolicy.Capability.SHARED_MEMORY, "SharedMutex actor send"); + } else { + policyCeiling.require(IsolatePolicy.Capability.SHARED_MEMORY, "SharedMutex host send"); + } + target.policy.require(IsolatePolicy.Capability.SHARED_MEMORY, "SharedMutex actor receive"); + sharedMutex.inspectForTransport(payload -> + requireSharedMutexPayloadSafe( + payload, + new IdentityHashMap<>(), + depth + 1)); + return; + } + if (value instanceof Shared shared) { + requireMutexTransport(target, shared.value(), visiting, depth + 1); + return; + } + if (value instanceof SyncCell) return; + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic values cannot cross actor boundaries"); + } + try { + if (value instanceof List list) { + for (Object item : list) requireMutexTransport(target, item, visiting, depth + 1); + } else if (value instanceof Set set) { + for (Object item : set) requireMutexTransport(target, item, visiting, depth + 1); + } else if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + requireMutexTransport(target, entry.getKey(), visiting, depth + 1); + requireMutexTransport(target, entry.getValue(), visiting, depth + 1); + } + } else if (value.getClass().isArray()) { + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + requireMutexTransport(target, Array.get(value, i), visiting, depth + 1); + } + } + } finally { + visiting.remove(value); + } + } + + private void requireSharedMutexPayloadSafe( + Object value, + IdentityHashMap visiting, + int depth) { + requireGraphDepth(depth); + if (value == null || isScalar(value)) return; + + if (value instanceof ActorRuntime.ActorRef ref) { + if (!ref.ownedBy(this)) { + throw new IllegalArgumentException( + "SharedMutex payload contains ActorRef from another ActorRuntime"); + } + return; + } + if (value instanceof Shared shared) { + if (!shared.ownedBy(this)) { + throw new IllegalArgumentException( + "SharedMutex payload contains Shared value from another ActorRuntime"); + } + requireSharedMutexPayloadSafe(shared.value(), visiting, depth + 1); + return; + } + if (value instanceof OresMutex.Shared) { + throw new IllegalArgumentException( + "SharedMutex payload cannot contain another SharedMutex; nested shared locks are not transport-safe until recursive lock-order semantics are defined"); + } + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard) { + throw new IllegalArgumentException( + "SharedMutex payload cannot contain actor-local mutex state"); + } + if (value instanceof SyncCell) { + throw new IllegalArgumentException( + "SharedMutex payload cannot contain SyncCell writable shared state"); + } + if (value instanceof java.util.concurrent.CompletionStage) { + throw new IllegalArgumentException( + "SharedMutex payload cannot contain pending/asynchronous computation state"); + } + + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic SharedMutex payload is not runtime-shared-safe"); + } + try { + if (value instanceof OresMutex.SharedState aggregate) { + for (Object child : aggregate.sharedStateChildren()) { + requireSharedMutexPayloadSafe(child, visiting, depth + 1); + } + return; + } + if (value instanceof List list) { + for (Object item : list) requireSharedMutexPayloadSafe(item, visiting, depth + 1); + return; + } + if (value instanceof Set set) { + for (Object item : set) requireSharedMutexPayloadSafe(item, visiting, depth + 1); + return; + } + if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + requireSharedMutexPayloadSafe(entry.getKey(), visiting, depth + 1); + requireSharedMutexPayloadSafe(entry.getValue(), visiting, depth + 1); + } + return; + } + if (value.getClass().isArray()) { + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + requireSharedMutexPayloadSafe(Array.get(value, i), visiting, depth + 1); + } + return; + } + throw new IllegalArgumentException( + "SharedMutex payload contains opaque host value of type " + + value.getClass().getName()); + } finally { + visiting.remove(value); + } + } + + private void requireOwnedActorRefs( + Object value, + IdentityHashMap visiting, + int depth) { + requireGraphDepth(depth); + if (value == null || isScalar(value)) return; + if (value instanceof ActorRuntime.ActorRef ref) { + if (!ref.ownedBy(this)) { + throw new IllegalArgumentException( + "ActorRef belongs to a different ActorRuntime; cross-runtime actor channels require an explicit bridge"); + } + return; + } + if (value instanceof Shared shared) { + requireOwnedActorRefs(shared.value(), visiting, depth + 1); + return; + } + if (value instanceof SyncCell) return; + if (value instanceof OresMutex.Shared sharedMutex) { + requireOwnedActorRefs(sharedMutex.transportValue(), visiting, depth + 1); + return; + } + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard) { + throw new IllegalArgumentException("actor-local mutex state cannot cross actor boundaries"); + } + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic values cannot cross actor boundaries"); + } + try { + if (value instanceof List list) { + for (Object item : list) requireOwnedActorRefs(item, visiting, depth + 1); + } else if (value instanceof Set set) { + for (Object item : set) requireOwnedActorRefs(item, visiting, depth + 1); + } else if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + requireOwnedActorRefs(entry.getKey(), visiting, depth + 1); + requireOwnedActorRefs(entry.getValue(), visiting, depth + 1); + } + } else if (value.getClass().isArray()) { + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + requireOwnedActorRefs(Array.get(value, i), visiting, depth + 1); + } + } + } finally { + visiting.remove(value); + } + } + + private void requireOwnedSharedHandles( + Object value, + IdentityHashMap visiting, + int depth) { + requireGraphDepth(depth); + if (value == null || isScalar(value)) return; + if (value instanceof ActorRuntime.ActorRef ref) { + if (!ref.ownedBy(this)) { + throw new IllegalArgumentException( + "ActorRef belongs to a different ActorRuntime; cross-runtime actor channels require an explicit bridge"); + } + return; + } + if (value instanceof Shared shared) { + if (!shared.ownedBy(this)) { + throw new IllegalArgumentException( + "Shared value belongs to a different ActorRuntime; copy/freeze it into the destination runtime"); + } + shared.value(); + return; + } + if (value instanceof SyncCell cell) { + if (!cell.ownedBy(this)) { + throw new IllegalArgumentException( + "SyncCell belongs to a different ActorRuntime and cannot cross shared-memory domains"); + } + if (cell.closed()) throw new IllegalArgumentException("SyncCell is closed"); + return; + } + if (value instanceof OresMutex.Shared) { + // Runtime affinity is reserved atomically immediately before mailbox admission. + return; + } + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard) { + throw new IllegalArgumentException("actor-local mutex state cannot cross shared-memory domains"); + } + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic values cannot cross actor boundaries"); + } + try { + if (value instanceof List list) { + for (Object item : list) requireOwnedSharedHandles(item, visiting, depth + 1); + } else if (value instanceof Set set) { + for (Object item : set) requireOwnedSharedHandles(item, visiting, depth + 1); + } else if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + requireOwnedSharedHandles(entry.getKey(), visiting, depth + 1); + requireOwnedSharedHandles(entry.getValue(), visiting, depth + 1); + } + } else if (value.getClass().isArray()) { + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + requireOwnedSharedHandles(Array.get(value, i), visiting, depth + 1); + } + } + } finally { + visiting.remove(value); + } + } + + private List> reserveSharedMutexBindings(Object value) { + Set> unique = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + collectSharedMutexes(value, unique, new IdentityHashMap<>(), 0); + + List> reserved = new ArrayList<>(unique.size()); + try { + for (OresMutex.Shared mutex : unique) { + if (!mutex.reserveRuntimePublication(this)) { + throw new IllegalArgumentException( + "SharedMutex may cross actor mailboxes only within its owning ActorRuntime"); + } + reserved.add(mutex); + } + return List.copyOf(reserved); + } catch (RuntimeException | Error failure) { + abortSharedMutexBindings(reserved); + throw failure; + } + } + + private static void collectSharedMutexes( + Object value, + Set> out, + IdentityHashMap visiting, + int depth) { + requireGraphDepth(depth); + if (value == null || isScalar(value) + || value instanceof ActorRuntime.ActorRef + || value instanceof SyncCell) return; + if (value instanceof OresMutex.Shared sharedMutex) { + if (!out.add(sharedMutex)) return; + if (visiting.put(value, Boolean.TRUE) != null) return; + try { + collectSharedMutexes( + sharedMutex.transportValue(), + out, + visiting, + depth + 1); + } finally { + visiting.remove(value); + } + return; + } + if (value instanceof Shared shared) { + collectSharedMutexes(shared.value(), out, visiting, depth + 1); + return; + } + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard) return; + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic values cannot cross actor boundaries"); + } + try { + if (value instanceof List list) { + for (Object item : list) collectSharedMutexes(item, out, visiting, depth + 1); + } else if (value instanceof Set set) { + for (Object item : set) collectSharedMutexes(item, out, visiting, depth + 1); + } else if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + collectSharedMutexes(entry.getKey(), out, visiting, depth + 1); + collectSharedMutexes(entry.getValue(), out, visiting, depth + 1); + } + } else if (value.getClass().isArray()) { + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + collectSharedMutexes(Array.get(value, i), out, visiting, depth + 1); + } + } + } finally { + visiting.remove(value); + } + } + + private void commitSharedMutexBindings(List> reservations) { + for (OresMutex.Shared mutex : reservations) { + mutex.commitRuntimePublication(this); + } + } + + private void abortSharedMutexBindings(List> reservations) { + for (int i = reservations.size() - 1; i >= 0; i--) { + reservations.get(i).abortRuntimePublication(this); + } + } + + private static void rejectSharedMutableHandles( + Object value, + IdentityHashMap visiting, + int depth) { + requireGraphDepth(depth); + if (value == null || isScalar(value) || value instanceof ActorRuntime.ActorRef) return; + if (value instanceof ActorRuntime.SyncCell) { + throw new IllegalArgumentException("SyncCell is mutable shared state and cannot be wrapped as Shared"); + } + if (value instanceof OresMutex.Shared) { + throw new IllegalArgumentException("SharedMutex is mutable shared state and cannot be wrapped as Shared"); + } + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard) { + throw new IllegalArgumentException("actor-local mutex state cannot be wrapped as Shared"); + } + if (value instanceof Shared shared) { + shared.value(); + return; + } + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic values cannot be shared read-only"); + } + try { + if (value instanceof List list) { + for (Object item : list) rejectSharedMutableHandles(item, visiting, depth + 1); + } else if (value instanceof Set set) { + for (Object item : set) rejectSharedMutableHandles(item, visiting, depth + 1); + } else if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + rejectSharedMutableHandles(entry.getKey(), visiting, depth + 1); + rejectSharedMutableHandles(entry.getValue(), visiting, depth + 1); + } + } else if (value.getClass().isArray()) { + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + rejectSharedMutableHandles(Array.get(value, i), visiting, depth + 1); + } + } else { + throw new IllegalArgumentException("value of type " + value.getClass().getName() + + " is not a runtime-owned immutable actor value"); + } + } finally { + visiting.remove(value); + } + } + + private static void validateMessageGraph(Object value) { + validateMessageGraph(value, new IdentityHashMap<>(), 0, new long[]{0L}); + } + + private static void validateMessageGraph( + Object value, + IdentityHashMap visiting, + int depth, + long[] nodes) { + requireGraphDepth(depth); + if (++nodes[0] > MAX_MESSAGE_GRAPH_NODES) { + throw new IllegalArgumentException( + "actor message graph exceeds maximum node count " + MAX_MESSAGE_GRAPH_NODES); + } + if (value == null || isScalar(value) + || value instanceof ActorRuntime.ActorRef + || value instanceof Shared + || value instanceof SyncCell + || value instanceof OresMutex.Shared) { + return; + } + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard) { + return; // transport-specific validation produces the semantic error. + } + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic values cannot cross actor boundaries"); + } + try { + if (value instanceof List list) { + requireGraphNodeCapacity(nodes[0], list.size()); + for (Object item : list) validateMessageGraph(item, visiting, depth + 1, nodes); + } else if (value instanceof Set set) { + requireGraphNodeCapacity(nodes[0], set.size()); + for (Object item : set) validateMessageGraph(item, visiting, depth + 1, nodes); + } else if (value instanceof Map map) { + requireGraphNodeCapacity(nodes[0], Math.multiplyExact((long) map.size(), 2L)); + for (Map.Entry entry : map.entrySet()) { + validateMessageGraph(entry.getKey(), visiting, depth + 1, nodes); + validateMessageGraph(entry.getValue(), visiting, depth + 1, nodes); + } + } else if (value.getClass().isArray()) { + int length = Array.getLength(value); + requireGraphNodeCapacity(nodes[0], length); + for (int i = 0; i < length; i++) { + validateMessageGraph(Array.get(value, i), visiting, depth + 1, nodes); + } + } + } finally { + visiting.remove(value); + } + } + + private static void requireGraphNodeCapacity(long alreadyVisited, long additionalNodes) { + if (additionalNodes < 0 + || additionalNodes > (long) MAX_MESSAGE_GRAPH_NODES - alreadyVisited) { + throw new IllegalArgumentException( + "actor message graph exceeds maximum node count " + MAX_MESSAGE_GRAPH_NODES); + } + } + + private static void requireGraphDepth(int depth) { + if (depth > MAX_MESSAGE_GRAPH_DEPTH) { + throw new IllegalArgumentException( + "actor message graph exceeds maximum nesting depth " + MAX_MESSAGE_GRAPH_DEPTH); + } + } + + /** + * Converts supported values into a deeply immutable/sendable graph. + * Unknown host objects are rejected instead of being passed by reference. + */ + public static Object freeze(Object value) { + validateMessageGraph(value); + rejectDataFreezeCapabilities(value, new IdentityHashMap<>(), 0); + return freeze(value, new IdentityHashMap<>(), 0); + } + + private static Object freezeForTransport(Object value) { + validateMessageGraph(value); + return freeze(value, new IdentityHashMap<>(), 0); + } + + private static void rejectDataFreezeCapabilities( + Object value, + IdentityHashMap visiting, + int depth) { + requireGraphDepth(depth); + if (value == null || isScalar(value)) return; + if (value instanceof ActorRuntime.ActorRef + || value instanceof Shared + || value instanceof SyncCell + || value instanceof OresMutex.Lock + || value instanceof OresMutex.Guard) { + throw new IllegalArgumentException( + "freeze() accepts data values only; live actor/shared capabilities require explicit actor transport"); + } + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic values cannot be frozen"); + } + try { + if (value instanceof List list) { + for (Object item : list) rejectDataFreezeCapabilities(item, visiting, depth + 1); + } else if (value instanceof Set set) { + for (Object item : set) rejectDataFreezeCapabilities(item, visiting, depth + 1); + } else if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + rejectDataFreezeCapabilities(entry.getKey(), visiting, depth + 1); + rejectDataFreezeCapabilities(entry.getValue(), visiting, depth + 1); + } + } else if (value.getClass().isArray()) { + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + rejectDataFreezeCapabilities(Array.get(value, i), visiting, depth + 1); + } + } + } finally { + visiting.remove(value); + } + } + + private static Object freeze( + Object value, + IdentityHashMap visiting, + int depth) { + requireGraphDepth(depth); + if (isScalar(value)) return value; + if (value instanceof Shared shared) { + shared.value(); + return shared; + } + if (value instanceof ActorRuntime.ActorRef ref) return ref; + if (value instanceof ActorRuntime.SyncCell cell) return cell; + if (value instanceof OresMutex.Shared sharedMutex) return sharedMutex; + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard) { + throw new IllegalArgumentException("actor-local mutex state cannot cross actor boundaries"); + } + + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic values cannot cross actor boundaries"); + } + try { + if (value instanceof List list) { + List frozen = new ArrayList<>(list.size()); + for (Object item : list) frozen.add(freeze(item, visiting, depth + 1)); + return List.copyOf(frozen); + } + if (value instanceof Set set) { + LinkedHashSet frozen = new LinkedHashSet<>(); + for (Object item : set) frozen.add(freeze(item, visiting, depth + 1)); + return Collections.unmodifiableSet(frozen); + } + if (value instanceof Map map) { + Map frozen = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + frozen.put(freeze(entry.getKey(), visiting, depth + 1), freeze(entry.getValue(), visiting, depth + 1)); + } + return Collections.unmodifiableMap(frozen); + } + if (value.getClass().isArray()) { + int length = Array.getLength(value); + List frozen = new ArrayList<>(length); + for (int i = 0; i < length; i++) { + frozen.add(freeze(Array.get(value, i), visiting, depth + 1)); + } + return List.copyOf(frozen); + } + throw new IllegalArgumentException("value of type " + value.getClass().getName() + + " is not Sendable; mutable host objects cannot cross actor boundaries"); + } finally { + visiting.remove(value); + } + } + + /** + * Private transport never retains a shared mutable reference. Immutable + * shared wrappers are unwrapped and copied into the private message graph. + */ + private static Object isolateCopy(Object value) { + return isolateCopy(value, new IdentityHashMap<>(), 0); + } + + private static Object isolateCopy( + Object value, + IdentityHashMap visiting, + int depth) { + requireGraphDepth(depth); + if (isScalar(value)) return value; + if (value instanceof ActorRuntime.SyncCell) { + throw new IllegalArgumentException("private actors cannot receive shared SyncCell values"); + } + if (value instanceof OresMutex.Shared) { + throw new IllegalArgumentException("private actors cannot receive SharedMutex"); + } + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard) { + throw new IllegalArgumentException("actor-local mutex state cannot cross actor boundaries"); + } + if (value instanceof Shared shared) return isolateCopy(shared.value(), visiting, depth + 1); + if (value instanceof ActorRuntime.ActorRef ref) return ref; + + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic values cannot cross private actor boundaries"); + } + try { + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + for (Object item : list) copy.add(isolateCopy(item, visiting, depth + 1)); + return Collections.unmodifiableList(copy); + } + if (value instanceof Set set) { + LinkedHashSet copy = new LinkedHashSet<>(); + for (Object item : set) copy.add(isolateCopy(item, visiting, depth + 1)); + return Collections.unmodifiableSet(copy); + } + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + copy.put(isolateCopy(entry.getKey(), visiting, depth + 1), isolateCopy(entry.getValue(), visiting, depth + 1)); + } + return Collections.unmodifiableMap(copy); + } + if (value.getClass().isArray()) { + int length = Array.getLength(value); + List copy = new ArrayList<>(length); + for (int i = 0; i < length; i++) { + copy.add(isolateCopy(Array.get(value, i), visiting, depth + 1)); + } + return Collections.unmodifiableList(copy); + } + throw new IllegalArgumentException("value of type " + value.getClass().getName() + + " is not Sendable; mutable host objects cannot cross actor boundaries"); + } finally { + visiting.remove(value); + } + } + + private static long estimateSharedTransportBytes( + Object value, + IdentityHashMap visiting, + int depth, + long limit) { + requireGraphDepth(depth); + if (limit < 0) throw new IllegalStateException("message exceeds remaining actor memory"); + + long scalar = scalarLogicalBytes(value); + if (scalar >= 0) return requireWithinLimit(scalar, limit); + if (value instanceof Shared) return requireWithinLimit(48L, limit); + if (value instanceof ActorRuntime.SyncCell) return requireWithinLimit(64L, limit); + if (value instanceof OresMutex.Shared) return requireWithinLimit(64L, limit); + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard) { + throw new IllegalArgumentException("actor-local mutex state cannot cross actor boundaries"); + } + if (value instanceof ActorRuntime.ActorRef) return requireWithinLimit(48L, limit); + + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic values cannot cross actor boundaries"); + } + try { + if (value instanceof List list) { + long total = requireWithinLimit(containerBase(24L, 8L, list.size()), limit); + for (Object item : list) { + total = addWithinLimit(total, + estimateSharedTransportBytes(item, visiting, depth + 1, limit - total), + limit); + } + return total; + } + if (value instanceof Set set) { + long total = requireWithinLimit(containerBase(24L, 16L, set.size()), limit); + for (Object item : set) { + total = addWithinLimit(total, + estimateSharedTransportBytes(item, visiting, depth + 1, limit - total), + limit); + } + return total; + } + if (value instanceof Map map) { + long total = requireWithinLimit(containerBase(24L, 32L, map.size()), limit); + for (Map.Entry entry : map.entrySet()) { + total = addWithinLimit(total, + estimateSharedTransportBytes(entry.getKey(), visiting, depth + 1, limit - total), + limit); + total = addWithinLimit(total, + estimateSharedTransportBytes(entry.getValue(), visiting, depth + 1, limit - total), + limit); + } + return total; + } + if (value.getClass().isArray()) { + int length = Array.getLength(value); + long total = requireWithinLimit(containerBase(24L, 8L, length), limit); + for (int i = 0; i < length; i++) { + total = addWithinLimit(total, + estimateSharedTransportBytes( + Array.get(value, i), visiting, depth + 1, limit - total), + limit); + } + return total; + } + throw new IllegalArgumentException("value of type " + value.getClass().getName() + + " is not Sendable; mutable host objects cannot cross actor boundaries"); + } finally { + visiting.remove(value); + } + } + + private static long estimateSharedMailboxBytes( + Object value, + IdentityHashMap seen, + int depth) { + requireGraphDepth(depth); + long scalar = scalarLogicalBytes(value); + if (scalar >= 0) return scalar; + if (value instanceof Shared) return 48L; + if (value instanceof ActorRuntime.SyncCell) return 64L; + if (value instanceof OresMutex.Shared) return 64L; + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard) { + throw new IllegalArgumentException("actor-local mutex state cannot cross actor boundaries"); + } + if (value instanceof ActorRuntime.ActorRef) return 48L; + if (seen.put(value, Boolean.TRUE) != null) return 0L; + + long bytes = 24L; + if (value instanceof List list) { + bytes = Math.addExact(bytes, 8L * list.size()); + for (Object item : list) { + bytes = Math.addExact(bytes, estimateSharedMailboxBytes(item, seen, depth + 1)); + } + return bytes; + } + if (value instanceof Set set) { + bytes = Math.addExact(bytes, 16L * set.size()); + for (Object item : set) { + bytes = Math.addExact(bytes, estimateSharedMailboxBytes(item, seen, depth + 1)); + } + return bytes; + } + if (value instanceof Map map) { + bytes = Math.addExact(bytes, 32L * map.size()); + for (Map.Entry entry : map.entrySet()) { + bytes = Math.addExact(bytes, estimateSharedMailboxBytes(entry.getKey(), seen, depth + 1)); + bytes = Math.addExact(bytes, estimateSharedMailboxBytes(entry.getValue(), seen, depth + 1)); + } + return bytes; + } + return 64L; + } + + /** + * Validates and estimates a private-actor message before allocating its + * isolation copy. The walk short-circuits as soon as the destination or + * parent-runtime budget cannot admit the logical graph. + */ + private static long estimatePrivateTransportBytes( + Object value, + IdentityHashMap visiting, + int depth, + long limit) { + requireGraphDepth(depth); + if (limit < 0) throw new IllegalStateException("message exceeds remaining actor memory"); + + long scalar = scalarLogicalBytes(value); + if (scalar >= 0) return requireWithinLimit(scalar, limit); + + if (value instanceof ActorRuntime.SyncCell) { + throw new IllegalArgumentException("private actors cannot receive shared SyncCell values"); + } + if (value instanceof Shared shared) { + return estimatePrivateTransportBytes(shared.value(), visiting, depth + 1, limit); + } + if (value instanceof ActorRuntime.ActorRef) return requireWithinLimit(48L, limit); + + if (visiting.put(value, Boolean.TRUE) != null) { + throw new IllegalArgumentException("cyclic values cannot cross private actor boundaries"); + } + try { + if (value instanceof List list) { + long total = requireWithinLimit(containerBase(24L, 8L, list.size()), limit); + for (Object item : list) { + total = addWithinLimit(total, + estimatePrivateTransportBytes(item, visiting, depth + 1, limit - total), + limit); + } + return total; + } + if (value instanceof Set set) { + long total = requireWithinLimit(containerBase(24L, 16L, set.size()), limit); + for (Object item : set) { + total = addWithinLimit(total, + estimatePrivateTransportBytes(item, visiting, depth + 1, limit - total), + limit); + } + return total; + } + if (value instanceof Map map) { + long total = requireWithinLimit(containerBase(24L, 32L, map.size()), limit); + for (Map.Entry entry : map.entrySet()) { + total = addWithinLimit(total, + estimatePrivateTransportBytes(entry.getKey(), visiting, depth + 1, limit - total), + limit); + total = addWithinLimit(total, + estimatePrivateTransportBytes(entry.getValue(), visiting, depth + 1, limit - total), + limit); + } + return total; + } + if (value.getClass().isArray()) { + int length = Array.getLength(value); + long total = requireWithinLimit(containerBase(24L, 8L, length), limit); + for (int i = 0; i < length; i++) { + total = addWithinLimit(total, + estimatePrivateTransportBytes( + Array.get(value, i), visiting, depth + 1, limit - total), + limit); + } + return total; + } + throw new IllegalArgumentException("value of type " + value.getClass().getName() + + " is not Sendable; mutable host objects cannot cross actor boundaries"); + } finally { + visiting.remove(value); + } + } + + private static long scalarLogicalBytes(Object value) { + if (value == null) return 8L; + if (value instanceof Boolean || value instanceof Byte || value instanceof Short + || value instanceof Character || value instanceof Integer || value instanceof Float) return 16L; + if (value instanceof Long || value instanceof Double) return 24L; + if (value instanceof BigInteger integer) return 32L + integer.toByteArray().length; + if (value instanceof BigDecimal decimal) return 48L + decimal.unscaledValue().toByteArray().length; + if (value instanceof String string) return 40L + (long) string.length() * 2L; + if (value instanceof UUID || value instanceof ActorId) return 40L; + if (value instanceof Enum) return 24L; + return -1L; + } + + private static long containerBase(long header, long perEntry, int count) { + try { + return Math.addExact(header, Math.multiplyExact(perEntry, (long) count)); + } catch (ArithmeticException overflow) { + throw new IllegalStateException("actor message size accounting overflow"); + } + } + + private static long requireWithinLimit(long bytes, long limit) { + if (bytes > limit) { + throw new IllegalStateException( + "message requires at least " + bytes + " bytes but only " + limit + " remain"); + } + return bytes; + } + + private static long addWithinLimit(long left, long right, long limit) { + long total; + try { + total = Math.addExact(left, right); + } catch (ArithmeticException overflow) { + throw new IllegalStateException("actor message size accounting overflow"); + } + return requireWithinLimit(total, limit); + } + + /** + * Conservative language-level footprint estimate. This is a quota metric, + * not a promise about HotSpot/Graal object layout. + */ + private static long estimateFrozenBytes(Object value) { + return estimateFrozenBytes(value, new IdentityHashMap<>(), 0); + } + + private static long estimateFrozenBytes( + Object value, + IdentityHashMap seen, + int depth) { + requireGraphDepth(depth); + if (value == null) return 8L; + if (value instanceof Boolean || value instanceof Byte || value instanceof Short + || value instanceof Character || value instanceof Integer || value instanceof Float) return 16L; + if (value instanceof Long || value instanceof Double) return 24L; + if (value instanceof BigInteger integer) return 32L + integer.toByteArray().length; + if (value instanceof BigDecimal decimal) return 48L + decimal.unscaledValue().toByteArray().length; + if (value instanceof String string) return 40L + (long) string.length() * 2L; + if (value instanceof UUID || value instanceof ActorId) return 40L; + if (value instanceof Enum) return 24L; + if (value instanceof ActorRuntime.ActorRef) return 48L; + if (value instanceof ActorRuntime.SyncCell) return 64L; + if (value instanceof OresMutex.Shared) return 64L; + if (value instanceof OresMutex.Local || value instanceof OresMutex.Guard) { + throw new IllegalArgumentException("actor-local mutex state cannot be frozen"); + } + if (value instanceof Shared shared) return estimateFrozenBytes(shared.value(), seen, depth + 1); + + if (seen.put(value, Boolean.TRUE) != null) return 0L; + + long bytes = 24L; + if (value instanceof List list) { + bytes = Math.addExact(bytes, 8L * list.size()); + for (Object item : list) bytes = Math.addExact(bytes, estimateFrozenBytes(item, seen, depth + 1)); + return bytes; + } + if (value instanceof Set set) { + bytes = Math.addExact(bytes, 16L * set.size()); + for (Object item : set) bytes = Math.addExact(bytes, estimateFrozenBytes(item, seen, depth + 1)); + return bytes; + } + if (value instanceof Map map) { + bytes = Math.addExact(bytes, 32L * map.size()); + for (Map.Entry entry : map.entrySet()) { + bytes = Math.addExact(bytes, estimateFrozenBytes(entry.getKey(), seen, depth + 1)); + bytes = Math.addExact(bytes, estimateFrozenBytes(entry.getValue(), seen, depth + 1)); + } + return bytes; + } + if (value.getClass().isArray()) { + int length = Array.getLength(value); + bytes = Math.addExact(bytes, 8L * length); + for (int i = 0; i < length; i++) { + bytes = Math.addExact(bytes, estimateFrozenBytes(Array.get(value, i), seen, depth + 1)); + } + return bytes; + } + return 64L; + } + + private static boolean isScalar(Object value) { + return value == null || value instanceof String || value instanceof Boolean || value instanceof Character + || value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long + || value instanceof Float || value instanceof Double || value instanceof BigInteger || value instanceof BigDecimal + || value instanceof Enum || value instanceof UUID || value instanceof ActorId; + } + + @Override + public void close() { + requireSupervisorContext("close an ActorRuntime"); + + final boolean firstClose; + final List> snapshot; + synchronized (runtimeLifecycleLock) { + firstClose = closed.compareAndSet(false, true); + snapshot = List.copyOf(actors.values()); + } + for (ActorCell cell : snapshot) cell.stop(); + + if (firstClose) { + // Stop executor carriers so queued work is rejected/removed and + // blocked host-side executor operations can wake. This interrupt is + // backend shutdown mechanics only; it is never an actor + // cancellation identity. Every ActorCell above was already marked + // stopped through the structured runtime lifecycle. + privateDispatcher.shutdownNow(); + sharedDispatcher.shutdownNow(); + + // shutdownNow() removes queued tasks without invoking runBatch(). + // Clear those cells' scheduled bits only when no carrier actually + // entered the TurnExecutor boundary. Active carriers retain the bit + // until their executor finally exits. + for (ActorCell cell : snapshot) cell.cancelQueuedScheduleOnShutdown(); + } + + long deadline = System.nanoTime() + CLOSE_WAIT_NANOS; + boolean interrupted = false; + List stillRunning = new ArrayList<>(); + for (ActorCell cell : snapshot) { + long remaining = deadline - System.nanoTime(); + if (remaining > 0) { + try { + cell.awaitFinalized(remaining); + } catch (InterruptedException waitInterrupted) { + interrupted = true; + break; + } + } + if (!cell.finalized()) stillRunning.add(cell.ref.id()); + } + + boolean dispatchersTerminated = true; + for (ExecutorService dispatcher : List.of(privateDispatcher, sharedDispatcher)) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + dispatchersTerminated = false; + break; + } + try { + if (!dispatcher.awaitTermination(remaining, TimeUnit.NANOSECONDS)) { + dispatchersTerminated = false; + break; + } + } catch (InterruptedException waitInterrupted) { + interrupted = true; + dispatchersTerminated = false; + break; + } + } + + for (SyncCell cell : List.copyOf(syncCells)) cell.invalidateFromRuntime(); + syncCells.clear(); + for (Shared shared : List.copyOf(sharedValues)) shared.closeFromRuntime(); + sharedValues.clear(); + sharedMemoryBytes.set(0L); + + if (interrupted) Thread.currentThread().interrupt(); + if (!stillRunning.isEmpty() || !dispatchersTerminated || interrupted) { + if (interrupted) { + for (ActorCell cell : snapshot) { + if (!cell.finalized() && !stillRunning.contains(cell.ref.id())) { + stillRunning.add(cell.ref.id()); + } + } + } + throw new IllegalStateException( + "ActorRuntime close did not observe full actor termination/carrier exit: " + + stillRunning.size() + " actor(s) still running, dispatchersTerminated=" + + dispatchersTerminated); + } + actors.clear(); + actorCount.set(0); + } + + private ExecutorService dispatcherFor(ActorKind kind) { + return kind == ActorKind.PRIVATE ? privateDispatcher : sharedDispatcher; + } + + private static ExecutorService newDispatcher( + int parallelism, + int readyQueueCapacity, + String threadPrefix) { + String requested = System.getProperty(CARRIER_BACKEND_PROPERTY, "auto") + .trim() + .toLowerCase(java.util.Locale.ROOT); + if (!requested.equals("auto") + && !requested.equals("native") + && !requested.equals("java")) { + throw new IllegalArgumentException( + CARRIER_BACKEND_PROPERTY + " must be one of auto, native, java"); + } + + boolean unix = isNativeCarrierPlatform(); + if (!requested.equals("java") && (requested.equals("native") || unix)) { + if (!unix) { + throw new IllegalStateException( + "native Oreslang carriers currently require Linux or macOS"); + } + try { + return new NativeCarrierExecutor( + parallelism, + parallelism, + readyQueueCapacity, + threadPrefix); + } catch (UnsatisfiedLinkError | SecurityException unavailable) { + if (requested.equals("native")) { + throw new IllegalStateException( + "native Oreslang carrier backend was required but liboresthread could not be loaded", + unavailable); + } + // Development portability fallback only. CI and production can + // set -Dores.runtime.carriers=native to make this fail closed. + } + } + + ThreadPoolExecutor executor = new ThreadPoolExecutor( + parallelism, + parallelism, + 0L, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(readyQueueCapacity), + namedFactory(threadPrefix), + new ThreadPoolExecutor.AbortPolicy()); + // JVM workers are a portability fallback, not the preferred Oreslang + // runtime backend. They are created lazily on first scheduled turn. + return executor; + } + + private static boolean isNativeCarrierPlatform() { + String os = System.getProperty("os.name", "") + .toLowerCase(java.util.Locale.ROOT); + return os.contains("linux") || os.contains("mac") || os.contains("darwin"); + } + + private static ThreadFactory namedFactory(String prefix) { + AtomicInteger next = new AtomicInteger(); + return task -> { + Thread thread = new Thread(task, prefix + next.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } + + private final class ActorCell { + private final ActorRef ref; + private final ActorKind kind; + private final IsolatePolicy policy; + private final BehaviorFactory behaviorFactory; + private final boolean trustedFactory; + private final ActorCell parent; + private final Set> children = ConcurrentHashMap.newKeySet(); + private final Set> pendingOperations = ConcurrentHashMap.newKeySet(); + /** Mailbox transport is a bounded Oreslang channel of runtime envelopes. */ + private final ChannelRuntime.Channel mailbox; + private final ActorMemorySlice memorySlice; + private final AtomicBoolean scheduled = new AtomicBoolean(); + private final AtomicBoolean stopped = new AtomicBoolean(); + private final AtomicLong sharedMailboxBytes = new AtomicLong(); + private final Object mailboxAccountingLock = new Object(); + private int queuedMessages; + private int queuedUserMessages; + private final Object lifecycleLock = new Object(); + private final Object executionDomain = new Object(); + private int activeTurns; + private boolean carrierActive; + private boolean finalized; + private Behavior behavior; + + private ActorCell( + ActorRef ref, + ActorKind kind, + IsolatePolicy policy, + BehaviorFactory behaviorFactory, + boolean trustedFactory, + ActorCell parent) { + this.ref = ref; + this.kind = kind; + this.policy = policy; + this.behaviorFactory = behaviorFactory; + this.trustedFactory = trustedFactory; + this.parent = parent; + int mailboxCapacity = policy.maxMailboxMessages() > + Integer.MAX_VALUE - INTERNAL_CONTINUATION_SLOTS + ? Integer.MAX_VALUE + : policy.maxMailboxMessages() + INTERNAL_CONTINUATION_SLOTS; + this.mailbox = new ChannelRuntime.Channel<>(mailboxCapacity); + this.memorySlice = kind == ActorKind.PRIVATE + ? new ActorMemorySlice(ref.id(), policy.maxHeapBytes()) + : null; + } + + private int mailboxCapacityWithControlHeadroom() { + return policy.maxMailboxMessages() > + Integer.MAX_VALUE - INTERNAL_CONTINUATION_SLOTS + ? Integer.MAX_VALUE + : policy.maxMailboxMessages() + INTERNAL_CONTINUATION_SLOTS; + } + + /** + * User-message capacity and runtime-control capacity are distinct. + * Continuations may consume only the reserved control headroom; they + * must never reduce the actor's configured user mailbox allowance. + */ + private boolean reserveMailboxSlot() { + synchronized (mailboxAccountingLock) { + if (queuedUserMessages >= policy.maxMailboxMessages()) return false; + if (queuedMessages >= mailboxCapacityWithControlHeadroom()) return false; + queuedUserMessages++; + queuedMessages++; + return true; + } + } + + private boolean reserveContinuationSlot() { + synchronized (mailboxAccountingLock) { + if (queuedMessages >= mailboxCapacityWithControlHeadroom()) return false; + queuedMessages++; + return true; + } + } + + private void releaseMailboxSlot(boolean continuation) { + synchronized (mailboxAccountingLock) { + if (queuedMessages <= 0) { + throw new IllegalStateException( + "actor mailbox accounting underflow for " + ref.id()); + } + queuedMessages--; + + if (!continuation) { + if (queuedUserMessages <= 0) { + queuedMessages++; + throw new IllegalStateException( + "actor user-mailbox accounting underflow for " + ref.id()); + } + queuedUserMessages--; + } + } + } + + private boolean beginTurn() { + synchronized (lifecycleLock) { + if (stopped.get() || finalized) return false; + activeTurns++; + return true; + } + } + + private void endTurn() { + synchronized (lifecycleLock) { + if (activeTurns <= 0) { + throw new IllegalStateException("actor active-turn accounting underflow for " + ref.id()); + } + activeTurns--; + if (stopped.get() && activeTurns == 0) finalizeStopLocked(); + lifecycleLock.notifyAll(); + } + } + + private void finalizeStopLocked() { + // External termination is not observable until the dispatcher has + // fully crossed back out of the TurnExecutor boundary. For + // Truffle-backed runtimes that boundary owns context enter/leave. + if (finalized + || activeTurns != 0 + || scheduled.get() + || carrierActive + || !children.isEmpty()) return; + finalized = true; + drainMailboxReservations(); + if (memorySlice != null) memorySlice.close(); + try { + actorExitHook.accept(executionDomain); + } catch (VirtualMachineError | ThreadDeath fatal) { + throw fatal; + } catch (Throwable ignored) { + // Actor termination must still complete. Runtime cleanup hooks + // are best-effort and retryable by the process collector. + } + unregisterActor(this); + if (parent != null) parent.childFinalized(this); + lifecycleLock.notifyAll(); + } + + private void childFinalized(ActorCell child) { + synchronized (lifecycleLock) { + children.remove(child); + if (stopped.get() && activeTurns == 0) finalizeStopLocked(); + lifecycleLock.notifyAll(); + } + } + + private boolean finalized() { + synchronized (lifecycleLock) { + return finalized; + } + } + + private void awaitFinalized(long remainingNanos) throws InterruptedException { + long deadline = System.nanoTime() + Math.max(0L, remainingNanos); + synchronized (lifecycleLock) { + while (!finalized) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) return; + long millis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remaining)); + lifecycleLock.wait(millis); + } + } + } + + private void cancelQueuedScheduleOnShutdown() { + synchronized (lifecycleLock) { + if (scheduled.get() && !carrierActive) { + scheduled.set(false); + if (stopped.get() && activeTurns == 0) finalizeStopLocked(); + lifecycleLock.notifyAll(); + } + } + } + + private void reserveSharedMailbox(long bytes) { + if (bytes < 0) throw new IllegalArgumentException("shared mailbox reservation cannot be negative"); + synchronized (lifecycleLock) { + if (closed.get()) throw new IllegalStateException("actor runtime is closed"); + if (stopped.get()) throw terminated(ref); + long current = sharedMailboxBytes.get(); + long next; + try { + next = Math.addExact(current, bytes); + } catch (ArithmeticException overflow) { + throw new IllegalStateException("shared actor mailbox memory accounting overflow"); + } + if (next > policy.maxHeapBytes()) { + throw new IllegalStateException("shared actor mailbox memory limit exceeded for " + ref.id() + + ": requested=" + bytes + " used=" + current + " limit=" + policy.maxHeapBytes()); + } + reserveSharedRuntimeBytes(bytes, "shared actor mailbox"); + sharedMailboxBytes.set(next); + } + } + + private void releaseSharedMailbox(long bytes) { + if (bytes == 0) return; + synchronized (lifecycleLock) { + long current = sharedMailboxBytes.get(); + long next = Math.max(0L, current - bytes); + sharedMailboxBytes.set(next); + releaseSharedRuntimeBytes(bytes); + } + } + + private void schedule() { + if (stopped.get() || closed.get()) return; + if (!scheduled.compareAndSet(false, true)) return; + try { + dispatcherFor(kind).execute(this::runBatch); + } catch (RejectedExecutionException rejected) { + scheduled.set(false); + stop(); + if (!closed.get()) throw rejected; + } + } + + private void runBatch() { + ACTOR_CARRIER.set(Boolean.TRUE); + boolean carrierEntered = false; + boolean reschedule = false; + try { + synchronized (lifecycleLock) { + // shutdownNow() may leave a queued executor task that races + // with explicit queued-schedule cancellation. If shutdown + // won that race, this task is an inert no-op. + if (!scheduled.get()) return; + + if (stopped.get() || closed.get()) { + scheduled.set(false); + if (activeTurns == 0) finalizeStopLocked(); + lifecycleLock.notifyAll(); + return; + } + + carrierActive = true; + carrierEntered = true; + } + + turnExecutor.execute(this::runBatchEntered); + } catch (Throwable failure) { + fail(failure); + if (failure instanceof VirtualMachineError fatal) throw fatal; + if (failure instanceof ThreadDeath fatal) throw fatal; + if (failure instanceof LinkageError fatal) throw fatal; + } finally { + // The actor remains logically scheduled until the TurnExecutor + // returns. OresContext's executor leaves the TruffleContext in + // its own finally block, so clearing this bit any earlier lets + // shutdown/finalization race a carrier that still owns guest + // context state. + synchronized (lifecycleLock) { + if (carrierEntered) carrierActive = false; + if (scheduled.get()) scheduled.set(false); + if (stopped.get() && activeTurns == 0) finalizeStopLocked(); + reschedule = !stopped.get() + && !closed.get() + && !finalized + && !mailbox.isEmpty(); + lifecycleLock.notifyAll(); + } + ACTOR_CARRIER.remove(); + if (reschedule) schedule(); + } + } + + @SuppressWarnings("unchecked") + private void runBatchEntered() { + currentActor.set(this); + CURRENT_ACTOR_EXECUTION.set(new ActorExecutionContext( + ActorRuntime.this, ref.id(), kind, policy, executionDomain)); + boolean turnActive = beginTurn(); + try { + if (!turnActive) return; + + ActorContext context = new ActorContext<>() { + @Override public ActorRef self() { return ref; } + @Override public ActorRuntime runtime() { return ActorRuntime.this; } + @Override public IsolatePolicy policy() { return policy; } + @Override public ActorKind kind() { return kind; } + @Override public Optional privateMemory() { + return Optional.ofNullable(memorySlice); + } + }; + + if (behavior == null) { + Behavior created = Objects.requireNonNull( + behaviorFactory.create(context), + "actor behaviorFactory returned null"); + if (kind == ActorKind.PRIVATE && !trustedFactory) { + validatePrivateBehaviorState(ref.id(), created); + } + behavior = created; + } + + int processed = 0; + while (processed < dispatcherConfig.throughput() && !stopped.get()) { + MessageEnvelope envelope = mailbox.tryRead().orElse(null); + if (envelope == null) break; + releaseMailboxSlot(envelope.isContinuation()); + try (envelope) { + /* + * Linearize queued-work start against stop/cancel. + * If cancellation acquired lifecycleLock first, this + * envelope is discarded and no new guest work begins. + * If this gate wins first, the envelope is already-active + * actor work; a later cancellation is observed at the + * normal scheduler safepoints. + */ + synchronized (lifecycleLock) { + if (stopped.get() || finalized) break; + } + + if (envelope.isContinuation()) { + envelope.continuation().run(); + } else { + behavior.onMessage((M) envelope.value(), context); + } + if (kind == ActorKind.PRIVATE && !trustedFactory) { + // Private state that survives a mailbox turn must + // remain in actor-owned storage/capabilities. This + // catches behavior fields that were null/immutable + // at construction but later retain a mutable JVM + // object across turns. + validatePrivateBehaviorState(ref.id(), behavior); + } + } + processed++; + } + } catch (Throwable failure) { + // Actor cancellation is a control-plane unwind, not a guest + // failure. The actor was already marked stopped by the + // supervisor/cancel path; finally/endTurn completes teardown. + if (!(failure instanceof ActorCancellationSignal)) { + // Fail-stop supervision for ordinary actor failures. Fatal + // VM errors are cleaned up and then rethrown. + fail(failure); + } + if (failure instanceof VirtualMachineError fatal) throw fatal; + if (failure instanceof ThreadDeath fatal) throw fatal; + if (failure instanceof LinkageError fatal) throw fatal; + } finally { + if (turnActive) endTurn(); + CURRENT_ACTOR_EXECUTION.remove(); + currentActor.remove(); + // scheduled/finalization/rescheduling belong to runBatch(), + // after TurnExecutor.execute(...) has returned. + } + } + + private void drainMailboxReservations() { + MessageEnvelope envelope; + while ((envelope = mailbox.tryRead().orElse(null)) != null) { + releaseMailboxSlot(envelope.isContinuation()); + envelope.close(); + } + } + + private void fail(Throwable failure) { + terminateTree(failure, true); + } + + private void stop() { + terminateTree(null, false); + } + + private void cancel(ActorCancelledException cancellation) { + terminateTree(cancellation, true); + } + + private void terminateTree(Throwable cause, boolean recordCause) { + List> descendants; + List> pending; + synchronized (lifecycleLock) { + if (finalized) return; + if (recordCause && cause != null) { + ref.terminationCause.compareAndSet(null, cause); + } + stopped.set(true); + if (cause == null) mailbox.close(); + else mailbox.close(cause); + drainMailboxReservations(); + descendants = List.copyOf(children); + pending = List.copyOf(pendingOperations); + pendingOperations.clear(); + finalizeStopLocked(); + lifecycleLock.notifyAll(); + } + + // Cancellation removes channel/select waiter registrations before + // any future channel activity can revive work for this dead actor. + for (OresFuture future : pending) future.cancel(false); + + for (ActorCell child : descendants) { + child.cancel(new ActorCancelledException( + child.ref.id(), + "parent actor " + ref.id() + " terminated")); + } + } + } +} diff --git a/src/main/java/dev/oreslang/runtime/AsyncRuntime.java b/src/main/java/dev/oreslang/runtime/AsyncRuntime.java new file mode 100644 index 00000000..cb2bc181 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/AsyncRuntime.java @@ -0,0 +1,195 @@ +package dev.oreslang.runtime; + +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Context-owned scheduler for ordinary Oreslang async callables. + * + *

The language contract is deliberately task/future based rather than + * thread based: callers receive a {@link CompletionStage}; the backing carrier + * is an implementation detail. The initial interpreter uses virtual threads so + * an async callable never consumes an actor dispatcher worker while it is + * blocked in host/runtime code. A future compiler can replace this with + * continuation/state-machine lowering without changing source semantics.

+ */ +public final class AsyncRuntime implements AutoCloseable { + private static final long CLOSE_WAIT_MILLIS = 500L; + private static final ThreadLocal ASYNC_CARRIER = + ThreadLocal.withInitial(() -> Boolean.FALSE); + + @FunctionalInterface + public interface TurnExecutor { + void execute(Runnable turn); + + static TurnExecutor direct() { + return Runnable::run; + } + } + + @FunctionalInterface + public interface Task { + T run() throws Exception; + } + + private final TurnExecutor turnExecutor; + private final ExecutorService executor; + private final AtomicBoolean closed = new AtomicBoolean(); + + public AsyncRuntime() { + this(TurnExecutor.direct()); + } + + public AsyncRuntime(TurnExecutor turnExecutor) { + this.turnExecutor = Objects.requireNonNull(turnExecutor, "turnExecutor"); + ThreadFactory factory = Thread.ofVirtual().name("ores-async-", 0L).factory(); + this.executor = Executors.newThreadPerTaskExecutor(factory); + } + + /** + * True only while a host-owned async carrier is entering/executing guest + * code. Guest source cannot toggle this marker or create these carriers. + */ + public static boolean isAsyncCarrierThread() { + return Boolean.TRUE.equals(ASYNC_CARRIER.get()); + } + + public boolean isClosed() { + return closed.get(); + } + + /** + * Schedule one async callable and return immediately with a composable + * future. Cancellation is propagated to the backing task and therefore + * interrupts the current virtual carrier when possible. + */ + public CompletableFuture submit(Task task) { + Objects.requireNonNull(task, "task"); + if (closed.get()) throw new RejectedExecutionException("async runtime is closed"); + + TaskFuture completion = new TaskFuture<>(); + final Future scheduled; + try { + scheduled = executor.submit(() -> runTask(task, completion)); + } catch (RejectedExecutionException rejected) { + throw new RejectedExecutionException("async runtime is closed", rejected); + } + completion.attach(scheduled); + + // Close may race submission after the initial check. + if (closed.get()) completion.cancel(true); + return completion; + } + + private void runTask(Task task, TaskFuture completion) { + if (completion.isCancelled()) return; + + AtomicReference value = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + ASYNC_CARRIER.set(Boolean.TRUE); + try { + turnExecutor.execute(() -> { + if (completion.isCancelled()) return; + try { + value.set(task.run()); + } catch (Throwable thrown) { + failure.set(thrown); + } + }); + } catch (Throwable thrown) { + failure.compareAndSet(null, thrown); + } finally { + ASYNC_CARRIER.remove(); + } + + if (completion.isCancelled()) return; + Throwable thrown = failure.get(); + if (thrown == null) completion.complete(value.get()); + else completion.completeExceptionally(thrown); + } + + /** + * Blocking bridge used only where the interpreter has not yet lowered an + * await to a continuation. It preserves interruption/cancellation and + * unwraps the original failure instead of leaking CompletionException. + * + *

An actor carrier is never allowed to park here. Actor await must be + * lowered to mailbox-turn suspension/resumption.

+ */ + public static T await(CompletionStage stage) { + Objects.requireNonNull(stage, "stage"); + CompletableFuture future = stage.toCompletableFuture(); + if (ActorRuntime.inActorExecution() && !future.isDone()) { + throw new IllegalStateException( + "await would block an actor dispatcher carrier; actor continuation lowering must suspend/resume the mailbox turn"); + } + + try { + return future.get(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + CancellationException cancelled = new CancellationException("await interrupted"); + cancelled.initCause(interrupted); + throw cancelled; + } catch (ExecutionException failed) { + Throwable cause = failed.getCause(); + if (cause instanceof RuntimeException runtime) throw runtime; + if (cause instanceof Error error) throw error; + throw new RuntimeException(cause); + } + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + executor.shutdownNow(); + + boolean interrupted = false; + try { + if (!executor.awaitTermination(CLOSE_WAIT_MILLIS, TimeUnit.MILLISECONDS)) { + throw new IllegalStateException( + "async runtime did not terminate all tasks after cancellation"); + } + } catch (InterruptedException waitInterrupted) { + interrupted = true; + throw new IllegalStateException( + "interrupted while closing async runtime", + waitInterrupted); + } finally { + if (interrupted) Thread.currentThread().interrupt(); + } + } + + private static final class TaskFuture extends CompletableFuture { + private final AtomicReference> task = new AtomicReference<>(); + + private void attach(Future scheduled) { + if (!task.compareAndSet(null, scheduled)) { + scheduled.cancel(true); + throw new IllegalStateException("async task carrier already attached"); + } + if (isCancelled()) scheduled.cancel(true); + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + boolean cancelled = super.cancel(mayInterruptIfRunning); + Future scheduled = task.get(); + if (cancelled && scheduled != null) { + scheduled.cancel(mayInterruptIfRunning); + } + return cancelled; + } + } +} diff --git a/src/main/java/dev/oreslang/runtime/Awaitable.java b/src/main/java/dev/oreslang/runtime/Awaitable.java new file mode 100644 index 00000000..63826cd4 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/Awaitable.java @@ -0,0 +1,14 @@ +package dev.oreslang.runtime; + +/** + * Compiler/runtime projection used by the Oreslang {@code await} operator. + * + *

{@code await} does not execute a continuation from this method. The + * projection only exposes the runtime Future that represents readiness. The + * owning actor/task scheduler remains solely responsible for suspending, + * unwinding the current turn, and dispatching the continuation later.

+ */ +@FunctionalInterface +public interface Awaitable { + OresFuture getAwaited(); +} diff --git a/src/main/java/dev/oreslang/runtime/CapabilityChecker.java b/src/main/java/dev/oreslang/runtime/CapabilityChecker.java new file mode 100644 index 00000000..01a42daa --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/CapabilityChecker.java @@ -0,0 +1,421 @@ +package dev.oreslang.runtime; + +import dev.oreslang.ast.Ast; +import dev.oreslang.imports.ImportRules; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Language-level capability admission pass. This executes before guest + * statements and complements Graal/OS isolation rather than replacing it. + * + * Restricted capabilities are checked transitively through type aliases and + * stored class state so a private actor cannot launder shared-memory authority + * behind an otherwise ordinary-looking type. + */ +public final class CapabilityChecker { + private final Map aliases = new HashMap<>(); + private final Map classes = new HashMap<>(); + private final Map functions = new HashMap<>(); + private final Set ambiguousAliases = new HashSet<>(); + private final Set ambiguousClasses = new HashSet<>(); + private final Set ambiguousFunctions = new HashSet<>(); + private final Map javaImports = new HashMap<>(); + private final Set callableStack = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + private final Set methodStack = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + private final Set typeExpansionStack = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + + private CapabilityChecker(Ast.Program program) { + for (Ast.ImportDecl imported : program.imports()) { + ImportRules.validate(imported); + if (!ImportRules.isJavaPath(imported.path())) continue; + String className = ImportRules.javaClassName(imported.path()); + for (String binding : ImportRules.exposedBindings(imported)) { + javaImports.put(binding, className); + } + } + + for (Ast.ModuleDecl module : program.modules()) { + for (Ast.Decl declaration : module.declarations()) { + if (declaration instanceof Ast.TypeAliasDecl alias) { + index(aliases, ambiguousAliases, module.name(), alias.name(), alias); + } else if (declaration instanceof Ast.ClassDecl klass) { + index(classes, ambiguousClasses, module.name(), klass.name(), klass); + } else if (declaration instanceof Ast.FunctionDecl fn) { + index(functions, ambiguousFunctions, module.name(), fn.name(), fn); + } + } + } + } + + public static void check(Ast.Program program, IsolatePolicy policy) { + new CapabilityChecker(program).checkProgram(program, policy); + } + + private static void index( + Map values, + Set ambiguous, + String module, + String name, + T value) { + values.put(module + "." + name, value); + T previous = values.putIfAbsent(name, value); + if (previous != null && previous != value) { + values.remove(name); + ambiguous.add(name); + } + } + + private Ast.TypeAliasDecl findAlias(String name) { + return ambiguousAliases.contains(name) ? null : aliases.get(name); + } + + private Ast.ClassDecl findClass(String name) { + return ambiguousClasses.contains(name) ? null : classes.get(name); + } + + private Ast.FunctionDecl findFunction(String name) { + return ambiguousFunctions.contains(name) ? null : functions.get(name); + } + + private void checkReferencedFunction(Ast.FunctionDecl fn, IsolatePolicy policy) { + if (!callableStack.add(fn)) return; + try { + IsolatePolicy effective = actorPolicy(fn.actorKind(), policy); + if (fn.actorKind() == Ast.ActorKind.SHARED) { + require(effective, IsolatePolicy.Capability.SHARED_MEMORY, "shared actor fnc " + fn.name()); + } + checkCallableTypes(fn.parameters(), fn.returnType(), effective); + checkStatements(fn.body(), effective); + } finally { + callableStack.remove(fn); + } + } + + private Ast.MethodDecl findStaticMethod(Ast.Expr callee, int arity) { + if (!(callee instanceof Ast.MemberExpr member)) return null; + String ownerName = memberPath(member.receiver()); + if (ownerName == null) return null; + Ast.ClassDecl owner = findClass(ownerName); + if (owner == null) return null; + + Ast.MethodDecl found = null; + for (Ast.MethodDecl method : owner.methods()) { + if (!method.isStatic() + || !method.name().equals(member.member()) + || method.parameters().size() != arity) { + continue; + } + if (found != null) return null; + found = method; + } + return found; + } + + private Ast.MethodDecl findUniqueStaticMethodValue(Ast.MemberExpr member) { + String ownerName = memberPath(member.receiver()); + if (ownerName == null) return null; + Ast.ClassDecl owner = findClass(ownerName); + if (owner == null) return null; + + Ast.MethodDecl found = null; + for (Ast.MethodDecl method : owner.methods()) { + if (!method.isStatic() || !method.name().equals(member.member())) continue; + if (found != null) return null; + found = method; + } + return found; + } + + private void checkReferencedMethod(Ast.MethodDecl method, IsolatePolicy policy) { + if (!methodStack.add(method)) return; + try { + checkType(method.explicitReceiverType(), policy); + checkCallableTypes(method.parameters(), method.returnType(), policy); + checkStatements(method.body(), policy); + } finally { + methodStack.remove(method); + } + } + + private void checkProgram(Ast.Program program, IsolatePolicy policy) { + for (Ast.ImportDecl imported : program.imports()) { + if (ImportRules.isJavaPath(imported.path())) { + require(policy, IsolatePolicy.Capability.JAVA_INTEROP, + "Java host import " + ImportRules.javaClassName(imported.path())); + } + } + + for (Ast.ModuleDecl module : program.modules()) { + for (Ast.Decl declaration : module.declarations()) { + if (declaration instanceof Ast.FunctionDecl fn) { + IsolatePolicy actorPolicy = actorPolicy(fn.actorKind(), policy); + if (fn.actorKind() == Ast.ActorKind.SHARED) { + require(actorPolicy, IsolatePolicy.Capability.SHARED_MEMORY, "shared actor fnc " + fn.name()); + } + checkCallableTypes(fn.parameters(), fn.returnType(), actorPolicy); + checkStatements(fn.body(), actorPolicy); + } else if (declaration instanceof Ast.ClassDecl klass) { + IsolatePolicy actorPolicy = actorPolicy(klass.actorKind(), policy); + if (klass.actorKind() == Ast.ActorKind.SHARED) { + require(actorPolicy, IsolatePolicy.Capability.SHARED_MEMORY, "shared actor " + klass.name()); + } + for (Ast.TypeRef parent : klass.parents()) checkType(parent, actorPolicy); + for (Ast.TypeRef iface : klass.interfaces()) checkType(iface, actorPolicy); + for (Ast.FieldDecl field : klass.fields()) { + checkType(field.type(), actorPolicy); + if (field.initializer() != null) checkExpr(field.initializer(), actorPolicy); + } + for (Ast.MethodDecl method : klass.methods()) { + checkType(method.explicitReceiverType(), actorPolicy); + checkCallableTypes(method.parameters(), method.returnType(), actorPolicy); + checkStatements(method.body(), actorPolicy); + } + } else if (declaration instanceof Ast.InterfaceDecl iface) { + for (Ast.TypeRef parent : iface.parents()) checkType(parent, policy); + for (Ast.InterfaceMember member : iface.members()) { + if (member instanceof Ast.InterfaceFunctionDecl fn) { + checkCallableTypes(fn.parameters(), fn.returnType(), policy); + } else if (member instanceof Ast.InterfaceFieldDecl field) { + checkType(field.type(), policy); + } + } + } else if (declaration instanceof Ast.FieldDecl field) { + checkType(field.type(), policy); + if (field.initializer() != null) checkExpr(field.initializer(), policy); + } else if (declaration instanceof Ast.TypeAliasDecl alias) { + checkType(alias.target(), policy); + } + } + } + } + + private static IsolatePolicy actorPolicy(Ast.ActorKind kind, IsolatePolicy parent) { + if (kind != Ast.ActorKind.PRIVATE) return parent; + return parent.withoutCapabilities( + IsolatePolicy.Capability.SHARED_MEMORY, + IsolatePolicy.Capability.ACTOR_SHARE_READONLY, + IsolatePolicy.Capability.JAVA_INTEROP, + IsolatePolicy.Capability.JAVA_SOURCE_INTEROP); + } + + private void checkCallableTypes( + List parameters, + Ast.TypeRef returnType, + IsolatePolicy policy) { + for (Ast.Param parameter : parameters) checkType(parameter.type(), policy); + checkType(returnType, policy); + } + + private void checkType(Ast.TypeRef type, IsolatePolicy policy) { + if (type == null) return; + + if (type.name().equals("SharedMutex")) { + require(policy, IsolatePolicy.Capability.SHARED_MEMORY, "SharedMutex"); + } + String javaClass = javaImports.get(type.name()); + if (javaClass != null) { + require(policy, IsolatePolicy.Capability.JAVA_INTEROP, "Java host import " + javaClass); + } + for (Ast.TypeRef argument : type.arguments()) checkType(argument, policy); + if (type.isBorrow()) checkType(type.borrowedTarget(), policy); + + Ast.TypeAliasDecl alias = findAlias(type.name()); + if (alias != null && typeExpansionStack.add(alias)) { + try { + checkType(alias.target(), policy); + } finally { + typeExpansionStack.remove(alias); + } + } + + Ast.ClassDecl klass = findClass(type.name()); + if (klass != null && typeExpansionStack.add(klass)) { + try { + for (Ast.TypeRef parent : klass.parents()) checkType(parent, policy); + for (Ast.TypeRef iface : klass.interfaces()) checkType(iface, policy); + for (Ast.FieldDecl field : klass.fields()) { + checkType(field.type(), policy); + if (field.initializer() != null) checkExpr(field.initializer(), policy); + } + // An object stored in a private actor is itself an authority + // carrier. Its instance methods must therefore be admissible + // under the actor's policy; otherwise an ordinary class could + // hide a SharedMutex/process.share_readonly call behind a method. + for (Ast.MethodDecl method : klass.methods()) { + if (method.isStatic()) continue; + checkType(method.explicitReceiverType(), policy); + checkCallableTypes(method.parameters(), method.returnType(), policy); + checkStatements(method.body(), policy); + } + } finally { + typeExpansionStack.remove(klass); + } + } + } + + private void checkStatements(List statements, IsolatePolicy policy) { + for (Ast.Stmt stmt : statements) { + if (stmt instanceof Ast.BindingStmt s) { + checkType(s.declaredType(), policy); + checkExpr(s.initializer(), policy); + } + else if (stmt instanceof Ast.DestructureStmt s) checkExpr(s.initializer(), policy); + else if (stmt instanceof Ast.ReturnStmt s && s.value() != null) checkExpr(s.value(), policy); + else if (stmt instanceof Ast.ExprStmt s) checkExpr(s.expression(), policy); + else if (stmt instanceof Ast.DeferStmt s) checkExpr(s.expression(), policy); + else if (stmt instanceof Ast.BlockStmt s) checkStatements(s.body(), policy); + else if (stmt instanceof Ast.LoopStmt s) checkStatements(s.body(), policy); + else if (stmt instanceof Ast.IfStmt s) { + for (Ast.IfBranch b : s.branches()) { + checkExpr(b.condition(), policy); + checkStatements(b.body(), policy); + } + checkStatements(s.elseBody(), policy); + } else if (stmt instanceof Ast.MatchStmt s) { + checkExpr(s.subject(), policy); + for (Ast.MatchArm arm : s.arms()) { + if (arm.guard() != null) checkExpr(arm.guard(), policy); + checkStatements(arm.body(), policy); + } + } else if (stmt instanceof Ast.SwitchStmt s) { + checkExpr(s.subject(), policy); + for (Ast.SwitchCase arm : s.cases()) { + for (Ast.Expr constant : arm.constants()) { + checkExpr(constant, policy); + } + checkStatements(arm.body(), policy); + } + checkStatements(s.defaultBody(), policy); + } else if (stmt instanceof Ast.SelectStmt s) { + for (Ast.SelectArm arm : s.arms()) { + if (arm.channel() != null) checkExpr(arm.channel(), policy); + if (arm.value() != null) checkExpr(arm.value(), policy); + checkStatements(arm.body(), policy); + } + } else if (stmt instanceof Ast.TryStmt s) { + checkStatements(s.body(), policy); + checkStatements(s.catchBody(), policy); + checkStatements(s.finallyBody(), policy); + } else if (stmt instanceof Ast.ForOfDestructureStmt s) { + checkExpr(s.iterable(), policy); + checkStatements(s.body(), policy); + } else if (stmt instanceof Ast.ForOfStmt s) { + checkExpr(s.iterable(), policy); + checkStatements(s.body(), policy); + } else if (stmt instanceof Ast.ForStmt s) { + if (s.initializer() != null) checkStatements(List.of(s.initializer()), policy); + if (s.condition() != null) checkExpr(s.condition(), policy); + if (s.update() != null) checkExpr(s.update(), policy); + checkStatements(s.body(), policy); + } + } + } + + private void checkExpr(Ast.Expr expr, IsolatePolicy policy) { + if (expr instanceof Ast.NameExpr javaName) { + String javaClass = javaImports.get(javaName.name()); + if (javaClass != null) { + require(policy, IsolatePolicy.Capability.JAVA_INTEROP, "Java host import " + javaClass); + } + } + + if (expr instanceof Ast.NameExpr n && n.name().equals("print")) { + require(policy, IsolatePolicy.Capability.STDOUT, "print"); + } else if (expr instanceof Ast.NameExpr n && n.name().equals("SharedMutex")) { + require(policy, IsolatePolicy.Capability.SHARED_MEMORY, "SharedMutex"); + } else if (expr instanceof Ast.NameExpr n) { + // Function values can be laundered through locals/callbacks before + // invocation. Check the referenced body at the point the function + // enters the actor's value graph, not only for direct call syntax. + Ast.FunctionDecl referenced = findFunction(n.name()); + if (referenced != null) checkReferencedFunction(referenced, policy); + } + else if (expr instanceof Ast.CallExpr c) { + String target = memberPath(c.callee()); + if (target != null) { + Ast.FunctionDecl fn = findFunction(target); + if (fn != null) checkReferencedFunction(fn, policy); + } + Ast.MethodDecl staticMethod = findStaticMethod(c.callee(), c.arguments().size()); + if (staticMethod != null) checkReferencedMethod(staticMethod, policy); + checkExpr(c.callee(), policy); + for (Ast.Expr arg : c.arguments()) checkExpr(arg, policy); + } else if (expr instanceof Ast.MemberExpr m) { + String path = memberPath(m); + if (path != null) { + // Qualified module function values have the same laundering + // risk as unqualified function values. + Ast.FunctionDecl referenced = findFunction(path); + if (referenced != null) checkReferencedFunction(referenced, policy); + Ast.MethodDecl staticValue = findUniqueStaticMethodValue(m); + if (staticValue != null) checkReferencedMethod(staticValue, policy); + if (path.startsWith("stdio.") || path.equals("stdio")) require(policy, IsolatePolicy.Capability.STDOUT, path); + if (path.startsWith("process.descriptor") || path.equals("process.context_id")) require(policy, IsolatePolicy.Capability.PROCESS_INFO, path); + if (path.startsWith("process.share_readonly")) require(policy, IsolatePolicy.Capability.ACTOR_SHARE_READONLY, path); + if (path.equals("process.gc") || path.startsWith("process.gc.")) require(policy, IsolatePolicy.Capability.GC_CONTROL, path); + if (path.equals("SharedMutex") || path.startsWith("SharedMutex.")) require(policy, IsolatePolicy.Capability.SHARED_MEMORY, path); + if (path.startsWith("network.")) require(policy, IsolatePolicy.Capability.NETWORK, path); + if (path.startsWith("fs.read")) require(policy, IsolatePolicy.Capability.FILESYSTEM_READ, path); + if (path.startsWith("fs.write")) require(policy, IsolatePolicy.Capability.FILESYSTEM_WRITE, path); + if (path.startsWith("env.")) require(policy, IsolatePolicy.Capability.ENVIRONMENT, path); + if (path.startsWith("ffi.")) require(policy, IsolatePolicy.Capability.FFI, path); + if (path.startsWith("polyglot.")) require(policy, IsolatePolicy.Capability.POLYGLOT, path); + if (path.startsWith("thread.")) require(policy, IsolatePolicy.Capability.THREAD_CREATE, path); + if (path.startsWith("process.spawn")) require(policy, IsolatePolicy.Capability.CHILD_PROCESS, path); + } + checkExpr(m.receiver(), policy); + } else if (expr instanceof Ast.BinaryExpr e) { checkExpr(e.left(), policy); checkExpr(e.right(), policy); } + else if (expr instanceof Ast.UnaryExpr e) checkExpr(e.operand(), policy); + else if (expr instanceof Ast.AssignExpr e) { + checkExpr(e.target(), policy); + checkExpr(e.value(), policy); + } + else if (expr instanceof Ast.ConditionalExpr e) { checkExpr(e.condition(), policy); checkExpr(e.whenTrue(), policy); checkExpr(e.whenFalse(), policy); } + else if (expr instanceof Ast.IndexExpr e) { checkExpr(e.receiver(), policy); checkExpr(e.index(), policy); } + else if (expr instanceof Ast.NewExpr e) { + checkType(e.type(), policy); + for (Ast.Expr a : e.arguments()) checkExpr(a, policy); + } + else if (expr instanceof Ast.AwaitExpr e) checkExpr(e.expression(), policy); + else if (expr instanceof Ast.ChannelOpExpr e) { + checkExpr(e.channel(), policy); + if (e.value() != null) checkExpr(e.value(), policy); + } + else if (expr instanceof Ast.DynamicSelectExpr e) checkExpr(e.cases(), policy); + else if (expr instanceof Ast.ListExpr e) for (Ast.Expr a : e.elements()) checkExpr(a, policy); + else if (expr instanceof Ast.TupleExpr e) for (Ast.Expr a : e.elements()) checkExpr(a, policy); + else if (expr instanceof Ast.ObjectExpr e) { + for (Ast.ObjectField f : e.fields()) { + if (f.isDynamic()) checkExpr(f.dynamicName(), policy); + checkExpr(f.value(), policy); + } + } + else if (expr instanceof Ast.LambdaExpr e) { + if (e.expressionBody() != null) checkExpr(e.expressionBody(), policy); + if (e.blockBody() != null) checkStatements(e.blockBody(), policy); + } + } + + private static String memberPath(Ast.Expr expr) { + if (expr instanceof Ast.NameExpr n) return n.name(); + if (expr instanceof Ast.MemberExpr m) { + String parent = memberPath(m.receiver()); + return parent == null ? null : parent + "." + m.member(); + } + return null; + } + + private static void require(IsolatePolicy policy, IsolatePolicy.Capability capability, String api) { + if (!policy.allows(capability)) { + throw new SecurityException("Oreslang isolate denies capability " + capability + " required by " + api); + } + } +} diff --git a/src/main/java/dev/oreslang/runtime/ChannelRuntime.java b/src/main/java/dev/oreslang/runtime/ChannelRuntime.java new file mode 100644 index 00000000..bda05a02 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/ChannelRuntime.java @@ -0,0 +1,820 @@ +package dev.oreslang.runtime; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Oreslang-owned channel and atomic select substrate. + * + *

All pending channel operations are represented as select registrations; + * direct read/write is simply a one-case SelectSet. One fair runtime lock owns + * only the short admission/commit critical section. No actor carrier parks on + * this lock waiting for channel readiness: an unready operation returns an + * {@link OresFuture} and the owning Ores continuation suspends or keeps it + * armed.

+ * + *

This single commit coordinator deliberately favors semantic strength over + * premature sharding in the reference runtime. It guarantees that multi-channel + * selection, rendezvous, cancellation, close, and loser preservation share one + * atomic arbitration point. A native backend may shard/lock-free this later as + * long as it preserves the same observable contract.

+ */ +public final class ChannelRuntime { + private ChannelRuntime() { } + + private static final ReentrantLock COORDINATOR = new ReentrantLock(true); + private static final Set ACTIVE = new LinkedHashSet<>(); + private static long nextTicket; + + public enum SelectPolicy { + /** Deterministic rotating fairness on a reusable SelectSet. */ + FAIR, + /** Strict source/list order. May intentionally starve later cases. */ + PRIORITY, + /** Explicit opt-in randomized case order; never the language default. */ + RANDOM + } + + public enum SelectOperation { + READ, + WRITE, + DEFAULT + } + + public record SelectResult( + int index, + SelectOperation operation, + Object value) { + public SelectResult { + if (index < 0) { + throw new IllegalArgumentException( + "select result index must be non-negative"); + } + Objects.requireNonNull(operation, "operation"); + } + } + + public static final class ChannelClosedException extends IllegalStateException { + public ChannelClosedException(String message) { + super(message); + } + } + + /** + * Bounded MPMC channel. Capacity zero is a rendezvous channel. + * + *

Null is deliberately rejected because Oreslang has no standalone null + * value. Use Option or an explicit signal/unit value instead.

+ */ + public static final class Channel implements AutoCloseable { + private final int capacity; + private final java.util.ArrayDeque buffer = new java.util.ArrayDeque<>(); + private final ArrayList registrations = new ArrayList<>(); + private boolean closed; + private Throwable closeCause; + + public Channel(int capacity) { + if (capacity < 0) { + throw new IllegalArgumentException( + "channel capacity cannot be negative"); + } + this.capacity = capacity; + } + + public int capacity() { + return capacity; + } + + public int size() { + COORDINATOR.lock(); + try { + return buffer.size(); + } finally { + COORDINATOR.unlock(); + } + } + + public boolean isEmpty() { + return size() == 0; + } + + public boolean isClosed() { + COORDINATOR.lock(); + try { + return closed; + } finally { + COORDINATOR.unlock(); + } + } + + /** + * Immediate receive probe. No waiter remains registered when this + * method returns empty. + */ + public Optional tryRead() { + Optional result = + SelectSet.of(read(this)).trySelect(SelectPolicy.PRIORITY); + if (result.isEmpty()) return Optional.empty(); + @SuppressWarnings("unchecked") + T value = (T) result.get().value(); + return Optional.of(value); + } + + /** + * Immediate send probe. No waiter remains registered when this returns + * false. + */ + public boolean tryWrite(T value) { + requireValue(value); + return SelectSet.of(write(this, value)) + .trySelect(SelectPolicy.PRIORITY) + .isPresent(); + } + + /** + * Scheduler-friendly receive registration. + */ + public OresFuture readAsync() { + OresFuture selected = + SelectSet.of(read(this)).selectAsync(SelectPolicy.PRIORITY); + return mapSelection(selected, result -> { + @SuppressWarnings("unchecked") + T value = (T) result.value(); + return value; + }); + } + + /** + * Scheduler-friendly send registration. + */ + public OresFuture writeAsync(T value) { + requireValue(value); + OresFuture selected = + SelectSet.of(write(this, value)) + .selectAsync(SelectPolicy.PRIORITY); + return mapSelection(selected, ignored -> null); + } + + private void requireValue(T value) { + Objects.requireNonNull( + value, + "Oreslang channels cannot carry null; use Option"); + } + + private ChannelClosedException closedFailure() { + ChannelClosedException failure = + new ChannelClosedException("channel is closed"); + if (closeCause != null) failure.initCause(closeCause); + return failure; + } + + @Override + public void close() { + close(null); + } + + /** + * Close preserves already-buffered values for readers. Once the buffer + * is drained, reads fail; writes fail immediately. Pending selections + * re-arbitrate atomically across all of their cases. + */ + public void close(Throwable cause) { + ArrayList completions = new ArrayList<>(); + COORDINATOR.lock(); + try { + if (closed) return; + closed = true; + closeCause = cause; + pumpLocked(completions); + } finally { + COORDINATOR.unlock(); + } + runCompletions(completions); + } + } + + public sealed interface SelectCase permits ReadCase, WriteCase, DefaultCase { } + + public record ReadCase(Channel channel) implements SelectCase { + public ReadCase { + Objects.requireNonNull(channel, "channel"); + } + } + + public record WriteCase(Channel channel, T value) + implements SelectCase { + public WriteCase { + Objects.requireNonNull(channel, "channel"); + Objects.requireNonNull( + value, + "Oreslang channels cannot carry null; use Option"); + } + } + + public record DefaultCase() implements SelectCase { } + + public static ReadCase read(Channel channel) { + return new ReadCase<>(channel); + } + + public static WriteCase write(Channel channel, T value) { + return new WriteCase<>(channel, value); + } + + public static DefaultCase defaultCase() { + return new DefaultCase(); + } + + /** + * Reusable select descriptor. Static and dynamic source select lower to + * this same primitive. + */ + public static final class SelectSet { + private final List cases; + private final AtomicLong fairCursor = new AtomicLong(); + + public SelectSet(Collection cases) { + this(cases, 0L); + } + + /** + * Compiler/runtime hook for static select sites that rebuild evaluated + * case values each execution but retain one deterministic fairness + * ticket. + */ + public SelectSet( + Collection cases, + long initialFairCursor) { + Objects.requireNonNull(cases, "cases"); + if (cases.isEmpty()) { + throw new IllegalArgumentException( + "select requires at least one case"); + } + this.cases = List.copyOf(cases); + this.fairCursor.set(initialFairCursor); + long defaults = this.cases.stream() + .filter(DefaultCase.class::isInstance) + .count(); + if (defaults > 1) { + throw new IllegalArgumentException( + "select permits at most one default case"); + } + } + + public static SelectSet of(SelectCase... cases) { + return new SelectSet(List.of(cases)); + } + + public static SelectSet from(Iterable cases) { + ArrayList copy = new ArrayList<>(); + for (SelectCase selectCase : cases) { + copy.add(Objects.requireNonNull(selectCase)); + } + return new SelectSet(copy); + } + + /** + * Map insertion/value iteration order defines the case index/order. + * The language may add keyed SelectResult metadata later without + * changing selection arbitration. + */ + public static SelectSet fromMap(Map cases) { + Objects.requireNonNull(cases, "cases"); + return new SelectSet(cases.values()); + } + + public List cases() { + return cases; + } + + public OresFuture selectAsync() { + return selectAsync(SelectPolicy.FAIR); + } + + public OresFuture selectAsync(SelectPolicy policy) { + SelectRegistration registration = + new SelectRegistration( + this, + Objects.requireNonNull(policy, "policy")); + registration.start(false); + return registration.future; + } + + public Optional trySelect() { + return trySelect(SelectPolicy.FAIR); + } + + public Optional trySelect(SelectPolicy policy) { + SelectRegistration registration = + new SelectRegistration( + this, + Objects.requireNonNull(policy, "policy")); + return registration.tryNow(); + } + + private int[] initialOrder(SelectPolicy policy) { + int count = cases.size(); + int[] order = new int[count]; + + if (policy == SelectPolicy.PRIORITY) { + for (int i = 0; i < count; i++) order[i] = i; + return order; + } + + if (policy == SelectPolicy.FAIR) { + int start = Math.floorMod(fairCursor.get(), count); + for (int i = 0; i < count; i++) { + order[i] = (start + i) % count; + } + return order; + } + + for (int i = 0; i < count; i++) order[i] = i; + for (int i = count - 1; i > 0; i--) { + int j = ThreadLocalRandom.current().nextInt(i + 1); + int tmp = order[i]; + order[i] = order[j]; + order[j] = tmp; + } + return order; + } + + private void selected(int index, SelectPolicy policy) { + if (policy == SelectPolicy.FAIR && !cases.isEmpty()) { + fairCursor.set((index + 1L) % cases.size()); + } + } + } + + private static final class CaseRegistration { + private final SelectRegistration selection; + private final int index; + private final SelectCase selectCase; + private final long ticket; + + private CaseRegistration( + SelectRegistration selection, + int index, + SelectCase selectCase) { + this.selection = selection; + this.index = index; + this.selectCase = selectCase; + this.ticket = nextTicket++; + } + + private Channel channel() { + if (selectCase instanceof ReadCase read) return read.channel(); + if (selectCase instanceof WriteCase write) return write.channel(); + throw new IllegalStateException( + "default case is never channel-registered"); + } + + private SelectOperation operation() { + if (selectCase instanceof ReadCase) return SelectOperation.READ; + if (selectCase instanceof WriteCase) return SelectOperation.WRITE; + return SelectOperation.DEFAULT; + } + + private Object writeValue() { + return ((WriteCase) selectCase).value(); + } + } + + private static final class SelectRegistration { + private final SelectSet set; + private final SelectPolicy policy; + private final int[] order; + private final ArrayList registrations = + new ArrayList<>(); + private final int defaultIndex; + private final OresFuture future; + private boolean decided; + private boolean registered; + + private SelectRegistration(SelectSet set, SelectPolicy policy) { + this.set = set; + this.policy = policy; + this.order = set.initialOrder(policy); + + int foundDefault = -1; + for (int i = 0; i < set.cases.size(); i++) { + if (set.cases.get(i) instanceof DefaultCase) { + foundDefault = i; + break; + } + } + this.defaultIndex = foundDefault; + this.future = new OresFuture<>( + this::cancelAdmission, + () -> { }); + } + + private boolean cancelAdmission() { + COORDINATOR.lock(); + try { + if (decided) return false; + decided = true; + unregisterLocked(this); + return true; + } finally { + COORDINATOR.unlock(); + } + } + + private void start(boolean immediateOnly) { + ArrayList completions = new ArrayList<>(); + COORDINATOR.lock(); + try { + registerLocked(this); + pumpLocked(completions); + + if (!decided && defaultIndex >= 0) { + commitSingleLocked( + caseOrDefault(defaultIndex), + new SelectResult( + defaultIndex, + SelectOperation.DEFAULT, + null), + completions); + } + + if (!decided && immediateOnly) { + decided = true; + unregisterLocked(this); + } + } finally { + COORDINATOR.unlock(); + } + runCompletions(completions); + } + + private Optional tryNow() { + start(true); + if (!future.isDone()) return Optional.empty(); + return Optional.of(future.join()); + } + + private CaseRegistration caseOrDefault(int index) { + for (CaseRegistration registration : registrations) { + if (registration.index == index) return registration; + } + return new CaseRegistration(this, index, set.cases.get(index)); + } + } + + private static void registerLocked(SelectRegistration selection) { + if (selection.decided || selection.registered) return; + selection.registered = true; + ACTIVE.add(selection); + + for (int index : selection.order) { + SelectCase selectCase = selection.set.cases.get(index); + if (selectCase instanceof DefaultCase) continue; + + CaseRegistration registration = + new CaseRegistration(selection, index, selectCase); + selection.registrations.add(registration); + channelOf(selectCase).registrations.add(registration); + } + } + + private static void unregisterLocked(SelectRegistration selection) { + if (!selection.registered) return; + selection.registered = false; + ACTIVE.remove(selection); + for (CaseRegistration registration : + List.copyOf(selection.registrations)) { + registration.channel().registrations.remove(registration); + } + selection.registrations.clear(); + } + + /** + * Drive all registrations until no further atomic commit is possible. + * Completion callbacks are queued and run only after COORDINATOR is + * released, preventing channel-lock -> actor-mailbox lock inversion. + */ + private static void pumpLocked(List completions) { + boolean progressed; + do { + progressed = false; + + for (SelectRegistration selection : List.copyOf(ACTIVE)) { + if (selection.decided) continue; + + CaseRegistration chosen = + preferredCommittableCaseLocked(selection); + if (chosen == null) continue; + + Channel channel = chosen.channel(); + + if (chosen.operation() == SelectOperation.READ) { + if (!channel.buffer.isEmpty()) { + Object value = channel.buffer.removeFirst(); + commitSingleLocked( + chosen, + new SelectResult( + chosen.index, + SelectOperation.READ, + value), + completions); + progressed = true; + break; + } + + if (channel.closed) { + commitFailureLocked( + chosen, + channel.closedFailure(), + completions); + progressed = true; + break; + } + + CaseRegistration writer = + findMutualPeerLocked( + chosen, + SelectOperation.WRITE); + if (writer != null) { + commitPairLocked(chosen, writer, completions); + progressed = true; + break; + } + } else { + if (channel.closed) { + commitFailureLocked( + chosen, + channel.closedFailure(), + completions); + progressed = true; + break; + } + + CaseRegistration reader = + findMutualPeerLocked( + chosen, + SelectOperation.READ); + if (reader != null) { + commitPairLocked(reader, chosen, completions); + progressed = true; + break; + } + + if (channel.capacity > channel.buffer.size()) { + @SuppressWarnings("unchecked") + Channel writable = + (Channel) channel; + writable.buffer.addLast(chosen.writeValue()); + commitSingleLocked( + chosen, + new SelectResult( + chosen.index, + SelectOperation.WRITE, + null), + completions); + progressed = true; + break; + } + } + } + } while (progressed); + } + + private static CaseRegistration preferredCommittableCaseLocked( + SelectRegistration selection) { + if (selection.decided) return null; + + for (int index : selection.order) { + SelectCase selectCase = selection.set.cases.get(index); + if (selectCase instanceof DefaultCase) continue; + + CaseRegistration registration = + findRegistration(selection, index); + if (registration == null) continue; + + Channel channel = registration.channel(); + if (registration.operation() == SelectOperation.READ) { + if (!channel.buffer.isEmpty() || channel.closed) { + return registration; + } + if (findMutualPeerLocked( + registration, + SelectOperation.WRITE) != null) { + return registration; + } + } else { + if (channel.closed + || channel.capacity > channel.buffer.size()) { + return registration; + } + if (findMutualPeerLocked( + registration, + SelectOperation.READ) != null) { + return registration; + } + } + } + return null; + } + + private static CaseRegistration preferredReadyCaseLocked( + SelectRegistration selection) { + if (selection.decided) return null; + + for (int index : selection.order) { + SelectCase selectCase = selection.set.cases.get(index); + if (selectCase instanceof DefaultCase) continue; + + CaseRegistration registration = + findRegistration(selection, index); + if (registration != null && basicReadyLocked(registration)) { + return registration; + } + } + return null; + } + + private static boolean basicReadyLocked(CaseRegistration registration) { + Channel channel = registration.channel(); + + if (registration.operation() == SelectOperation.READ) { + if (!channel.buffer.isEmpty()) return true; + if (channel.closed) return true; + return hasOppositePeerLocked( + registration, + SelectOperation.WRITE); + } + + if (channel.closed) return true; + if (hasOppositePeerLocked(registration, SelectOperation.READ)) { + return true; + } + return channel.capacity > channel.buffer.size(); + } + + private static boolean hasOppositePeerLocked( + CaseRegistration registration, + SelectOperation operation) { + for (CaseRegistration candidate : + registration.channel().registrations) { + if (candidate.selection == registration.selection + || candidate.selection.decided + || candidate.operation() != operation) { + continue; + } + return true; + } + return false; + } + + /** + * A rendezvous commits only when each selection currently prefers the + * matching case under its own FAIR/PRIORITY/RANDOM order. This prevents a + * peer's lower-priority arm from being stolen merely because it is present + * on the same rendezvous channel. + */ + private static CaseRegistration findMutualPeerLocked( + CaseRegistration registration, + SelectOperation opposite) { + CaseRegistration best = null; + + for (CaseRegistration candidate : + registration.channel().registrations) { + if (candidate.selection == registration.selection + || candidate.selection.decided + || candidate.operation() != opposite) { + continue; + } + + CaseRegistration peerPreferred = + preferredReadyCaseLocked(candidate.selection); + if (peerPreferred != candidate) continue; + + if (best == null || candidate.ticket < best.ticket) { + best = candidate; + } + } + return best; + } + + private static CaseRegistration findRegistration( + SelectRegistration selection, + int index) { + for (CaseRegistration registration : selection.registrations) { + if (registration.index == index) return registration; + } + return null; + } + + private static void commitPairLocked( + CaseRegistration reader, + CaseRegistration writer, + List completions) { + if (reader.selection == writer.selection + || reader.selection.decided + || writer.selection.decided) { + return; + } + + Object value = writer.writeValue(); + + reader.selection.decided = true; + writer.selection.decided = true; + reader.selection.set.selected(reader.index, reader.selection.policy); + writer.selection.set.selected(writer.index, writer.selection.policy); + unregisterLocked(reader.selection); + unregisterLocked(writer.selection); + + SelectResult readResult = new SelectResult( + reader.index, + SelectOperation.READ, + value); + SelectResult writeResult = new SelectResult( + writer.index, + SelectOperation.WRITE, + null); + + completions.add(() -> + reader.selection.future.completeFromRuntime(readResult)); + completions.add(() -> + writer.selection.future.completeFromRuntime(writeResult)); + } + + private static void commitSingleLocked( + CaseRegistration registration, + SelectResult result, + List completions) { + SelectRegistration selection = registration.selection; + if (selection.decided) return; + + selection.decided = true; + selection.set.selected(result.index(), selection.policy); + unregisterLocked(selection); + completions.add(() -> + selection.future.completeFromRuntime(result)); + } + + private static void commitFailureLocked( + CaseRegistration registration, + Throwable failure, + List completions) { + SelectRegistration selection = registration.selection; + if (selection.decided) return; + + // A terminally ready case still wins selection. FAIR reuse must rotate + // past it exactly as it does after a successful read/write; otherwise a + // permanently closed arm can monopolize a reusable SelectSet forever. + selection.decided = true; + selection.set.selected(registration.index, selection.policy); + unregisterLocked(selection); + completions.add(() -> + selection.future.failFromRuntime(failure)); + } + + private static Channel channelOf(SelectCase selectCase) { + if (selectCase instanceof ReadCase read) return read.channel(); + if (selectCase instanceof WriteCase write) return write.channel(); + throw new IllegalArgumentException( + "default select case has no channel"); + } + + private static OresFuture mapSelection( + OresFuture source, + Function mapper) { + OresFuture result = new OresFuture<>( + () -> source.cancel(false), + () -> { }); + + source.whenCompleteRuntime((selected, failure) -> { + if (failure == null) { + try { + result.completeFromRuntime(mapper.apply(selected)); + } catch (Throwable mappingFailure) { + result.failFromRuntime(mappingFailure); + } + } else if (source.isCancelled()) { + // source is private to this mapping. Its cancellation was + // initiated by result.cancel(); allow the outer cancellation + // call to perform the authoritative Cancelled settlement. + return; + } else if (!result.isDone()) { + result.failFromRuntime(failure); + } + }); + return result; + } + + private static void runCompletions(List completions) { + for (Runnable completion : completions) completion.run(); + } +} diff --git a/src/main/java/dev/oreslang/runtime/ExecutionProfile.java b/src/main/java/dev/oreslang/runtime/ExecutionProfile.java new file mode 100644 index 00000000..cfd11c6b --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/ExecutionProfile.java @@ -0,0 +1,38 @@ +package dev.oreslang.runtime; + +import java.util.Locale; + +/** + * Declares how the Oreslang runtime itself is deployed and whether guest code + * may be optimized by a JIT. This is deliberately separate from source + * semantics so the same .ores program can run on server, desktop, Android or + * iOS profiles. + */ +public record ExecutionProfile(Mode mode, Platform platform) { + public enum Mode { AOT, JIT, HYBRID } + public enum Platform { SERVER, WINDOWS, MACOS, LINUX, ANDROID, IOS } + + public ExecutionProfile { + if (mode == null || platform == null) throw new IllegalArgumentException("mode/platform are required"); + if (platform == Platform.IOS && mode != Mode.AOT) { + throw new IllegalArgumentException("iOS profile is AOT-only; hot reload uses interpreted guest source/IR, not executable-code JIT"); + } + } + + public static ExecutionProfile serverJit() { return new ExecutionProfile(Mode.JIT, Platform.SERVER); } + public static ExecutionProfile serverHybrid() { return new ExecutionProfile(Mode.HYBRID, Platform.SERVER); } + public static ExecutionProfile mobileAot(Platform platform) { + if (platform != Platform.ANDROID && platform != Platform.IOS) throw new IllegalArgumentException("mobile profile requires ANDROID or IOS"); + return new ExecutionProfile(Mode.AOT, platform); + } + + public boolean hostAheadOfTime() { return mode == Mode.AOT || mode == Mode.HYBRID; } + public boolean guestJitAllowed() { return mode == Mode.JIT || mode == Mode.HYBRID; } + public boolean supportsSourceHotReload() { return true; } + + public static ExecutionProfile parse(String mode, String platform) { + return new ExecutionProfile( + Mode.valueOf(mode.toUpperCase(Locale.ROOT)), + Platform.valueOf(platform.toUpperCase(Locale.ROOT))); + } +} diff --git a/src/main/java/dev/oreslang/runtime/HotReloadManager.java b/src/main/java/dev/oreslang/runtime/HotReloadManager.java new file mode 100644 index 00000000..5a6747aa --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/HotReloadManager.java @@ -0,0 +1,172 @@ +package dev.oreslang.runtime; + +import dev.oreslang.OresLanguage; +import dev.oreslang.compiler.OresCompiler; +import dev.oreslang.compiler.IncrementalCompiler; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Versioned source hot loader. + * + * Loading is side-effect free with respect to guest execution: source is + * parsed/type/capability checked, then assigned a fresh context. The trusted + * supervisor explicitly starts the generation after activation. + * + * No JNI/FFI or OS dynamic-library loading is required. + */ +public final class HotReloadManager implements AutoCloseable { + private final IsolatePolicy policy; + private final ExecutionProfile executionProfile; + private final AtomicLong sequence = new AtomicLong(); + private final AtomicReference active = new AtomicReference<>(); + private final Map activeByCodeUnit = new LinkedHashMap<>(); + private final Map generations = new LinkedHashMap<>(); + + public HotReloadManager(IsolatePolicy policy, ExecutionProfile executionProfile) { + this.policy = policy; + this.executionProfile = executionProfile; + if (!policy.allows(IsolatePolicy.Capability.HOT_CODE_LOAD)) { + throw new SecurityException("HOT_CODE_LOAD capability is required"); + } + } + + /** + * Validates and stages a new generation without executing its entrypoint. + */ + public synchronized Generation load(String name, String sourceText) { + OresCompiler.validateForIsolate(sourceText, policy); + return stage(name, digest(sourceText), sourceText); + } + + /** + * Reuses an incrementally compiled unit. Static compilation work is reused, + * while capability admission is deliberately repeated for the destination + * isolate because authority belongs to the runtime policy, not the cache. + */ + public synchronized Generation load(IncrementalCompiler.CompiledUnit unit) { + CapabilityChecker.check(unit.program(), policy); + return stage(unit.unitId(), unit.sourceDigest(), unit.sourceText()); + } + + private Generation stage(String codeUnitId, String sourceDigest, String sourceText) { + long id = sequence.incrementAndGet(); + Context context = policy.restrictedContextBuilder(executionProfile).build(); + try { + Source source = Source.newBuilder(OresLanguage.ID, sourceText, codeUnitId) + .mimeType(OresLanguage.MIME_TYPE) + .buildLiteral(); + Generation generation = new Generation(id, codeUnitId, sourceDigest, context, source, executionProfile); + generations.put(id, generation); + activeByCodeUnit.put(codeUnitId, generation); + active.set(generation); + return generation; + } catch (RuntimeException failure) { + context.close(true); + throw failure; + } + } + + public synchronized Generation loadAndStart(String name, String sourceText) { + Generation generation = load(name, sourceText); + generation.start(); + return generation; + } + + /** Last generation staged, retained for compatibility with the single-unit API. */ + public Generation active() { return active.get(); } + + /** Active generation for one independently compiled code unit. */ + public synchronized Generation active(String codeUnitId) { + return activeByCodeUnit.get(codeUnitId); + } + + public synchronized Map activeGenerations() { + return Map.copyOf(activeByCodeUnit); + } + + /** Explicit retirement permits old actors/requests to drain before teardown. */ + public synchronized void retire(long generationId) { + Generation generation = generations.remove(generationId); + if (generation != null) { + active.compareAndSet(generation, null); + activeByCodeUnit.remove(generation.codeUnitId(), generation); + generation.close(); + } + } + + public synchronized int liveGenerations() { return generations.size(); } + + @Override + public synchronized void close() { + for (Generation generation : generations.values()) generation.close(); + generations.clear(); + activeByCodeUnit.clear(); + active.set(null); + } + + private static String digest(String text) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (Exception impossible) { + throw new IllegalStateException(impossible); + } + } + + public static final class Generation implements AutoCloseable { + private final long id; + private final String codeUnitId; + private final String sha256; + private final Context context; + private final Source source; + private final ExecutionProfile executionProfile; + private final AtomicBoolean started = new AtomicBoolean(); + private final AtomicBoolean closed = new AtomicBoolean(); + + private Generation(long id, String codeUnitId, String sha256, Context context, Source source, ExecutionProfile executionProfile) { + this.id = id; + this.codeUnitId = codeUnitId; + this.sha256 = sha256; + this.context = context; + this.source = source; + this.executionProfile = executionProfile; + } + + public long id() { return id; } + public String codeUnitId() { return codeUnitId; } + public String sha256() { return sha256; } + public Context context() { return context; } + public Source source() { return source; } + public ExecutionProfile executionProfile() { return executionProfile; } + public boolean started() { return started.get(); } + public boolean closed() { return closed.get(); } + + /** Starts the staged generation exactly once. */ + public Value start() { + if (closed.get()) throw new IllegalStateException("generation is closed"); + if (!started.compareAndSet(false, true)) throw new IllegalStateException("generation already started"); + try { + return context.eval(source); + } catch (RuntimeException failure) { + close(); + throw failure; + } + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) context.close(true); + } + } +} diff --git a/src/main/java/dev/oreslang/runtime/HungryActor.java b/src/main/java/dev/oreslang/runtime/HungryActor.java new file mode 100644 index 00000000..4c4ddab0 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/HungryActor.java @@ -0,0 +1,286 @@ +package dev.oreslang.runtime; + +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Explicit CPU-bound actor that owns one dedicated native pthread carrier for + * its entire lifetime. + * + *

This is intentionally different from ordinary Oreslang actors, which are + * multiplexed over bounded native dispatcher pools. A HungryActor is an opt-in + * escape hatch for sustained CPU work, thread-affine native runtimes, or + * workloads whose progress contract requires reserving one OS carrier until + * the actor releases it or terminates. It must therefore be used sparingly.

+ * + *

The mailbox remains bounded and messages cross the boundary through + * {@link ActorRuntime#freeze(Object)}. Execution is serial. Stop/release is + * cooperative for CPU loops: code should call {@link Context#schedulerSafepoint()} + * at bounded intervals so cancellation can be observed promptly.

+ */ +public final class HungryActor implements AutoCloseable { + private static final int DEFAULT_MAILBOX_CAPACITY = 1024; + private static final long CLOSE_WAIT_MILLIS = 2_000L; + + @FunctionalInterface + public interface Behavior { + void onMessage(M message, Context context) throws Exception; + } + + public interface Context { + HungryActor self(); + UUID id(); + boolean stopRequested(); + void release(); + void schedulerSafepoint(); + } + + private final UUID id = UUID.randomUUID(); + private final ArrayBlockingQueue mailbox; + private final Behavior behavior; + private final AtomicBoolean stopRequested = new AtomicBoolean(); + private final AtomicBoolean terminated = new AtomicBoolean(); + private final AtomicReference failure = new AtomicReference<>(); + private final CountDownLatch termination = new CountDownLatch(1); + private final Object lifecycleLock = new Object(); + private final NativeCarrierExecutor carrier; + private final AtomicReference worker = new AtomicReference<>(); + private final AtomicReference nativeThreadId = new AtomicReference<>(0L); + private final String carrierThreadName; + private final Context context = new Context<>() { + @Override + public HungryActor self() { + return HungryActor.this; + } + + @Override + public UUID id() { + return id; + } + + @Override + public boolean stopRequested() { + return stopRequested.get(); + } + + @Override + public void release() { + HungryActor.this.release(); + } + + @Override + public void schedulerSafepoint() { + HungryActor.this.schedulerSafepoint(); + } + }; + + public HungryActor(Behavior behavior) { + this("worker", DEFAULT_MAILBOX_CAPACITY, behavior); + } + + public HungryActor(String name, int mailboxCapacity, Behavior behavior) { + if (mailboxCapacity <= 0) { + throw new IllegalArgumentException("mailboxCapacity must be > 0"); + } + this.mailbox = new ArrayBlockingQueue<>(mailboxCapacity); + this.behavior = Objects.requireNonNull(behavior, "behavior"); + + String safeName = sanitizeName(name); + String prefix = "ores-hungry-actor-" + safeName + "-" + id + "-"; + this.carrierThreadName = prefix + "1"; + this.carrier = new NativeCarrierExecutor(1, 1, 1, prefix); + try { + this.carrier.execute(this::runLoop); + } catch (RuntimeException | Error failure) { + this.carrier.shutdownNow(); + throw failure; + } + } + + public UUID id() { + return id; + } + + public String threadName() { + Thread active = worker.get(); + return active == null ? carrierThreadName : active.getName(); + } + + public long nativeThreadId() { + return nativeThreadId.get(); + } + + public boolean isNativeCarrier() { + return nativeThreadId.get() != 0L; + } + + public boolean isVirtualCarrier() { + Thread active = worker.get(); + return active != null && active.isVirtual(); + } + + public boolean isAlive() { + return !terminated.get() && !carrier.isTerminated(); + } + + public boolean stopRequested() { + return stopRequested.get(); + } + + public Optional failure() { + return Optional.ofNullable(failure.get()); + } + + /** + * Enqueue one frozen message without ever blocking the caller. + */ + public void send(M message) { + Object frozen = ActorRuntime.freeze(message); + synchronized (lifecycleLock) { + if (stopRequested.get() || terminated.get()) { + throw new IllegalStateException("HungryActor " + id + " is terminated"); + } + if (!mailbox.offer(frozen)) { + throw new IllegalStateException( + "HungryActor mailbox limit exceeded for " + id); + } + } + } + + /** + * Relinquish the dedicated pthread once the current callback unwinds. + */ + public void release() { + requestStop(); + } + + public void stop() { + requestStop(); + } + + private void requestStop() { + synchronized (lifecycleLock) { + if (!stopRequested.compareAndSet(false, true)) return; + } + + if (carrier.isCurrentCarrierThread()) { + // Do not inject an interrupt into the currently executing actor + // callback. The run loop observes stopRequested after it unwinds. + carrier.requestShutdownFromCarrier(); + } else { + // Wakes a carrier blocked in mailbox.take() without pthread_cancel. + carrier.shutdownNow(); + } + } + + /** + * Cooperative cancellation point for long CPU-bound loops. + */ + public void schedulerSafepoint() { + if (!carrier.isCurrentCarrierThread()) { + throw new IllegalStateException( + "HungryActor schedulerSafepoint must run on its dedicated native carrier"); + } + if (stopRequested.get() || Thread.currentThread().isInterrupted()) { + throw new CancellationException("HungryActor " + id + " is stopping"); + } + Thread.onSpinWait(); + } + + public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + Objects.requireNonNull(unit, "unit"); + if (timeout < 0) throw new IllegalArgumentException("timeout must be non-negative"); + return termination.await(timeout, unit); + } + + @SuppressWarnings("unchecked") + private void runLoop() { + worker.set(Thread.currentThread()); + nativeThreadId.set(NativeCarrierExecutor.currentNativeThreadId()); + if (!carrier.isCurrentCarrierThread() || nativeThreadId.get() == 0L) { + failure.compareAndSet( + null, + new IllegalStateException( + "HungryActor must execute on a JNI pthread carrier")); + stopRequested.set(true); + } + + try { + while (!stopRequested.get()) { + final Object raw; + try { + raw = mailbox.take(); + } catch (InterruptedException interrupted) { + if (stopRequested.get()) break; + Thread.currentThread().interrupt(); + throw new CancellationException( + "HungryActor " + id + " native carrier interrupted"); + } + + try { + behavior.onMessage((M) raw, context); + } catch (CancellationException cancelled) { + if (!stopRequested.get()) failure.compareAndSet(null, cancelled); + requestStop(); + } catch (VirtualMachineError | ThreadDeath fatal) { + failure.compareAndSet(null, fatal); + requestStop(); + throw fatal; + } catch (Throwable thrown) { + failure.compareAndSet(null, thrown); + requestStop(); + } + } + } finally { + synchronized (lifecycleLock) { + stopRequested.set(true); + mailbox.clear(); + terminated.set(true); + } + if (!carrier.isShutdown() && carrier.isCurrentCarrierThread()) { + carrier.requestShutdownFromCarrier(); + } + termination.countDown(); + } + } + + @Override + public void close() { + release(); + boolean interrupted = false; + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(CLOSE_WAIT_MILLIS); + try { + if (!termination.await(CLOSE_WAIT_MILLIS, TimeUnit.MILLISECONDS)) { + throw new IllegalStateException( + "HungryActor " + id + " did not release its dedicated native carrier"); + } + long remaining = deadline - System.nanoTime(); + if (remaining > 0 + && !carrier.awaitTermination(remaining, TimeUnit.NANOSECONDS)) { + throw new IllegalStateException( + "HungryActor " + id + " native carrier did not terminate"); + } + } catch (InterruptedException waitInterrupted) { + interrupted = true; + throw new IllegalStateException( + "interrupted while waiting for HungryActor " + id + " to terminate", + waitInterrupted); + } finally { + if (interrupted) Thread.currentThread().interrupt(); + } + } + + private static String sanitizeName(String name) { + String raw = Objects.requireNonNullElse(name, "worker").trim(); + if (raw.isEmpty()) raw = "worker"; + String safe = raw.replaceAll("[^A-Za-z0-9._-]", "-"); + return safe.length() <= 48 ? safe : safe.substring(0, 48); + } +} diff --git a/src/main/java/dev/oreslang/runtime/IsolatePolicy.java b/src/main/java/dev/oreslang/runtime/IsolatePolicy.java new file mode 100644 index 00000000..476fd970 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/IsolatePolicy.java @@ -0,0 +1,333 @@ +package dev.oreslang.runtime; + +import dev.oreslang.OresLanguage; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.EnvironmentAccess; +import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.PolyglotAccess; +import org.graalvm.polyglot.SandboxPolicy; +import org.graalvm.polyglot.io.IOAccess; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.time.Duration; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** Deny-by-default security policy for an Oreslang isolate/context. */ +public record IsolatePolicy( + Set capabilities, + long maxHeapBytes, + int maxMailboxMessages, + Duration maxWallTime, + boolean adversarial) { + + public enum Capability { + STDIN, + STDOUT, + PROCESS_INFO, + GC_CONTROL, + ACTOR_SHARE_READONLY, + SHARED_MEMORY, + NETWORK, + FILESYSTEM_READ, + FILESYSTEM_WRITE, + ENVIRONMENT, + HOT_CODE_LOAD, + JAVA_INTEROP, + JAVA_SOURCE_INTEROP, + FFI, + NATIVE, + REFLECTION, + CHILD_PROCESS, + THREAD_CREATE, + POLYGLOT + } + + public IsolatePolicy(Set capabilities, long maxHeapBytes, int maxMailboxMessages, Duration maxWallTime) { + this(capabilities, maxHeapBytes, maxMailboxMessages, maxWallTime, false); + } + + public IsolatePolicy { + capabilities = Set.copyOf(capabilities); + if (maxHeapBytes < 16L * 1024 * 1024) throw new IllegalArgumentException("maxHeapBytes must be at least 16 MiB"); + if (maxMailboxMessages <= 0) throw new IllegalArgumentException("maxMailboxMessages must be positive"); + if (maxWallTime.isNegative() || maxWallTime.isZero()) throw new IllegalArgumentException("maxWallTime must be positive"); + if (adversarial && capabilities.contains(Capability.THREAD_CREATE)) { + throw new IllegalArgumentException("adversarial isolates cannot grant THREAD_CREATE"); + } + if (adversarial && capabilities.contains(Capability.JAVA_INTEROP)) { + throw new IllegalArgumentException("adversarial isolates cannot grant JAVA_INTEROP"); + } + if (adversarial && capabilities.contains(Capability.JAVA_SOURCE_INTEROP)) { + throw new IllegalArgumentException("adversarial isolates cannot grant JAVA_SOURCE_INTEROP"); + } + } + + /** + * Production FaaS baseline: malicious-source threat model, stdout only, + * one guest thread, hard VM isolate and guest resource limits. + */ + public static IsolatePolicy strictFaas() { + return new IsolatePolicy(Set.of(Capability.STDOUT), 128L * 1024 * 1024, 1024, Duration.ofSeconds(30), true); + } + + /** Restricted local/test baseline. Java interop/FFI/native/reflection/process spawning remain denied. */ + public static IsolatePolicy developer() { + return new IsolatePolicy( + Set.of(Capability.STDIN, Capability.STDOUT, Capability.PROCESS_INFO, Capability.GC_CONTROL, + Capability.ACTOR_SHARE_READONLY, Capability.SHARED_MEMORY, Capability.HOT_CODE_LOAD), + 512L * 1024 * 1024, 8192, Duration.ofMinutes(10), false); + } + + public IsolatePolicy withCapabilities(Capability... added) { + EnumSet next = capabilities.isEmpty() + ? EnumSet.noneOf(Capability.class) + : EnumSet.copyOf(capabilities); + next.addAll(Arrays.asList(added)); + return new IsolatePolicy(next, maxHeapBytes, maxMailboxMessages, maxWallTime, adversarial); + } + + public IsolatePolicy withoutCapabilities(Capability... removed) { + EnumSet next = capabilities.isEmpty() + ? EnumSet.noneOf(Capability.class) + : EnumSet.copyOf(capabilities); + next.removeAll(Arrays.asList(removed)); + return new IsolatePolicy(next, maxHeapBytes, maxMailboxMessages, maxWallTime, adversarial); + } + + public IsolatePolicy asAdversarial() { + return adversarial ? this : new IsolatePolicy(capabilities, maxHeapBytes, maxMailboxMessages, maxWallTime, true); + } + + /** + * Graal baseline: no host reflection, native access, polyglot calls, + * environment access, guest-created threads, or host filesystem/network IO. + * + * For adversarial policies, Graal's UNTRUSTED sandbox is selected. It + * spawns a VM-level polyglot isolate when the language-isolate artifact is + * available and enforces guest/isolate resource limits. + */ + public Context.Builder restrictedContextBuilder() { + return restrictedContextBuilder(ExecutionProfile.serverJit(), Set.of()); + } + + public Context.Builder restrictedContextBuilder(ExecutionProfile profile) { + return restrictedContextBuilder(profile, Set.of()); + } + + /** + * Builds a deny-by-default Graal context. Java host classes require two + * independent grants: JAVA_INTEROP and this exact fully-qualified allowlist. + */ + public Context.Builder restrictedContextBuilder( + ExecutionProfile profile, + Set allowedHostClasses) { + Set hostClasses = Set.copyOf(allowedHostClasses); + if (!hostClasses.isEmpty()) { + require(Capability.JAVA_INTEROP, "Java host imports"); + if (adversarial) { + throw new SecurityException("Java host imports are disabled for adversarial isolates"); + } + for (String className : hostClasses) validateHostClassAuthority(className); + } + + HostAccess hostAccess; + if (!hostClasses.isEmpty()) { + hostAccess = explicitHostAccess(hostClasses); + } else { + hostAccess = adversarial + ? HostAccess.newBuilder(HostAccess.NONE).allowMutableTargetMappings().methodScoping(true).build() + : HostAccess.NONE; + } + + Context.Builder builder = Context.newBuilder(OresLanguage.ID) + .allowHostAccess(hostAccess) + .allowHostClassLookup(hostClasses.isEmpty() ? ignored -> false : hostClasses::contains) + .allowHostClassLoading(false) + .allowPolyglotAccess(PolyglotAccess.NONE) + .allowEnvironmentAccess(EnvironmentAccess.NONE) + .allowNativeAccess(false) + .allowCreateThread(false) + .allowIO(IOAccess.NONE) + .in(new ByteArrayInputStream(new byte[0])) + .out(new ByteArrayOutputStream()) + .err(new ByteArrayOutputStream()) + .arguments(OresLanguage.ID, applicationArguments(profile)); + + /* + * Graal's engine.IsolateLibrary option is experimental in 25.x. Opt in + * only when the embedding process explicitly supplies a polyglot + * isolate library; ordinary strict/adversarial contexts remain on the + * non-experimental builder path. + */ + if (System.getProperty("polyglot.engine.IsolateLibrary") != null) { + builder.allowExperimentalOptions(true); + } + + if (adversarial) { + long guestHeap = Math.max(8L * 1024 * 1024, maxHeapBytes * 3 / 4); + long maxOutput = allows(Capability.STDOUT) ? 1024L * 1024 : 0L; + builder.sandbox(SandboxPolicy.UNTRUSTED) + .spawnIsolate(true) + .option("engine.MaxIsolateMemory", bytes(maxHeapBytes)) + .option("sandbox.MaxHeapMemory", bytes(guestHeap)) + .option("sandbox.MaxCPUTime", duration(maxWallTime)) + .option("sandbox.MaxASTDepth", "256") + .option("sandbox.MaxThreads", "1") + .option("sandbox.MaxOutputStreamSize", bytes(maxOutput)) + .option("sandbox.MaxErrorStreamSize", "64KB"); + } + + return builder; + } + + private void validateHostClassAuthority(String className) { + if (className == null || className.isBlank()) { + throw new IllegalArgumentException("allowlisted Java host class name cannot be blank"); + } + + if (className.equals("java.lang.Class") + || className.equals("java.lang.ClassLoader") + || className.equals("java.lang.Module") + || className.equals("java.lang.Runtime") + || className.equals("java.lang.System") + || className.equals("java.lang.Process") + || className.equals("java.lang.ProcessBuilder") + || className.equals("java.lang.ProcessHandle") + || className.equals("java.lang.Thread") + || className.equals("java.lang.ThreadGroup") + || className.equals("java.lang.SecurityManager") + || className.equals("java.util.ServiceLoader") + || className.startsWith("java.lang.reflect.") + || className.startsWith("java.lang.invoke.") + || className.startsWith("java.beans.") + || className.startsWith("javax.script.") + || className.startsWith("javax.tools.") + || className.startsWith("jdk.") + || className.startsWith("sun.") + || className.startsWith("com.sun.")) { + throw new SecurityException("Java host class is blocked from class-level interop: " + className); + } + + if (className.startsWith("java.io.") || className.startsWith("java.nio.file.")) { + require(Capability.FILESYSTEM_READ, "Java host class " + className); + require(Capability.FILESYSTEM_WRITE, "Java host class " + className); + } + if (className.startsWith("java.net.")) { + require(Capability.NETWORK, "Java host class " + className); + } + if (className.startsWith("java.nio.channels.")) { + require(Capability.NETWORK, "Java host class " + className); + require(Capability.FILESYSTEM_READ, "Java host class " + className); + require(Capability.FILESYSTEM_WRITE, "Java host class " + className); + } + if (className.startsWith("java.util.concurrent.")) { + require(Capability.THREAD_CREATE, "Java host class " + className); + } + if (className.startsWith("java.lang.foreign.")) { + require(Capability.NATIVE, "Java host class " + className); + } + if (className.startsWith("java.lang.management.")) { + require(Capability.PROCESS_INFO, "Java host class " + className); + } + } + + private static HostAccess explicitHostAccess(Set hostClasses) { + HostAccess.Builder access = HostAccess.newBuilder(HostAccess.NONE) + .allowAccessInheritance(false); + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + + for (String className : hostClasses) { + final Class type; + try { + type = Class.forName(className, false, loader); + } catch (ClassNotFoundException failure) { + throw new IllegalArgumentException("allowlisted Java host class is unavailable: " + className, failure); + } + if (!Modifier.isPublic(type.getModifiers())) { + throw new IllegalArgumentException("allowlisted Java host class must be public: " + className); + } + + for (Constructor constructor : type.getDeclaredConstructors()) { + if (Modifier.isPublic(constructor.getModifiers())) access.allowAccess(constructor); + } + for (Method method : type.getDeclaredMethods()) { + if (Modifier.isPublic(method.getModifiers())) access.allowAccess(method); + } + for (Field field : type.getDeclaredFields()) { + if (Modifier.isPublic(field.getModifiers())) access.allowAccess(field); + } + } + return access.build(); + } + + public boolean allows(Capability capability) { + return capabilities.contains(capability); + } + + public void require(Capability capability, String api) { + if (!allows(capability)) { + throw new SecurityException("Oreslang isolate denies capability " + capability + " required by " + api); + } + } + + public String[] applicationArguments(ExecutionProfile profile) { + String caps = capabilities.stream().map(Enum::name).sorted().collect(Collectors.joining(",")); + return new String[] { + "--ores-capabilities=" + caps, + "--ores-max-heap-bytes=" + maxHeapBytes, + "--ores-max-mailbox-messages=" + maxMailboxMessages, + "--ores-max-wall-ms=" + maxWallTime.toMillis(), + "--ores-adversarial=" + adversarial, + "--ores-execution-mode=" + profile.mode().name(), + "--ores-platform=" + profile.platform().name() + }; + } + + public static IsolatePolicy fromApplicationArguments(String[] args) { + String raw = null; + long maxHeap = 128L * 1024 * 1024; + int maxMailbox = 1024; + long maxWallMs = 30_000L; + boolean adversarial = false; + for (String arg : args) { + if (arg.startsWith("--ores-capabilities=")) raw = arg.substring("--ores-capabilities=".length()); + else if (arg.startsWith("--ores-max-heap-bytes=")) maxHeap = Long.parseLong(arg.substring("--ores-max-heap-bytes=".length())); + else if (arg.startsWith("--ores-max-mailbox-messages=")) maxMailbox = Integer.parseInt(arg.substring("--ores-max-mailbox-messages=".length())); + else if (arg.startsWith("--ores-max-wall-ms=")) maxWallMs = Long.parseLong(arg.substring("--ores-max-wall-ms=".length())); + else if (arg.startsWith("--ores-adversarial=")) adversarial = Boolean.parseBoolean(arg.substring("--ores-adversarial=".length())); + } + if (raw == null) return developer(); + EnumSet caps = EnumSet.noneOf(Capability.class); + if (!raw.isBlank()) { + for (String value : raw.split(",")) caps.add(Capability.valueOf(value.trim().toUpperCase(Locale.ROOT))); + } + return new IsolatePolicy(caps, maxHeap, maxMailbox, Duration.ofMillis(maxWallMs), adversarial); + } + + public static ExecutionProfile executionProfileFromApplicationArguments(String[] args) { + String mode = "JIT"; + String platform = "SERVER"; + for (String arg : args) { + if (arg.startsWith("--ores-execution-mode=")) mode = arg.substring("--ores-execution-mode=".length()); + else if (arg.startsWith("--ores-platform=")) platform = arg.substring("--ores-platform=".length()); + } + return ExecutionProfile.parse(mode, platform); + } + + private static String bytes(long value) { + return value + "B"; + } + + private static String duration(Duration value) { + return value.toMillis() + "ms"; + } +} diff --git a/src/main/java/dev/oreslang/runtime/LinkedProgramRunner.java b/src/main/java/dev/oreslang/runtime/LinkedProgramRunner.java new file mode 100644 index 00000000..73744cec --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/LinkedProgramRunner.java @@ -0,0 +1,326 @@ +package dev.oreslang.runtime; + +import dev.oreslang.OresLanguage; +import dev.oreslang.ast.Ast; +import dev.oreslang.compiler.IncrementalCompiler; +import dev.oreslang.config.OresProjectConfig; +import dev.oreslang.imports.ImportRules; +import dev.oreslang.interop.MixedInteropBridge; +import dev.oreslang.interop.MixedJavaCompiler; +import dev.oreslang.interop.MixedSourceUnit; +import dev.oreslang.nodes.OresEvalRootNode; +import dev.oreslang.parser.Parser; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Host-side multi-file loader. + * + * Oreslang code-unit identity comes exclusively from the canonical Unix-style + * filesystem path. Mixed .ores/.java files are split before parsing; no + * source-level module declaration is synthesized. + */ +public final class LinkedProgramRunner { + private LinkedProgramRunner() { } + + /** Parses/type-checks the reachable Ores graph and javac-checks mixed Java source. */ + public static IncrementalCompiler.BuildResult validate(Path entryFile) throws IOException { + return validate(entryFile, System.getenv()); + } + + public static IncrementalCompiler.BuildResult validate( + Path entryFile, + Map environment) throws IOException { + Path entry = entryFile.toAbsolutePath().normalize(); + if (!Files.isRegularFile(entry)) throw new IllegalArgumentException("not a file: " + entry); + + OresProjectConfig projectConfig = OresProjectConfig.discover(entry, environment); + LinkedHashMap units = new LinkedHashMap<>(); + LinkedHashMap> importResolutions = new LinkedHashMap<>(); + collectImportClosure(entry, units, projectConfig, importResolutions); + ensureJavaEntryContainsOres(entry, units); + Map sources = oresSources(units); + IncrementalCompiler.BuildResult build = + new IncrementalCompiler().compile(sources, importResolutions); + Map programs = parsePrograms(sources); + try (MixedJavaCompiler.Compilation ignored = MixedJavaCompiler.compile(new ArrayList<>(units.values()), programs)) { + return build; + } + } + + public static IncrementalCompiler.BuildResult run( + Path entryFile, + IsolatePolicy policy, + ExecutionProfile executionProfile, + OutputStream out, + OutputStream err) throws IOException { + return run(entryFile, policy, executionProfile, Set.of(), System.getenv(), out, err); + } + + public static IncrementalCompiler.BuildResult run( + Path entryFile, + IsolatePolicy policy, + ExecutionProfile executionProfile, + Set allowedHostClasses, + OutputStream out, + OutputStream err) throws IOException { + return run( + entryFile, + policy, + executionProfile, + allowedHostClasses, + System.getenv(), + out, + err); + } + + public static IncrementalCompiler.BuildResult run( + Path entryFile, + IsolatePolicy policy, + ExecutionProfile executionProfile, + Set allowedHostClasses, + Map environment, + OutputStream out, + OutputStream err) throws IOException { + Path entry = entryFile.toAbsolutePath().normalize(); + if (!Files.isRegularFile(entry)) throw new IllegalArgumentException("not a file: " + entry); + + OresProjectConfig projectConfig = OresProjectConfig.discover(entry, environment); + LinkedHashMap units = new LinkedHashMap<>(); + LinkedHashMap> importResolutions = new LinkedHashMap<>(); + collectImportClosure(entry, units, projectConfig, importResolutions); + ensureJavaEntryContainsOres(entry, units); + boolean hasJavaSource = units.values().stream().anyMatch(MixedSourceUnit::hasJavaSource); + if (hasJavaSource) { + policy.require(IsolatePolicy.Capability.JAVA_SOURCE_INTEROP, "mixed Java/Ores source islands"); + policy.require(IsolatePolicy.Capability.JAVA_INTEROP, "mixed Java/Ores object interop"); + if (policy.adversarial()) throw new SecurityException("mixed Java/Ores source islands are disabled for adversarial isolates"); + if (executionProfile.mode() != ExecutionProfile.Mode.JIT) { + throw new IllegalArgumentException("java { ... } / ores { ... } source islands currently require --mode=jit; AOT/hybrid builds must precompile Java source"); + } + } + + Map sources = oresSources(units); + IncrementalCompiler.BuildResult build = + new IncrementalCompiler().compile(sources, importResolutions); + Map programs = parsePrograms(sources); + String entryId = unitId(entry); + MixedSourceUnit entryUnit = units.get(entryId); + if (entryUnit == null) throw new IllegalStateException("entry source unit was not collected: " + entryId); + + try (MixedJavaCompiler.Compilation javaCompilation = MixedJavaCompiler.compile(new ArrayList<>(units.values()), programs)) { + LinkedHashSet effectiveHostClasses = new LinkedHashSet<>(allowedHostClasses); + effectiveHostClasses.addAll(javaCompilation.hostClasses()); + + Thread currentThread = Thread.currentThread(); + ClassLoader previousLoader = currentThread.getContextClassLoader(); + currentThread.setContextClassLoader(javaCompilation.classLoader()); + try { + Context.Builder builder = policy.restrictedContextBuilder(executionProfile, effectiveHostClasses); + if (out != null) builder.out(forwardingStream(out)); + if (err != null) builder.err(forwardingStream(err)); + + try (Context context = builder.build()) { + LinkedHashMap parsedUnits = new LinkedHashMap<>(); + List ids = new ArrayList<>(build.units().keySet()); + ids.sort(String::compareTo); + + for (String id : ids) { + IncrementalCompiler.CompiledUnit unit = build.units().get(id); + Source source = Source.newBuilder(OresLanguage.ID, unit.sourceText(), id) + .mimeType(OresLanguage.MIME_TYPE) + .buildLiteral(); + parsedUnits.put(id, context.parse(source)); + } + + for (Map.Entry> importer : importResolutions.entrySet()) { + Value parsed = parsedUnits.get(importer.getKey()); + if (parsed == null) continue; + for (Map.Entry resolution : importer.getValue().entrySet()) { + parsed.execute( + OresEvalRootNode.REGISTER_IMPORT_COMMAND, + resolution.getKey(), + resolution.getValue()); + } + } + + for (String id : ids) parsedUnits.get(id).execute(OresEvalRootNode.LINK_ONLY_COMMAND); + for (List group : build.initializationGroups()) { + for (String id : group) parsedUnits.get(id).execute(OresEvalRootNode.INIT_ONLY_COMMAND); + } + + LinkedHashMap bridgeInvokers = new LinkedHashMap<>(); + for (Map.Entry parsed : parsedUnits.entrySet()) { + Value unit = parsed.getValue(); + bridgeInvokers.put(parsed.getKey(), (function, arguments) -> { + Object[] invocation = new Object[2 + arguments.length]; + invocation[0] = OresEvalRootNode.INVOKE_PUBLIC_COMMAND; + invocation[1] = function; + System.arraycopy(arguments, 0, invocation, 2, arguments.length); + return toHostValue(unit.execute(invocation)); + }); + } + + try (MixedInteropBridge.Scope ignored = MixedInteropBridge.open(bridgeInvokers)) { + if (entryUnit.primaryLanguage() == MixedSourceUnit.PrimaryLanguage.JAVA) { + invokeJavaMain(javaCompilation.classLoader(), javaCompilation.mainClass(entryId)); + } else { + Value entryPoint = parsedUnits.get(entryId); + if (entryPoint == null) throw new IllegalStateException("entry unit was not linked: " + entryId); + entryPoint.execute(OresEvalRootNode.MAIN_ONLY_COMMAND); + } + } + } + } finally { + currentThread.setContextClassLoader(previousLoader); + } + } + return build; + } + + /** + * Graal's UNTRUSTED sandbox rejects raw System.out/System.err as ambient + * standard streams. Always present host-selected output as an explicit, + * non-closing redirection while preserving the caller-owned destination. + */ + private static OutputStream forwardingStream(OutputStream target) { + return new OutputStream() { + @Override public void write(int value) throws IOException { + target.write(value); + } + + @Override public void write(byte[] bytes, int offset, int length) throws IOException { + target.write(bytes, offset, length); + } + + @Override public void flush() throws IOException { + target.flush(); + } + + @Override public void close() throws IOException { + // The embedding caller owns the underlying stream (often + // System.out/System.err); Context.close must not close it. + target.flush(); + } + }; + } + + private static Object toHostValue(Value value) { + if (value == null || value.isNull()) return null; + if (value.isHostObject()) return value.asHostObject(); + if (value.isBoolean()) return value.asBoolean(); + if (value.isString()) return value.asString(); + if (value.fitsInInt()) return value.asInt(); + if (value.fitsInLong()) return value.asLong(); + if (value.fitsInDouble()) return value.asDouble(); + return value; + } + + private static void invokeJavaMain(ClassLoader loader, String className) { + if (className == null || className.isBlank()) throw new IllegalStateException("mixed Java entry has no main class"); + try { + Class type = Class.forName(className, true, loader); + Method main = type.getMethod("main", String[].class); + if (!Modifier.isStatic(main.getModifiers()) || main.getReturnType() != void.class) { + throw new IllegalArgumentException("Java entry main must be public static void main(String[]): " + className); + } + main.invoke(null, (Object) new String[0]); + } catch (InvocationTargetException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof RuntimeException runtime) throw runtime; + if (cause instanceof Error error) throw error; + throw new IllegalStateException("Java mixed-source main failed", cause); + } catch (ReflectiveOperationException failure) { + throw new IllegalArgumentException("cannot invoke Java mixed-source main on " + className, failure); + } + } + + private static void ensureJavaEntryContainsOres(Path entry, Map units) { + MixedSourceUnit unit = units.get(unitId(entry)); + if (unit != null && unit.primaryLanguage() == MixedSourceUnit.PrimaryLanguage.JAVA && unit.foreignIslands().isEmpty()) { + throw new IllegalArgumentException("a .java Oreslang entry must contain at least one ores { ... } island"); + } + } + + private static Map parsePrograms(Map sources) { + LinkedHashMap programs = new LinkedHashMap<>(); + for (Map.Entry source : sources.entrySet()) programs.put(source.getKey(), Parser.parse(source.getValue())); + return programs; + } + + private static Map oresSources(Map units) { + LinkedHashMap sources = new LinkedHashMap<>(); + for (MixedSourceUnit unit : units.values()) if (unit.hasOresSource()) sources.put(unit.unitId(), unit.oresSource()); + return sources; + } + + private static void collectImportClosure( + Path unit, + Map units, + OresProjectConfig projectConfig, + Map> importResolutions) throws IOException { + Path normalized = unit.toAbsolutePath().normalize(); + String id = unitId(normalized); + if (units.containsKey(id)) return; + + MixedSourceUnit mixed = MixedSourceUnit.parse(normalized, Files.readString(normalized)); + units.put(id, mixed); + if (!mixed.hasOresSource()) return; + + Ast.Program program = Parser.parse(mixed.oresSource()); + for (Ast.ImportDecl imported : program.imports()) { + if (ImportRules.isJavaPath(imported.path())) continue; + + String raw = imported.path().replace('\\', '/'); + java.util.Optional target = projectConfig.resolveImport(normalized, imported.path()); + if (target.isEmpty()) { + if (raw.startsWith(".") || Path.of(raw).isAbsolute()) { + throw new IllegalArgumentException( + "filesystem import '" + imported.path() + "' from '" + id + "' does not resolve to a file"); + } + // Preserve the existing package-resolver boundary for bare + // imports that are not present in project/ORESLANG_PATH roots. + continue; + } + + String targetId = unitId(target.get()); + recordImportResolution(importResolutions, id, imported.path(), targetId); + collectImportClosure(target.get(), units, projectConfig, importResolutions); + } + } + + private static void recordImportResolution( + Map> importResolutions, + String importerId, + String importPath, + String targetId) { + Map importer = importResolutions.computeIfAbsent( + importerId, + ignored -> new LinkedHashMap<>()); + String previous = importer.putIfAbsent(importPath, targetId); + if (previous != null && !previous.equals(targetId)) { + throw new IllegalArgumentException( + "import '" + importPath + "' from '" + importerId + + "' resolved to both '" + previous + "' and '" + targetId + "'"); + } + } + + private static String unitId(Path path) { + return path.toAbsolutePath().normalize().toString().replace('\\', '/'); + } +} diff --git a/src/main/java/dev/oreslang/runtime/NativeCarrierExecutor.java b/src/main/java/dev/oreslang/runtime/NativeCarrierExecutor.java new file mode 100644 index 00000000..94f81577 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/NativeCarrierExecutor.java @@ -0,0 +1,382 @@ +package dev.oreslang.runtime; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReferenceArray; + +/** + * Bounded actor-carrier executor backed by native OS threads. + * + *

The OS carriers are created by liboresthread (pthread on Linux/macOS), + * attached to the host VM exactly once with JNI AttachCurrentThreadAsDaemon, + * and then repeatedly execute ordinary Runnable actor turns from this bounded + * ready queue. Logical actors are therefore multiplexed over a much smaller + * native carrier set; no actor owns a carrier and carrier identity is never + * actor identity.

+ * + *

All max carriers are created up front so watchdog compensation never has + * to allocate an OS thread while the runtime is already under pressure. Only + * {@code corePoolSize} carrier slots are enabled at a time. Extra carriers are + * parked in native code and are enabled/disabled by the bounded compensation + * policy in {@link ActorRuntime}.

+ */ +public final class NativeCarrierExecutor extends AbstractExecutorService implements AutoCloseable { + private static final String LIBRARY = "oresthread"; + private static final ThreadLocal CURRENT_EXECUTOR = new ThreadLocal<>(); + private static final ThreadLocal CURRENT_SLOT = new ThreadLocal<>(); + private static final ThreadLocal CURRENT_NATIVE_THREAD_ID = new ThreadLocal<>(); + + private static final Object NATIVE_LIBRARY_LOCK = new Object(); + private static volatile boolean nativeLibraryLoaded; + + private final ArrayBlockingQueue queue; + private final int maximumPoolSize; + private final long nativeStackBytes; + private final AtomicInteger corePoolSize; + private final AtomicInteger largestPoolSize; + private final AtomicInteger activeCount = new AtomicInteger(); + private final AtomicLong completedTaskCount = new AtomicLong(); + private final AtomicReferenceArray carrierThreads; + private final AtomicBoolean shutdown = new AtomicBoolean(); + /** Serializes Java->native pool calls against asynchronous native retirement. */ + private final Object nativeLifecycleLock = new Object(); + private final long nativeHandle; + + public NativeCarrierExecutor( + int corePoolSize, + int maximumPoolSize, + int queueCapacity, + String threadPrefix) { + if (corePoolSize <= 0) throw new IllegalArgumentException("corePoolSize must be > 0"); + if (maximumPoolSize < corePoolSize) { + throw new IllegalArgumentException("maximumPoolSize must be >= corePoolSize"); + } + if (queueCapacity <= 0) throw new IllegalArgumentException("queueCapacity must be > 0"); + Objects.requireNonNull(threadPrefix, "threadPrefix"); + + ensureNativeLibraryLoaded(); + + this.queue = new ArrayBlockingQueue<>(queueCapacity, true); + this.maximumPoolSize = maximumPoolSize; + this.nativeStackBytes = configuredCarrierStackBytes(); + this.carrierThreads = new AtomicReferenceArray<>(maximumPoolSize); + this.corePoolSize = new AtomicInteger(corePoolSize); + this.largestPoolSize = new AtomicInteger(corePoolSize); + + long handle = nativeCreate( + this, maximumPoolSize, corePoolSize, threadPrefix, nativeStackBytes); + if (handle == 0L) throw new IllegalStateException("native carrier pool returned a null handle"); + this.nativeHandle = handle; + + boolean started = false; + try { + nativeStart(handle); + started = true; + } finally { + if (!started) nativeShutdown(handle); + } + } + + static void ensureNativeLibraryLoaded() { + if (nativeLibraryLoaded) return; + synchronized (NATIVE_LIBRARY_LOCK) { + if (nativeLibraryLoaded) return; + loadNativeLibrary(); + nativeLibraryLoaded = true; + } + } + + private static long configuredCarrierStackBytes() { + long configured = Long.getLong("ores.actor.carrier-stack-bytes", 2L * 1024 * 1024); + if (configured < 256L * 1024 || configured > 64L * 1024 * 1024) { + throw new IllegalArgumentException( + "ores.actor.carrier-stack-bytes must be between 262144 and 67108864"); + } + return configured; + } + + private static void loadNativeLibrary() { + String explicit = System.getProperty("ores.thread.native.path"); + if (explicit != null && !explicit.isBlank()) { + System.load(Path.of(explicit).toAbsolutePath().normalize().toString()); + return; + } + + Path local = Path.of("target", "native", System.mapLibraryName(LIBRARY)) + .toAbsolutePath().normalize(); + if (Files.isRegularFile(local)) { + System.load(local.toString()); + return; + } + + System.loadLibrary(LIBRARY); + } + + /** + * Native pthread entry callback. One invocation lives for one physical + * carrier's lifetime; many unrelated actor/root Runnables pass through it. + */ + @SuppressWarnings("unused") // invoked from JNI + private void nativeCarrierLoop(int slot) { + CURRENT_EXECUTOR.set(this); + CURRENT_SLOT.set(slot); + CURRENT_NATIVE_THREAD_ID.set(nativeCurrentThreadId()); + carrierThreads.set(slot, Thread.currentThread()); + try { + while (!shutdown.get()) { + nativeAwaitEnabled(nativeHandle, slot); + if (shutdown.get()) break; + + Runnable task; + try { + // A finite wait lets a carrier observe a requested shrink + // after it becomes idle without an extra JVM helper thread. + task = queue.poll(50L, TimeUnit.MILLISECONDS); + } catch (InterruptedException interrupted) { + // Watchdog interruption belongs to the actor/root turn that + // was active on this carrier. Never let the interrupt bit + // poison the next unrelated actor turn. + Thread.interrupted(); + if (shutdown.get()) break; + continue; + } + if (task == null) continue; + + activeCount.incrementAndGet(); + try { + task.run(); + } catch (Throwable failure) { + // ActorRuntime catches ordinary turn failures itself. This + // is the executor's final containment boundary, equivalent + // to a ThreadPoolExecutor worker's uncaught-exception path. + dispatchUncaught(failure); + } finally { + activeCount.decrementAndGet(); + completedTaskCount.incrementAndGet(); + Thread.interrupted(); + } + } + } finally { + carrierThreads.set(slot, null); + CURRENT_NATIVE_THREAD_ID.remove(); + CURRENT_SLOT.remove(); + CURRENT_EXECUTOR.remove(); + } + } + + private static void dispatchUncaught(Throwable failure) { + Thread current = Thread.currentThread(); + Thread.UncaughtExceptionHandler handler = current.getUncaughtExceptionHandler(); + if (handler == null) handler = Thread.getDefaultUncaughtExceptionHandler(); + if (handler != null) { + try { + handler.uncaughtException(current, failure); + } catch (Throwable ignored) { + // Keep the native carrier alive; ActorRuntime's own watchdog + // and fail-stop state are the authoritative control plane. + } + } + } + + @Override + public void execute(Runnable task) { + Objects.requireNonNull(task, "task"); + if (shutdown.get() || !queue.offer(task)) { + throw new RejectedExecutionException( + shutdown.get() ? "native carrier executor is shut down" + : "native carrier ready queue is full"); + } + } + + public boolean remove(Runnable task) { + return queue.remove(task); + } + + @Override + public List shutdownNow() { + if (isCurrentCarrierThread()) { + throw new IllegalStateException( + "native carrier executor cannot synchronously interrupt itself; use requestShutdownFromCarrier()"); + } + return beginShutdown(true); + } + + /** + * Cooperative one-way shutdown for code currently executing on one of this + * executor's own native carriers. No Java interrupt is injected into the + * active turn; once that turn unwinds, the carrier observes the shutdown + * flag and exits through the native reaper path. + */ + void requestShutdownFromCarrier() { + if (!isCurrentCarrierThread()) { + throw new IllegalStateException( + "requestShutdownFromCarrier must run on this executor's native carrier"); + } + beginShutdown(false); + } + + boolean isCurrentCarrierThread() { + return CURRENT_EXECUTOR.get() == this; + } + + private List beginShutdown(boolean interruptActiveCarriers) { + if (!shutdown.compareAndSet(false, true)) return List.of(); + ArrayList abandoned = new ArrayList<>(); + queue.drainTo(abandoned); + + if (interruptActiveCarriers) { + // Match ThreadPoolExecutor.shutdownNow(): signal any active carrier + // before native retirement. This is cooperative Java interruption, + // never unsafe pthread_cancel(). + for (int slot = 0; slot < carrierThreads.length(); slot++) { + Thread carrier = carrierThreads.get(slot); + if (carrier != null) carrier.interrupt(); + } + } + + synchronized (nativeLifecycleLock) { + // Native shutdown is non-blocking: it marks the pool closed and + // hands joins/reclamation to a native reaper so an uncooperative + // guest stack can never hold this caller hostage. + nativeShutdown(nativeHandle); + } + return List.copyOf(abandoned); + } + + /** + * Wait until every attached pthread carrier has exited its Java loop. + * Native shutdown itself is deliberately non-blocking; callers that own a + * scheduler can use this bounded observation without joining arbitrary + * guest stacks in native code. + */ + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + Objects.requireNonNull(unit, "unit"); + if (timeout < 0) throw new IllegalArgumentException("timeout must be non-negative"); + long nanos = unit.toNanos(timeout); + long deadline = System.nanoTime() + nanos; + for (;;) { + boolean anyAttached = false; + for (int slot = 0; slot < carrierThreads.length(); slot++) { + if (carrierThreads.get(slot) != null) { + anyAttached = true; + break; + } + } + if (!anyAttached) return true; + if (nanos == 0L || System.nanoTime() >= deadline) return false; + long remaining = deadline - System.nanoTime(); + long millis = Math.max(1L, Math.min(10L, TimeUnit.NANOSECONDS.toMillis(remaining))); + Thread.sleep(millis); + } + } + + public boolean isQueueEmpty() { return queue.isEmpty(); } + + @Override + public void close() { + shutdownNow(); + } + + public int getActiveCount() { return activeCount.get(); } + public int getQueueSize() { return queue.size(); } + public long getCompletedTaskCount() { return completedTaskCount.get(); } + public int getLargestPoolSize() { return largestPoolSize.get(); } + public int getMaximumPoolSize() { return maximumPoolSize; } + public long getNativeStackBytes() { return nativeStackBytes; } + public int getCorePoolSize() { return corePoolSize.get(); } + @Override + public void shutdown() { + shutdownNow(); + } + + @Override + public boolean isShutdown() { return shutdown.get(); } + + @Override + public boolean isTerminated() { + if (!shutdown.get()) return false; + for (int slot = 0; slot < carrierThreads.length(); slot++) { + if (carrierThreads.get(slot) != null) return false; + } + return true; + } + + public void setCorePoolSize(int value) { + if (value <= 0 || value > maximumPoolSize) { + throw new IllegalArgumentException( + "corePoolSize must be in [1," + maximumPoolSize + "]"); + } + if (shutdown.get()) throw new RejectedExecutionException("native carrier executor is shut down"); + synchronized (nativeLifecycleLock) { + if (shutdown.get()) { + throw new RejectedExecutionException("native carrier executor is shut down"); + } + corePoolSize.set(value); + largestPoolSize.accumulateAndGet(value, Math::max); + nativeSetDesired(nativeHandle, value); + } + } + + /** + * All bounded compensation carriers are physically created at pool + * construction and parked natively, so there is nothing to allocate here. + */ + public boolean prestartCoreThread() { + return false; + } + + public static boolean isNativeCarrierThread() { + return CURRENT_EXECUTOR.get() != null; + } + + /** Diagnostic-only native pthread identity; never an actor identity. */ + public static long currentNativeThreadId() { + Long id = CURRENT_NATIVE_THREAD_ID.get(); + return id == null ? 0L : id; + } + + /** Diagnostic-only carrier slot within its pool. */ + public static int currentCarrierSlot() { + Integer slot = CURRENT_SLOT.get(); + return slot == null ? -1 : slot; + } + + /** CPU consumed by the current native carrier, excluding time descheduled. */ + public static long currentCarrierCpuTimeNanos() { + return isNativeCarrierThread() ? nativeCurrentThreadCpuNanos() : 0L; + } + + /** CPU consumed by a specific carrier slot in this pool, for watchdog accounting. */ + public long carrierCpuTimeNanos(int slot) { + if (slot < 0 || slot >= maximumPoolSize || shutdown.get()) return 0L; + synchronized (nativeLifecycleLock) { + if (shutdown.get()) return 0L; + return nativeCarrierCpuTimeNanos(nativeHandle, slot); + } + } + + private static native long nativeCreate( + NativeCarrierExecutor executor, + int maxThreads, + int desiredThreads, + String threadPrefix, + long stackBytes); + private static native void nativeStart(long handle); + private static native void nativeSetDesired(long handle, int desiredThreads); + private static native void nativeAwaitEnabled(long handle, int slot); + private static native void nativeShutdown(long handle); + private static native long nativeCurrentThreadId(); + private static native long nativeCurrentThreadCpuNanos(); + private static native long nativeCarrierCpuTimeNanos(long handle, int slot); +} diff --git a/src/main/java/dev/oreslang/runtime/OresContext.java b/src/main/java/dev/oreslang/runtime/OresContext.java new file mode 100644 index 00000000..c09edcb3 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/OresContext.java @@ -0,0 +1,245 @@ +package dev.oreslang.runtime; + +import com.oracle.truffle.api.TruffleContext; +import com.oracle.truffle.api.TruffleLanguage; +import com.oracle.truffle.api.TruffleLanguage.ContextReference; +import com.oracle.truffle.api.nodes.Node; +import dev.oreslang.OresLanguage; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; + +public final class OresContext implements AutoCloseable { + private static final ContextReference REFERENCE = ContextReference.create(OresLanguage.class); + + private final OresLanguage language; + private final TruffleLanguage.Env env; + private final BufferedReader input; + private final PrintWriter output; + private final ActorRuntime actors; + private final AsyncRuntime asyncRuntime; + private final RuntimeGarbageCollector garbageCollector; + private final UUID contextId = UUID.randomUUID(); + private final AtomicLong schedulerSafepoints = new AtomicLong(); + private final IsolatePolicy isolatePolicy; + private final ExecutionProfile executionProfile; + private final ReentrantLock adversarialActorTurnLock = new ReentrantLock(true); + private final Map linkedCodeUnits = new HashMap<>(); + private final Map> linkedImportResolutions = new HashMap<>(); + + public OresContext(OresLanguage language, TruffleLanguage.Env env) { + this.language = language; + this.env = env; + this.input = new BufferedReader(new InputStreamReader(env.in())); + this.output = new PrintWriter(env.out(), true); + this.isolatePolicy = IsolatePolicy.fromApplicationArguments(env.getApplicationArguments()); + this.executionProfile = IsolatePolicy.executionProfileFromApplicationArguments(env.getApplicationArguments()); + this.actors = new ActorRuntime( + isolatePolicy, + ActorRuntime.DispatcherConfig.defaults(), + this::executeActorTurn); + this.asyncRuntime = new AsyncRuntime(this::executeAsyncTurn); + this.garbageCollector = new RuntimeGarbageCollector(); + this.actors.setActorExitHook(garbageCollector::retireActorDomain); + } + + public static OresContext get(Node node) { + return REFERENCE.get(node); + } + + public OresLanguage language() { return language; } + public TruffleLanguage.Env env() { return env; } + public BufferedReader input() { return input; } + public PrintWriter output() { return output; } + public ActorRuntime actors() { return actors; } + public AsyncRuntime asyncRuntime() { return asyncRuntime; } + public RuntimeGarbageCollector garbageCollector() { return garbageCollector; } + public UUID contextId() { return contextId; } + public IsolatePolicy isolatePolicy() { return isolatePolicy; } + public ExecutionProfile executionProfile() { return executionProfile; } + + public Object lookupHostSymbol(String className) { + requireCapability(IsolatePolicy.Capability.JAVA_INTEROP, "Java host import " + className); + if (!env.isHostLookupAllowed()) { + throw new SecurityException("Java host class lookup is disabled by the embedding Context"); + } + try { + return env.lookupHostSymbol(className); + } catch (RuntimeException failure) { + throw new IllegalArgumentException( + "Java host class is not allowlisted or unavailable: " + className, + failure); + } + } + + public void requireCapability(IsolatePolicy.Capability capability, String api) { + IsolatePolicy actorPolicy = ActorRuntime.currentActorPolicy(); + if (actorPolicy != null && ActorRuntime.currentActorRuntime() != actors) { + throw new SecurityException( + "actor capability check crossed ActorRuntime boundary for " + api); + } + requireEffectiveCapability(isolatePolicy, capability, api); + } + + static void requireEffectiveCapability( + IsolatePolicy contextPolicy, + IsolatePolicy.Capability capability, + String api) { + // Actor turns execute inside the parent Truffle context, but they may + // have a strictly narrower capability set than that context. Always + // enforce the actor-local policy first so helper functions, imported + // code, and ordinary class methods cannot launder authority from the + // parent context into a private actor. + IsolatePolicy actorPolicy = ActorRuntime.currentActorPolicy(); + if (actorPolicy != null) actorPolicy.require(capability, api); + contextPolicy.require(capability, api); + } + + /** + * Compiler-injected cooperative scheduling checkpoint. Loops call this on + * every iteration so a future supervisor/control mailbox can interrupt + * long-running actor code without requiring recursion-only looping. + */ + public void schedulerSafepoint() { + schedulerSafepoints.incrementAndGet(); + actors.schedulerSafepoint(); + } + + public long schedulerSafepoints() { return schedulerSafepoints.get(); } + + /** + * Host-managed cross-file link registry. Guest imports may only observe + * units that the host has explicitly loaded into this context; import + * syntax never grants filesystem access. + */ + public synchronized void registerLinkedCodeUnit(String codeUnitId, Object unit) { + if (codeUnitId == null || codeUnitId.isBlank()) { + throw new IllegalArgumentException("linked code unit id cannot be blank"); + } + Object previous = linkedCodeUnits.putIfAbsent(codeUnitId, unit); + if (previous != null && previous != unit) { + throw new IllegalStateException("code unit already linked in this context: " + codeUnitId); + } + } + + public synchronized Object linkedCodeUnit(String codeUnitId) { + return linkedCodeUnits.get(codeUnitId); + } + + public synchronized boolean hasLinkedCodeUnit(String codeUnitId) { + return linkedCodeUnits.containsKey(codeUnitId); + } + + /** + * Registers the host compiler's exact filesystem resolution for one import. + * Guest code can only consume these aliases; it does not gain filesystem + * access by knowing the resolved target. + */ + public synchronized void registerLinkedImportResolution( + String importerCodeUnitId, + String importPath, + String targetCodeUnitId) { + if (importerCodeUnitId == null || importerCodeUnitId.isBlank()) { + throw new IllegalArgumentException("importer code unit id cannot be blank"); + } + if (importPath == null || importPath.isBlank()) { + throw new IllegalArgumentException("linked import path cannot be blank"); + } + if (targetCodeUnitId == null || targetCodeUnitId.isBlank()) { + throw new IllegalArgumentException("target code unit id cannot be blank"); + } + + Map imports = linkedImportResolutions.computeIfAbsent( + normalizeCodeUnitId(importerCodeUnitId), + ignored -> new HashMap<>()); + String target = normalizeCodeUnitId(targetCodeUnitId); + String previous = imports.putIfAbsent(importPath, target); + if (previous != null && !previous.equals(target)) { + throw new IllegalStateException( + "conflicting import resolution for '" + importPath + "' in '" + importerCodeUnitId + "'"); + } + } + + public synchronized String resolvedLinkedImport(String importerCodeUnitId, String importPath) { + Map imports = linkedImportResolutions.get(normalizeCodeUnitId(importerCodeUnitId)); + return imports == null ? null : imports.get(importPath); + } + + private static String normalizeCodeUnitId(String id) { + return java.nio.file.Path.of(id).normalize().toString().replace('\\', '/'); + } + + private void executeActorTurn(Runnable turn) { + executeGuestTurn(turn, isolatePolicy.adversarial()); + } + + private void executeAsyncTurn(Runnable turn) { + // The current interpreter executes an async callable as one virtual- + // thread task. Holding the adversarial actor serialization lock across + // an await could deadlock a nested async task, so strict/adversarial + // profiles fail closed until compiler continuation lowering can release + // the guest turn at each await suspension point. + if (isolatePolicy.adversarial()) { + throw new SecurityException( + "async callable execution in adversarial contexts requires continuation lowering"); + } + executeGuestTurn(turn, false); + } + + private void executeGuestTurn(Runnable turn, boolean serialize) { + if (serialize) adversarialActorTurnLock.lock(); + TruffleContext truffleContext = env.getContext(); + Object previous = null; + boolean entered = false; + try { + previous = truffleContext.enter(null); + entered = true; + turn.run(); + } finally { + if (entered) truffleContext.leave(null, previous); + if (serialize) adversarialActorTurnLock.unlock(); + } + } + + + public Map processDescriptor() { + return Map.of( + "context_id", contextId.toString(), + "runtime", "graalvm-truffle", + "language", "oreslang", + "execution_mode", executionProfile.mode().name(), + "platform", executionProfile.platform().name(), + "actor_carrier_backend", actors.carrierBackend().name().toLowerCase(java.util.Locale.ROOT), + "scheduler_safepoints", schedulerSafepoints.get()); + } + + @Override + public void close() { + RuntimeException failure = null; + try { + asyncRuntime.close(); + } catch (RuntimeException asyncFailure) { + failure = asyncFailure; + } + try { + actors.close(); + } catch (RuntimeException actorFailure) { + if (failure == null) failure = actorFailure; + else failure.addSuppressed(actorFailure); + } finally { + synchronized (this) { + linkedCodeUnits.clear(); + linkedImportResolutions.clear(); + } + garbageCollector.close(); + output.flush(); + } + if (failure != null) throw failure; + } +} diff --git a/src/main/java/dev/oreslang/runtime/OresFuture.java b/src/main/java/dev/oreslang/runtime/OresFuture.java new file mode 100644 index 00000000..b148ab61 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/OresFuture.java @@ -0,0 +1,667 @@ +package dev.oreslang.runtime; + +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Oreslang's runtime-owned Future primitive. + * + *

This is deliberately not a {@link CompletableFuture}. Oreslang + * Future values do not expose a callback surface that can accidentally execute + * guest code on an I/O, timer, JNI, or producer-completion thread. Runtime + * components may register enqueue-only waiters through + * {@link #whenCompleteRuntime(BiConsumer)}; actor/root schedulers decide when + * the captured Oreslang continuation actually runs.

+ * + *

Completion authority belongs to the runtime operation that created the + * Future. Guest code may observe state, await, or request cancellation, but it + * cannot forge a value/failure.

+ */ +public final class OresFuture implements Future, Awaitable { + private static final Object PENDING = new Object(); + + /** + * Single-shot completion capability used to adapt callback-only APIs. + * + *

Calling resolve/reject/cancel settles the target Future but never + * resumes Oreslang guest code inline. Awaiting code is still resumed only + * by its owning scheduler after the suspending turn has unwound.

+ */ + public interface Callback { + void resolve(T value); + void reject(Throwable failure); + void cancel(); + boolean isDone(); + + default void complete(Throwable failure, T value) { + if (failure == null) resolve(value); + else reject(failure); + } + } + + public static final class AlreadySettledException extends IllegalStateException { + public AlreadySettledException(String message) { + super(message); + } + } + + private record Success(T value) { } + private record Failure(Throwable failure) { } + private record Cancelled(CancellationException failure) { } + + /** + * Runtime-only detachable completion waiter. + * + *

The waiter is also its own registration handle. Keeping the callback, + * exactly-once claim bit, owner, and detach operation in one object avoids + * allocating a second registration wrapper and captured removal lambda for + * every genuinely suspending await.

+ * + *

Detaching never changes the Future's producer/cancellation state. It + * only prevents this runtime continuation waiter from retaining or being + * invoked after its owning scheduler/task has been cancelled.

+ */ + static final class RuntimeWaiterRegistration { + private final OresFuture owner; + private final BiConsumer callback; + private final AtomicBoolean claimed = new AtomicBoolean(); + + private RuntimeWaiterRegistration( + OresFuture owner, + BiConsumer callback) { + this.owner = Objects.requireNonNull(owner, "owner"); + this.callback = Objects.requireNonNull(callback, "callback"); + } + + boolean detach() { + if (!claimed.compareAndSet(false, true)) return false; + owner.waiters.remove(this); + return true; + } + } + + private final BooleanSupplier cancelAdmission; + private final AtomicReference cancelHook; + private final AtomicBoolean cancelHookRun = new AtomicBoolean(); + private final AtomicReference state = new AtomicReference<>(PENDING); + private final ConcurrentLinkedQueue> waiters = new ConcurrentLinkedQueue<>(); + + public OresFuture() { + this(() -> true, () -> { }); + } + + OresFuture(Runnable cancelHook) { + this(() -> true, cancelHook); + } + + /** + * Runtime-only constructor for operations whose cancellation must win an + * external arbitration before the Future transitions to cancelled. + * + *

This is used by channels/select so a cancellation racing with a ready + * case cannot consume a channel value after cancellation has already won. + * Guest code never receives the admission capability.

+ */ + OresFuture(BooleanSupplier cancelAdmission, Runnable cancelHook) { + this.cancelAdmission = Objects.requireNonNull( + cancelAdmission, "cancelAdmission"); + this.cancelHook = new AtomicReference<>( + Objects.requireNonNull(cancelHook, "cancelHook")); + } + + public static OresFuture completed(T value) { + OresFuture future = new OresFuture<>(); + future.completeFromRuntime(value); + return future; + } + + public static OresFuture failed(Throwable failure) { + OresFuture future = new OresFuture<>(); + future.failFromRuntime(Objects.requireNonNull(failure, "failure")); + return future; + } + + /** + * Adapt a single-shot callback registration API into an Ores Future. + * + *

The registrar may invoke the callback synchronously. That only settles + * the Future; it cannot re-enter an awaiting Oreslang frame. A second + * callback settlement is rejected deterministically.

+ */ + public static OresFuture fromCallback( + Consumer> registrar) { + Objects.requireNonNull(registrar, "registrar"); + OresFuture future = new OresFuture<>(); + AtomicBoolean callbackClaimed = new AtomicBoolean(); + + Callback completion = new Callback<>() { + private boolean claim(String operation) { + if (!callbackClaimed.compareAndSet(false, true)) { + throw new AlreadySettledException( + "callback Future already settled; duplicate " + operation); + } + + // Consumer cancellation may legitimately win before a foreign + // callback arrives. The first late producer callback is then a + // no-op rather than an exception escaping onto the producer's + // thread. Marking the callback claimed still diagnoses any + // subsequent duplicate callback invocation. + return !future.isCancelled(); + } + + @Override + public void resolve(T value) { + if (!claim("resolve")) return; + if (!future.completeFromRuntime(value) && !future.isCancelled()) { + throw new AlreadySettledException( + "callback Future was already settled before resolve"); + } + } + + @Override + public void reject(Throwable failure) { + Objects.requireNonNull(failure, "failure"); + if (!claim("reject")) return; + if (!future.failFromRuntime(failure) && !future.isCancelled()) { + throw new AlreadySettledException( + "callback Future was already settled before reject"); + } + } + + @Override + public void cancel() { + if (!claim("cancel")) return; + if (!future.cancel(false) && !future.isCancelled()) { + throw new AlreadySettledException( + "callback Future was already settled before cancel"); + } + } + + @Override + public boolean isDone() { + return callbackClaimed.get() || future.isDone(); + } + }; + + try { + registrar.accept(completion); + } catch (Throwable failure) { + // Promise-style constructor semantics: a registrar failure rejects + // only if the callback has not already won the single-shot race. + if (callbackClaimed.compareAndSet(false, true)) { + future.failFromRuntime(failure); + } else if (failure instanceof VirtualMachineError fatal) { + throw fatal; + } else if (failure instanceof ThreadDeath fatal) { + throw fatal; + } else if (failure instanceof LinkageError fatal) { + throw fatal; + } + } + return future; + } + + @Override + public OresFuture getAwaited() { + return this; + } + + @SuppressWarnings("unchecked") + public static OresFuture from(OresFuture future) { + return (OresFuture) Objects.requireNonNull(future, "future"); + } + + /** + * Host-interop adapter. The host CompletionStage may complete on any thread; + * its callback only settles this OresFuture. It does not run guest code. + */ + public static OresFuture from(CompletionStage stage) { + Objects.requireNonNull(stage, "stage"); + Runnable cancelHook = stage instanceof Future cancellable + ? () -> cancellable.cancel(true) + : () -> { }; + OresFuture result = new OresFuture<>(cancelHook); + stage.whenComplete((value, failure) -> { + if (failure == null) { + result.completeFromRuntime(value); + return; + } + + Throwable terminalFailure = unwrap(failure); + boolean cancelled = stage instanceof Future hostFuture + ? hostFuture.isCancelled() + : terminalFailure instanceof CancellationException; + + if (cancelled) { + result.cancel(false); + } else { + result.failFromRuntime(terminalFailure); + } + }); + return result; + } + + /** + * Chain a callback-only operation after this Future on an explicit Ores + * scheduler. Guest registrar code executes only as a scheduler turn. + * + *

If the callback fires synchronously, the dependent Future is already + * settled when this turn returns Await, but TaskRunner still requires the + * current turn to unwind before the continuation can be dispatched.

+ */ + public OresFuture attachCallback( + OresScheduler scheduler, + java.util.function.BiConsumer> registrar) { + Objects.requireNonNull(scheduler, "scheduler"); + Objects.requireNonNull(registrar, "registrar"); + OresFuture source = this; + AtomicReference> dependentRef = new AtomicReference<>(); + + OresFuture task = scheduler.start(new OresScheduler.Task<>() { + private int pc; + + @Override + public OresScheduler.Step resume(OresScheduler.Resume resume) { + if (pc == 0) { + if (!resume.initial()) { + throw new IllegalStateException( + "callback chain started with a non-initial resume"); + } + pc = 1; + return OresScheduler.await(source); + } + + if (pc == 1) { + if (resume.failure() != null) { + throw propagate(resume.failure()); + } + @SuppressWarnings("unchecked") + T value = (T) resume.value(); + OresFuture dependent = OresFuture.fromCallback( + callback -> registrar.accept(value, callback)); + dependentRef.set(dependent); + pc = 2; + return OresScheduler.await(dependent); + } + + if (pc == 2) { + if (resume.failure() != null) { + throw propagate(resume.failure()); + } + @SuppressWarnings("unchecked") + U value = (U) resume.value(); + pc = 3; + return OresScheduler.done(value); + } + + throw new IllegalStateException( + "callback Future chain resumed after completion"); + } + + private RuntimeException propagate(Throwable failure) { + Throwable unwrapped = OresFuture.unwrap(failure); + if (unwrapped instanceof RuntimeException runtime) return runtime; + if (unwrapped instanceof Error error) throw error; + return new RuntimeException(unwrapped); + } + }); + + AtomicReference> taskWaiter = + new AtomicReference<>(); + + Runnable detach = () -> { + RuntimeWaiterRegistration registration = + taskWaiter.getAndSet(null); + if (registration != null) registration.detach(); + }; + + OresFuture exposed = new OresFuture<>(() -> { + detach.run(); + task.cancel(true); + + // The callback-produced Future belongs to this chain. Marking it + // cancelled prevents a late foreign callback from reviving work; + // OresFuture.fromCallback safely drops the first such late callback. + OresFuture dependent = dependentRef.getAndSet(null); + if (dependent != null) dependent.cancel(false); + + // The source may be shared by other consumers, so chain + // cancellation deliberately never cancels it. + }); + + RuntimeWaiterRegistration registration = + task.whenCompleteRuntimeCancellable((value, failure) -> { + try { + if (exposed.isDone()) return; + + OresFuture dependent = dependentRef.get(); + if (failure == null) { + exposed.completeFromRuntime(value); + } else if (task.isCancelled() + || source.isCancelled() + || (dependent != null && dependent.isCancelled())) { + exposed.cancel(false); + } else { + exposed.failFromRuntime(OresFuture.unwrap(failure)); + } + } finally { + detach.run(); + dependentRef.set(null); + } + }); + + taskWaiter.set(registration); + if (exposed.isDone()) { + detach.run(); + dependentRef.set(null); + } + + return exposed; + } + + boolean completeFromRuntime(T value) { + boolean completed = settle(new Success<>(value)); + if (completed) cancelHook.set(null); + return completed; + } + + boolean failFromRuntime(Throwable failure) { + boolean completed = settle( + new Failure(Objects.requireNonNull(failure, "failure"))); + if (completed) cancelHook.set(null); + return completed; + } + + /** + * Runtime-only completion subscription. + * + *

Callbacks registered here must be scheduler plumbing only: transition a + * dependent Future, enqueue a continuation, or release runtime accounting. + * They must never execute Oreslang guest code directly.

+ */ + void whenCompleteRuntime(BiConsumer callback) { + whenCompleteRuntimeCancellable(callback); + } + + RuntimeWaiterRegistration whenCompleteRuntimeCancellable( + BiConsumer callback) { + Objects.requireNonNull(callback, "callback"); + RuntimeWaiterRegistration waiter = + new RuntimeWaiterRegistration<>(this, callback); + waiters.add(waiter); + + Object observed = state.get(); + if (observed != PENDING) { + // A registration racing with (or following) settlement must not + // leave an already-claimed callback strongly retained in the + // pending waiter queue. Removing before notification is race-safe: + // if settle() already polled it, remove is a no-op and the claimed + // bit still guarantees exactly-once callback delivery. + waiters.remove(waiter); + notifyWaiter(waiter, observed); + } + return waiter; + } + + int pendingRuntimeWaiterCount() { + return waiters.size(); + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + if (state.get() != PENDING) return false; + if (!cancelAdmission.getAsBoolean()) return false; + + CancellationException cancelled = + new CancellationException("OresFuture was cancelled"); + if (!settle(new Cancelled(cancelled))) return false; + + Runnable hook = cancelHook.getAndSet(null); + if (hook != null && cancelHookRun.compareAndSet(false, true)) { + try { + hook.run(); + } catch (RuntimeException | Error ignored) { + // Cancellation state is already authoritative. A host + // cancellation hook cannot roll it back or poison waiter + // delivery. + } + } + return true; + } + + @Override + public boolean isCancelled() { + return state.get() instanceof Cancelled; + } + + @Override + public boolean isDone() { + return state.get() != PENDING; + } + + /** + * Allocation-free runtime observation used by scheduler await fast paths. + * + *

A non-null value is the Future's immutable terminal state object. A + * null result means the Future was still pending at the observation point. + * Callers that observe pending must still register normally, because + * settlement may race immediately after this load.

+ */ + Object runtimeTerminalStateOrNull() { + Object observed = state.get(); + return observed == PENDING ? null : observed; + } + + /** + * Decode a terminal state previously returned by + * {@link #runtimeTerminalStateOrNull()} without allocating a wrapper. + */ + static Object runtimeTerminalValue(Object terminal) { + if (terminal instanceof Success success) return success.value(); + if (terminal instanceof Failure || terminal instanceof Cancelled) return null; + throw new IllegalArgumentException("terminal Future state required"); + } + + /** + * Decode the failure/cancellation of a terminal state previously returned + * by {@link #runtimeTerminalStateOrNull()} without allocating a wrapper. + */ + static Throwable runtimeTerminalFailure(Object terminal) { + if (terminal instanceof Success) return null; + if (terminal instanceof Failure failed) return failed.failure(); + if (terminal instanceof Cancelled cancelled) return cancelled.failure(); + throw new IllegalArgumentException("terminal Future state required"); + } + + /** + * Read-only compatibility observation used by runtime/tests. As with + * CompletableFuture, cancellation is also an exceptional terminal state. + */ + public boolean isCompletedExceptionally() { + Object observed = state.get(); + return observed instanceof Failure || observed instanceof Cancelled; + } + + @Override + public T get() throws InterruptedException, ExecutionException { + Object observed = awaitState(0L, null); + return reportGet(observed); + } + + @Override + public T get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + Objects.requireNonNull(unit, "unit"); + if (timeout < 0) throw new IllegalArgumentException("timeout must be non-negative"); + Object observed = awaitState(timeout, unit); + if (observed == PENDING) { + throw new TimeoutException("OresFuture did not complete before timeout"); + } + return reportGet(observed); + } + + /** + * Host/embedder blocking bridge. Oreslang actor/root lowering must use the + * scheduler suspension ABI rather than calling join on a carrier. + */ + public T join() { + Object observed = state.get(); + boolean interrupted = false; + if (observed == PENDING) { + java.util.concurrent.CountDownLatch done = new java.util.concurrent.CountDownLatch(1); + whenCompleteRuntime((value, failure) -> done.countDown()); + for (;;) { + try { + done.await(); + break; + } catch (InterruptedException interruption) { + interrupted = true; + } + } + observed = state.get(); + } + if (interrupted) Thread.currentThread().interrupt(); + return reportJoin(observed); + } + + /** + * Runtime callback adapters use this to compose an Ores Future with APIs + * that still require CompletionStage. Guest/language code should never + * receive the returned stage. + */ + CompletionStage asCompletionStage() { + CompletableFuture bridge = new CompletableFuture<>(); + whenCompleteRuntime((value, failure) -> { + if (failure == null) bridge.complete(value); + else bridge.completeExceptionally(failure); + }); + return bridge; + } + + private boolean settle(Object terminal) { + if (!state.compareAndSet(PENDING, terminal)) return false; + + RuntimeWaiterRegistration waiter; + while ((waiter = waiters.poll()) != null) { + notifyWaiter(waiter, terminal); + } + return true; + } + + @SuppressWarnings("unchecked") + private void notifyWaiter(RuntimeWaiterRegistration waiter, Object terminal) { + if (!waiter.claimed.compareAndSet(false, true)) return; + try { + if (terminal instanceof Success success) { + waiter.callback.accept((T) success.value(), null); + } else if (terminal instanceof Failure failed) { + waiter.callback.accept(null, failed.failure()); + } else if (terminal instanceof Cancelled cancelled) { + waiter.callback.accept(null, cancelled.failure()); + } else { + throw new IllegalStateException("attempted to notify waiter from pending Future"); + } + } catch (RuntimeException | Error ignored) { + // Runtime waiter failures must not stop delivery to other waiters or + // mutate the settled Future. Scheduler plumbing owns its own + // failure path. + } + } + + private Object awaitState(long timeout, TimeUnit unit) throws InterruptedException { + Object observed = state.get(); + if (observed != PENDING) return observed; + + java.util.concurrent.CountDownLatch done = new java.util.concurrent.CountDownLatch(1); + whenCompleteRuntime((value, failure) -> done.countDown()); + + if (unit == null) { + done.await(); + } else if (!done.await(timeout, unit)) { + return state.get() == PENDING ? PENDING : state.get(); + } + return state.get(); + } + + @SuppressWarnings("unchecked") + private T reportGet(Object terminal) throws ExecutionException { + if (terminal instanceof Success success) return (T) success.value(); + if (terminal instanceof Failure failed) throw new ExecutionException(failed.failure()); + if (terminal instanceof Cancelled cancelled) throw cancelled.failure(); + throw new IllegalStateException("Future is still pending"); + } + + @SuppressWarnings("unchecked") + private T reportJoin(Object terminal) { + if (terminal instanceof Success success) return (T) success.value(); + if (terminal instanceof Failure failed) { + throw new CompletionException(failed.failure()); + } + if (terminal instanceof Cancelled cancelled) throw cancelled.failure(); + throw new IllegalStateException("Future is still pending"); + } + + /** + * Completion remains runtime-owned. These methods intentionally exist so + * Java interop receives an explicit failure instead of silently gaining a + * completion capability. + */ + public boolean complete(T value) { + throw new UnsupportedOperationException("OresFuture completion is runtime-owned"); + } + + public boolean completeExceptionally(Throwable ex) { + throw new UnsupportedOperationException("OresFuture completion is runtime-owned"); + } + + public CompletableFuture completeAsync(Supplier supplier) { + throw new UnsupportedOperationException("OresFuture completion is runtime-owned"); + } + + public CompletableFuture completeAsync( + Supplier supplier, + java.util.concurrent.Executor executor) { + throw new UnsupportedOperationException("OresFuture completion is runtime-owned"); + } + + public CompletableFuture orTimeout(long timeout, TimeUnit unit) { + throw new UnsupportedOperationException( + "OresFuture completion is runtime-owned; use an Oreslang timeout combinator"); + } + + public CompletableFuture completeOnTimeout(T value, long timeout, TimeUnit unit) { + throw new UnsupportedOperationException( + "OresFuture completion is runtime-owned; use an Oreslang timeout combinator"); + } + + public void obtrudeValue(T value) { + throw new UnsupportedOperationException("OresFuture completion is runtime-owned"); + } + + public void obtrudeException(Throwable ex) { + throw new UnsupportedOperationException("OresFuture completion is runtime-owned"); + } + + static Throwable unwrap(Throwable failure) { + Throwable current = failure; + while ((current instanceof CompletionException || current instanceof ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } +} diff --git a/src/main/java/dev/oreslang/runtime/OresFutures.java b/src/main/java/dev/oreslang/runtime/OresFutures.java new file mode 100644 index 00000000..4f68e604 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/OresFutures.java @@ -0,0 +1,191 @@ +package dev.oreslang.runtime; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReferenceArray; + +/** + * Structured combinators for language-level Future values. + * + *

The public language primitive is {@link OresFuture}. CompletionStage is + * accepted only as a host-interop input and is immediately normalized into an + * OresFuture so dependent Oreslang work never inherits a host callback + * execution policy.

+ */ +public final class OresFutures { + private OresFutures() { } + + public record Settled(T value, Throwable error) { + public boolean ok() { + return error == null; + } + } + + public static OresFuture> all(List awaitables) { + List> children = normalize(awaitables, "Futures.all"); + AtomicReferenceArray waiters = + new AtomicReferenceArray<>(children.size()); + Runnable detachWaiters = () -> detachAll(waiters); + + OresFuture> result = new OresFuture<>(() -> { + detachWaiters.run(); + children.forEach(child -> child.cancel(true)); + }); + if (children.isEmpty()) { + result.completeFromRuntime(List.of()); + return result; + } + + AtomicReferenceArray values = new AtomicReferenceArray<>(children.size()); + AtomicInteger remaining = new AtomicInteger(children.size()); + + for (int index = 0; index < children.size(); index++) { + int slot = index; + OresFuture.RuntimeWaiterRegistration registration = + children.get(index).whenCompleteRuntimeCancellable((value, failure) -> { + if (result.isDone()) { + detachWaiters.run(); + return; + } + if (failure != null) { + if (children.get(slot).isCancelled()) { + result.cancel(false); + } else { + result.failFromRuntime(OresFuture.unwrap(failure)); + } + detachWaiters.run(); + return; + } + values.set(slot, value); + if (remaining.decrementAndGet() == 0) { + ArrayList ordered = new ArrayList<>(children.size()); + for (int i = 0; i < children.size(); i++) { + @SuppressWarnings("unchecked") + T item = (T) values.get(i); + ordered.add(item); + } + result.completeFromRuntime(List.copyOf(ordered)); + detachWaiters.run(); + } + }); + waiters.set(slot, registration); + if (result.isDone()) { + OresFuture.RuntimeWaiterRegistration raced = + waiters.getAndSet(slot, null); + if (raced != null) raced.detach(); + } + } + return result; + } + + public static OresFuture race(List awaitables) { + List> children = normalize(awaitables, "Futures.race"); + if (children.isEmpty()) { + return OresFuture.failed( + new IllegalArgumentException("Futures.race requires at least one future")); + } + + AtomicReferenceArray waiters = + new AtomicReferenceArray<>(children.size()); + Runnable detachWaiters = () -> detachAll(waiters); + + OresFuture result = new OresFuture<>(() -> { + detachWaiters.run(); + children.forEach(child -> child.cancel(true)); + }); + + for (int index = 0; index < children.size(); index++) { + int slot = index; + OresFuture child = children.get(index); + OresFuture.RuntimeWaiterRegistration registration = + child.whenCompleteRuntimeCancellable((value, failure) -> { + if (result.isDone()) { + detachWaiters.run(); + return; + } + + if (failure == null) { + result.completeFromRuntime(value); + } else if (child.isCancelled()) { + result.cancel(false); + } else { + result.failFromRuntime(OresFuture.unwrap(failure)); + } + detachWaiters.run(); + }); + waiters.set(slot, registration); + if (result.isDone()) { + OresFuture.RuntimeWaiterRegistration raced = + waiters.getAndSet(slot, null); + if (raced != null) raced.detach(); + } + } + return result; + } + + public static OresFuture>> allSettled(List awaitables) { + List> children = normalize(awaitables, "Futures.all_settled"); + OresFuture>> result = new OresFuture<>( + () -> children.forEach(child -> child.cancel(true))); + if (children.isEmpty()) { + result.completeFromRuntime(List.of()); + return result; + } + + AtomicReferenceArray> settled = + new AtomicReferenceArray<>(children.size()); + AtomicInteger remaining = new AtomicInteger(children.size()); + + for (int index = 0; index < children.size(); index++) { + int slot = index; + children.get(index).whenCompleteRuntime((value, failure) -> { + settled.set( + slot, + new Settled<>( + failure == null ? value : null, + failure == null ? null : OresFuture.unwrap(failure))); + if (remaining.decrementAndGet() == 0) { + ArrayList> ordered = + new ArrayList<>(children.size()); + for (int i = 0; i < children.size(); i++) { + ordered.add(settled.get(i)); + } + result.completeFromRuntime(List.copyOf(ordered)); + } + }); + } + return result; + } + + private static void detachAll( + AtomicReferenceArray waiters) { + for (int i = 0; i < waiters.length(); i++) { + OresFuture.RuntimeWaiterRegistration registration = + waiters.getAndSet(i, null); + if (registration != null) registration.detach(); + } + } + + @SuppressWarnings("unchecked") + private static List> normalize( + List awaitables, + String operation) { + Objects.requireNonNull(awaitables, "awaitables"); + ArrayList> children = new ArrayList<>(awaitables.size()); + for (Object awaitable : awaitables) { + Objects.requireNonNull(awaitable, "future"); + if (awaitable instanceof OresFuture ores) { + children.add((OresFuture) ores); + } else if (awaitable instanceof CompletionStage stage) { + children.add(OresFuture.from((CompletionStage) stage)); + } else { + throw new IllegalArgumentException( + operation + " expects every list element to be a Future"); + } + } + return List.copyOf(children); + } +} diff --git a/src/main/java/dev/oreslang/runtime/OresMutex.java b/src/main/java/dev/oreslang/runtime/OresMutex.java new file mode 100644 index 00000000..a65c0301 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/OresMutex.java @@ -0,0 +1,953 @@ +package dev.oreslang.runtime; + +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.Semaphore; +import java.util.function.Function; + +/** + * Oreslang synchronization primitives. + * + *

{@link Local} is an actor/private-domain mutex. It deliberately does not + * use a JVM lock: the creating semantic execution domain owns it and recursive + * acquisition is rejected. Shared actors may migrate JVM worker threads without + * changing that domain. {@link Shared} is an explicit same-process + * shared-memory capability backed by a JVM synchronizer with poisoning and + * acquire/release ordering.

+ */ +public final class OresMutex { + private OresMutex() { } + + private static long saturatedNanos(Duration timeout) { + try { + return timeout.toNanos(); + } catch (ArithmeticException overflow) { + return Long.MAX_VALUE; + } + } + + public static Local local(T value) { + return new Local<>(value); + } + + public static Shared shared(T value) { + if (ActorRuntime.currentActorKind() == ActorRuntime.ActorKind.PRIVATE) { + throw new SecurityException("private actors cannot create SharedMutex"); + } + IsolatePolicy actorPolicy = ActorRuntime.currentActorPolicy(); + if (actorPolicy != null) { + actorPolicy.require( + IsolatePolicy.Capability.SHARED_MEMORY, + "SharedMutex.new"); + } + return new Shared<>(value); + } + + /** + * Runtime-owned future for a lexical MutexGuard. + * + *

Callers may observe or cancel it, but may not forge completion, + * timeout-complete it, or obtrude a value. Otherwise a queued lock request + * could be detached from the mutex's domain/permit accounting.

+ */ + public static final class GuardFuture extends CompletableFuture> { + private GuardFuture() { } + + private boolean completeFromRuntime(Guard guard) { + return super.complete(guard); + } + + private boolean failFromRuntime(Throwable failure) { + return super.completeExceptionally(failure); + } + + @Override + public boolean complete(Guard value) { + throw new UnsupportedOperationException("Mutex GuardFuture completion is runtime-owned"); + } + + @Override + public boolean completeExceptionally(Throwable ex) { + throw new UnsupportedOperationException("Mutex GuardFuture completion is runtime-owned"); + } + + @Override + public CompletableFuture> completeAsync( + java.util.function.Supplier> supplier) { + throw new UnsupportedOperationException("Mutex GuardFuture completion is runtime-owned"); + } + + @Override + public CompletableFuture> completeAsync( + java.util.function.Supplier> supplier, + java.util.concurrent.Executor executor) { + throw new UnsupportedOperationException("Mutex GuardFuture completion is runtime-owned"); + } + + @Override + public CompletableFuture> orTimeout(long timeout, TimeUnit unit) { + throw new UnsupportedOperationException("use Oreslang timed lock acquisition, not GuardFuture.orTimeout"); + } + + @Override + public CompletableFuture> completeOnTimeout( + Guard value, + long timeout, + TimeUnit unit) { + throw new UnsupportedOperationException("use Oreslang timed lock acquisition, not GuardFuture.completeOnTimeout"); + } + + @Override + public void obtrudeValue(Guard value) { + throw new UnsupportedOperationException("Mutex GuardFuture completion is runtime-owned"); + } + + @Override + public void obtrudeException(Throwable ex) { + throw new UnsupportedOperationException("Mutex GuardFuture completion is runtime-owned"); + } + } + + /** + * Runtime-owned aggregate contract used to inspect values protected by + * SharedMutex at actor-transport boundaries. Implementations must expose + * every transitively reachable child that can carry capabilities/state. + */ + public interface SharedState { + Iterable sharedStateChildren(); + } + + public sealed interface Lock permits Local, Shared { + Guard lock(); + Optional> tryLock(); + Optional> lockFor(Duration timeout); + CompletableFuture> lockAsync(); + CompletableFuture> lockAsyncFor(Duration timeout); + R withLock(Function body); + boolean isPoisoned(); + } + + /** + * Linear lock capability. Oreslang lowering owns guard release; guest code + * may release early, but the mutex itself has no public unlock operation. + */ + public interface Guard extends AutoCloseable { + T value(); + boolean released(); + void release(); + + /** + * Releases after an abnormal critical-section exit. Shared mutexes are + * poisoned; actor-local mutexes simply release because their state is + * confined to the failing actor/private domain. + */ + void fail(); + + @Override + default void close() { + release(); + } + } + + public static final class RecursiveLockException extends IllegalStateException { + public RecursiveLockException(String kind) { + super(kind + " is non-reentrant; recursive acquisition is forbidden"); + } + } + + public static final class WrongMutexDomainException extends IllegalStateException { + public WrongMutexDomainException(String message) { + super(message); + } + } + + public static final class PoisonedMutexException extends IllegalStateException { + public PoisonedMutexException() { + super("SharedMutex is poisoned because a previous critical section exited abnormally; call recover(...)"); + } + } + + public static final class LockTimeoutException extends IllegalStateException { + public LockTimeoutException(Duration timeout) { + super("mutex acquisition timed out after " + timeout); + } + } + + public static final class DeadlockDetectedException extends IllegalStateException { + public DeadlockDetectedException() { + super("SharedMutex wait would create a cross-mutex deadlock cycle"); + } + } + + /** + * Actor/private-domain mutex. There is no host lock and therefore no + * blocking path. Ownership follows the actor execution domain, not the + * transient JVM worker Thread. Outside actor execution, Thread identity is + * used as the local domain. + */ + public static final class Local implements Lock { + private final T value; + private final Object ownerDomain; + private boolean held; + + private Local(T value) { + this.value = value; + this.ownerDomain = ActorRuntime.currentExecutionDomain(); + } + + private void requireOwnerDomain() { + if (!Objects.equals(ActorRuntime.currentExecutionDomain(), ownerDomain)) { + throw new WrongMutexDomainException( + "Mutex is actor/private-domain state and cannot be accessed from another actor/execution domain; use SharedMutex"); + } + } + + @Override + public Guard lock() { + requireOwnerDomain(); + if (held) throw new RecursiveLockException("Mutex"); + held = true; + return new LocalGuard(); + } + + @Override + public Optional> tryLock() { + requireOwnerDomain(); + if (held) return Optional.empty(); + held = true; + return Optional.of(new LocalGuard()); + } + + @Override + public Optional> lockFor(Duration timeout) { + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isNegative()) throw new IllegalArgumentException("timeout must not be negative"); + return tryLock(); + } + + @Override + public CompletableFuture> lockAsync() { + GuardFuture future = new GuardFuture<>(); + try { + future.completeFromRuntime(lock()); + } catch (Throwable failure) { + future.failFromRuntime(failure); + } + return future; + } + + @Override + public CompletableFuture> lockAsyncFor(Duration timeout) { + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isNegative()) throw new IllegalArgumentException("timeout must not be negative"); + return lockAsync(); + } + + @Override + public R withLock(Function body) { + Objects.requireNonNull(body, "body"); + Guard guard = lock(); + try { + R result = body.apply(value); + guard.release(); + return result; + } catch (RuntimeException | Error failure) { + guard.fail(); + throw failure; + } + } + + @Override + public boolean isPoisoned() { + return false; + } + + private final class LocalGuard implements Guard { + private boolean released; + + @Override + public T value() { + requireOwnerDomain(); + if (released) throw new IllegalStateException("MutexGuard has been released"); + return value; + } + + @Override public boolean released() { return released; } + + @Override + public void release() { + requireOwnerDomain(); + if (released) return; + released = true; + held = false; + } + + @Override + public void fail() { + release(); + } + } + } + + /** + * Explicit same-process shared-memory mutex. This is intentionally not a + * distributed lock and must not be serialized across OS-process/Graal + * isolate boundaries. + */ + public static final class Shared implements Lock { + private static final int MAX_ASYNC_WAITERS = 8_192; + private static final int MAX_GLOBAL_ASYNC_WAITERS = 32_768; + private static final AtomicInteger GLOBAL_ASYNC_WAITERS = new AtomicInteger(); + private static final ConcurrentHashMap> WAITING_ON = + new ConcurrentHashMap<>(); + private static final ScheduledExecutorService ASYNC_TIMEOUTS = + Executors.newSingleThreadScheduledExecutor( + Thread.ofPlatform() + .daemon(true) + .name("ores-shared-mutex-timeouts") + .factory()); + + private final T value; + private final Semaphore permit = new Semaphore(1, true); + private final AtomicBoolean poisoned = new AtomicBoolean(); + private final AtomicInteger asyncWaiters = new AtomicInteger(); + private final AtomicReference currentOwnerDomain = new AtomicReference<>(); + private final Object asyncQueueLock = new Object(); + private final ArrayDeque asyncQueue = new ArrayDeque<>(); + private boolean preferAsyncHandoff = true; + /* + * Runtime ownership is publication-aware. A send may need to reserve + * ownership before mailbox admission so a receiver can never observe an + * unbound SharedMutex, but a failed admission must not permanently bind + * the handle. pendingPublications + publishedToRuntime provide that + * two-phase contract. + */ + private ActorRuntime owningRuntime; + private int pendingPublications; + private boolean publishedToRuntime; + private final Set activeDomains = ConcurrentHashMap.newKeySet(); + + private Shared(T value) { + this.value = value; + } + + synchronized boolean bindToRuntime(ActorRuntime runtime) { + Objects.requireNonNull(runtime, "runtime"); + if (owningRuntime == null) { + owningRuntime = runtime; + } else if (owningRuntime != runtime) { + return false; + } + publishedToRuntime = true; + return true; + } + + synchronized boolean reserveRuntimePublication(ActorRuntime runtime) { + Objects.requireNonNull(runtime, "runtime"); + if (owningRuntime == null) { + owningRuntime = runtime; + } else if (owningRuntime != runtime) { + return false; + } + pendingPublications++; + return true; + } + + synchronized void commitRuntimePublication(ActorRuntime runtime) { + Objects.requireNonNull(runtime, "runtime"); + if (owningRuntime != runtime || pendingPublications <= 0) { + throw new IllegalStateException("SharedMutex publication commit without matching reservation"); + } + pendingPublications--; + publishedToRuntime = true; + } + + synchronized void abortRuntimePublication(ActorRuntime runtime) { + Objects.requireNonNull(runtime, "runtime"); + if (owningRuntime != runtime || pendingPublications <= 0) { + throw new IllegalStateException("SharedMutex publication abort without matching reservation"); + } + pendingPublications--; + if (pendingPublications == 0 && !publishedToRuntime) { + owningRuntime = null; + } + } + + void inspectForTransport(java.util.function.Consumer inspection) { + Objects.requireNonNull(inspection, "inspection"); + final boolean acquired; + try { + acquired = permit.tryAcquire(0L, TimeUnit.NANOSECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new java.util.concurrent.CancellationException( + "SharedMutex transport inspection interrupted"); + } + if (!acquired) { + throw new IllegalStateException( + "SharedMutex cannot be published while locked or contended; retry after the current critical section completes"); + } + try { + inspection.accept(value); + } finally { + releasePermitOrHandoff(); + } + } + + private void requireActorAccess() { + if (ActorRuntime.currentActorKind() == ActorRuntime.ActorKind.PRIVATE) { + throw new SecurityException("private actors cannot access SharedMutex"); + } + IsolatePolicy actorPolicy = ActorRuntime.currentActorPolicy(); + if (actorPolicy != null) { + actorPolicy.require( + IsolatePolicy.Capability.SHARED_MEMORY, + "SharedMutex operation"); + } + + ActorRuntime current = ActorRuntime.currentActorRuntime(); + if (current != null && !bindToRuntime(current)) { + throw new WrongMutexDomainException( + "SharedMutex belongs to another ActorRuntime"); + } + } + + private void rejectBlockingActorAcquisition() { + requireActorAccess(); + if (ActorRuntime.inActorExecution()) { + throw new WrongMutexDomainException( + "blocking SharedMutex.lock()/lock_for()/with_lock() is forbidden during actor execution; use try_lock() or await lock_async()"); + } + } + + /** + * Reserve the semantic execution domain before waiting. This prevents a + * single actor from queueing a second acquisition behind itself and + * deadlocking, even when the actor migrates JVM workers. + */ + private Object reserveDomain(boolean tryOnly) { + requireActorAccess(); + Object domain = ActorRuntime.currentExecutionDomain(); + if (activeDomains.add(domain)) return domain; + if (tryOnly) return null; + throw new RecursiveLockException("SharedMutex"); + } + + private void releaseDomain(Object domain) { + if (domain != null) activeDomains.remove(domain); + } + + private void beginWait(Object domain) { + Shared existing = WAITING_ON.putIfAbsent(domain, this); + if (existing != null) { + if (existing == this) { + throw new RecursiveLockException("SharedMutex"); + } + throw new IllegalStateException( + "execution domain is already waiting on another SharedMutex"); + } + + if (wouldCreateDeadlock(domain)) { + WAITING_ON.remove(domain, this); + throw new DeadlockDetectedException(); + } + } + + private void endWait(Object domain) { + if (domain != null) WAITING_ON.remove(domain, this); + } + + private boolean wouldCreateDeadlock(Object requesterDomain) { + Object owner = currentOwnerDomain.get(); + Set seen = new HashSet<>(); + while (owner != null) { + if (Objects.equals(owner, requesterDomain)) return true; + if (!seen.add(owner)) return false; + Shared ownerWait = WAITING_ON.get(owner); + if (ownerWait == null) return false; + owner = ownerWait.currentOwnerDomain.get(); + } + return false; + } + + private void markOwner(Object domain) { + if (!currentOwnerDomain.compareAndSet(null, domain)) { + throw new IllegalStateException("SharedMutex permit acquired while another owner is recorded"); + } + } + + private void clearOwner(Object domain) { + if (domain == null) return; + if (!currentOwnerDomain.compareAndSet(domain, null)) { + Object recorded = currentOwnerDomain.get(); + if (recorded != null) { + throw new IllegalStateException( + "SharedMutex owner-domain accounting mismatch"); + } + } + } + + private int asyncWaiterLimit() { + IsolatePolicy policy = ActorRuntime.currentActorPolicy(); + return policy == null + ? MAX_ASYNC_WAITERS + : Math.min(MAX_ASYNC_WAITERS, policy.maxMailboxMessages()); + } + + private static boolean reserveWaiter(AtomicInteger counter, int limit) { + while (true) { + int current = counter.get(); + if (current >= limit) return false; + if (counter.compareAndSet(current, current + 1)) return true; + } + } + + private boolean reserveAsyncWaiter(int limit) { + return reserveWaiter(asyncWaiters, limit); + } + + private static void releaseWaiter(AtomicInteger counter, String scope) { + int remaining = counter.decrementAndGet(); + if (remaining < 0) { + counter.incrementAndGet(); + throw new IllegalStateException("SharedMutex " + scope + " async waiter accounting underflow"); + } + } + + private void releaseAsyncWaiter() { + releaseWaiter(asyncWaiters, "per-mutex"); + } + + private static void releaseGlobalAsyncWaiter() { + releaseWaiter(GLOBAL_ASYNC_WAITERS, "global"); + } + + private static GuardFuture failedGuardFuture(Throwable failure) { + GuardFuture future = new GuardFuture<>(); + future.failFromRuntime(failure); + return future; + } + + private final class AsyncWaiter { + private final Object ownerDomain; + private final boolean enforceOwnerDomain; + private final GuardFuture future; + + private AsyncWaiter( + Object ownerDomain, + boolean enforceOwnerDomain, + GuardFuture future) { + this.ownerDomain = ownerDomain; + this.enforceOwnerDomain = enforceOwnerDomain; + this.future = future; + } + } + + /** + * Releases the physical permit or directly transfers it to a queued + * async waiter. Direct handoff avoids one virtual thread per waiter. + * + *

When both blocking host waiters and async waiters exist, alternate + * preference so neither class can monopolize release handoff.

+ */ + private void releasePermitOrHandoff() { + while (true) { + AsyncWaiter waiter = null; + synchronized (asyncQueueLock) { + while (!asyncQueue.isEmpty()) { + AsyncWaiter candidate = asyncQueue.removeFirst(); + if (!candidate.future.isDone()) { + waiter = candidate; + break; + } + // Cancellation/completion can race with dequeue. Once + // removed from the queue, this path owns wait/domain cleanup. + endWait(candidate.ownerDomain); + releaseDomain(candidate.ownerDomain); + } + + if (waiter == null) { + permit.release(); + return; + } + + if (permit.hasQueuedThreads() && !preferAsyncHandoff) { + asyncQueue.addFirst(waiter); + preferAsyncHandoff = true; + permit.release(); + return; + } + if (permit.hasQueuedThreads()) preferAsyncHandoff = false; + } + + if (poisoned.get()) { + endWait(waiter.ownerDomain); + releaseDomain(waiter.ownerDomain); + waiter.future.failFromRuntime(new PoisonedMutexException()); + continue; + } + + endWait(waiter.ownerDomain); + markOwner(waiter.ownerDomain); + SharedGuard guard = new SharedGuard( + waiter.ownerDomain, + waiter.enforceOwnerDomain); + if (waiter.future.completeFromRuntime(guard)) return; + + // Cancellation won after dequeue but before completion. + clearOwner(waiter.ownerDomain); + releaseDomain(waiter.ownerDomain); + } + } + + private Guard checkedGuardAfterAcquire(Object ownerDomain, boolean enforceOwnerDomain) { + endWait(ownerDomain); + if (poisoned.get()) { + releaseDomain(ownerDomain); + releasePermitOrHandoff(); + throw new PoisonedMutexException(); + } + try { + markOwner(ownerDomain); + } catch (RuntimeException | Error failure) { + releaseDomain(ownerDomain); + releasePermitOrHandoff(); + throw failure; + } + return new SharedGuard(ownerDomain, enforceOwnerDomain); + } + + @Override + public Guard lock() { + rejectBlockingActorAcquisition(); + Object ownerDomain = reserveDomain(false); + try { + beginWait(ownerDomain); + permit.acquire(); + } catch (InterruptedException interrupted) { + endWait(ownerDomain); + releaseDomain(ownerDomain); + Thread.currentThread().interrupt(); + throw new java.util.concurrent.CancellationException("SharedMutex lock wait interrupted"); + } catch (RuntimeException | Error failure) { + endWait(ownerDomain); + releaseDomain(ownerDomain); + throw failure; + } + return checkedGuardAfterAcquire(ownerDomain, true); + } + + @Override + public Optional> tryLock() { + Object ownerDomain = reserveDomain(true); + if (ownerDomain == null) return Optional.empty(); + try { + if (!permit.tryAcquire(0L, TimeUnit.NANOSECONDS)) { + releaseDomain(ownerDomain); + return Optional.empty(); + } + return Optional.of(checkedGuardAfterAcquire(ownerDomain, true)); + } catch (InterruptedException interrupted) { + releaseDomain(ownerDomain); + Thread.currentThread().interrupt(); + throw new java.util.concurrent.CancellationException( + "SharedMutex try_lock interrupted"); + } + } + + @Override + public Optional> lockFor(Duration timeout) { + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isNegative()) throw new IllegalArgumentException("timeout must not be negative"); + rejectBlockingActorAcquisition(); + if (timeout.isZero()) return tryLock(); + Object ownerDomain = reserveDomain(true); + if (ownerDomain == null) return Optional.empty(); + try { + beginWait(ownerDomain); + if (!permit.tryAcquire(saturatedNanos(timeout), TimeUnit.NANOSECONDS)) { + endWait(ownerDomain); + releaseDomain(ownerDomain); + return Optional.empty(); + } + return Optional.of(checkedGuardAfterAcquire(ownerDomain, true)); + } catch (InterruptedException interrupted) { + endWait(ownerDomain); + releaseDomain(ownerDomain); + Thread.currentThread().interrupt(); + throw new java.util.concurrent.CancellationException("SharedMutex lock wait interrupted"); + } catch (RuntimeException | Error failure) { + endWait(ownerDomain); + releaseDomain(ownerDomain); + throw failure; + } + } + + @Override + public CompletableFuture> lockAsync() { + Object ownerDomain = reserveDomain(false); + int waiterLimit = asyncWaiterLimit(); + if (!reserveAsyncWaiter(waiterLimit)) { + releaseDomain(ownerDomain); + return failedGuardFuture(new IllegalStateException( + "SharedMutex async waiter limit exceeded: " + waiterLimit)); + } + if (!reserveWaiter(GLOBAL_ASYNC_WAITERS, MAX_GLOBAL_ASYNC_WAITERS)) { + releaseAsyncWaiter(); + releaseDomain(ownerDomain); + return failedGuardFuture(new IllegalStateException( + "SharedMutex process-wide async waiter limit exceeded: " + MAX_GLOBAL_ASYNC_WAITERS)); + } + + boolean enforceOwnerDomain = ActorRuntime.inActorExecution(); + GuardFuture future = new GuardFuture<>(); + AsyncWaiter waiter = new AsyncWaiter( + ownerDomain, + enforceOwnerDomain, + future); + + future.whenComplete((ignored, failure) -> { + boolean removed; + synchronized (asyncQueueLock) { + removed = asyncQueue.remove(waiter); + } + if (removed) { + endWait(ownerDomain); + releaseDomain(ownerDomain); + } + releaseAsyncWaiter(); + releaseGlobalAsyncWaiter(); + }); + + boolean acquired = false; + boolean poisonedNow = false; + Throwable waitFailure = null; + synchronized (asyncQueueLock) { + if (poisoned.get()) { + poisonedNow = true; + } else { + boolean waitRegistered = false; + try { + // The timed zero-duration form honors a fair + // Semaphore's queue order. Untimed tryAcquire() is + // explicitly allowed to barge ahead of queued host + // waiters, which would violate our mixed-waiter + // fairness contract. + if (permit.tryAcquire(0L, TimeUnit.NANOSECONDS)) { + acquired = true; + } else { + beginWait(ownerDomain); + waitRegistered = true; + asyncQueue.addLast(waiter); + } + } catch (InterruptedException interrupted) { + if (waitRegistered) endWait(ownerDomain); + Thread.currentThread().interrupt(); + waitFailure = new java.util.concurrent.CancellationException( + "SharedMutex async acquisition interrupted"); + } catch (RuntimeException | Error failure) { + if (waitRegistered) endWait(ownerDomain); + waitFailure = failure; + } + } + } + + if (waitFailure != null) { + releaseDomain(ownerDomain); + future.failFromRuntime(waitFailure); + } else if (poisonedNow) { + releaseDomain(ownerDomain); + future.failFromRuntime(new PoisonedMutexException()); + } else if (acquired) { + try { + markOwner(ownerDomain); + SharedGuard guard = new SharedGuard( + ownerDomain, + enforceOwnerDomain); + if (!future.completeFromRuntime(guard)) { + guard.releaseFromRuntime(); + } + } catch (RuntimeException | Error failure) { + releaseDomain(ownerDomain); + releasePermitOrHandoff(); + future.failFromRuntime(failure); + } + } + + return future; + } + + @Override + public CompletableFuture> lockAsyncFor(Duration timeout) { + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isNegative()) throw new IllegalArgumentException("timeout must not be negative"); + + CompletableFuture> pending = lockAsync(); + if (!(pending instanceof GuardFuture)) return pending; + + @SuppressWarnings("unchecked") + GuardFuture future = (GuardFuture) pending; + if (future.isDone()) return future; + + long timeoutNanos = saturatedNanos(timeout); + if (timeoutNanos == 0L) { + future.failFromRuntime(new LockTimeoutException(timeout)); + return future; + } + + final java.util.concurrent.ScheduledFuture timeoutTask; + try { + timeoutTask = ASYNC_TIMEOUTS.schedule( + () -> future.failFromRuntime(new LockTimeoutException(timeout)), + timeoutNanos, + TimeUnit.NANOSECONDS); + } catch (RuntimeException schedulingFailure) { + future.failFromRuntime(schedulingFailure); + return future; + } + future.whenComplete((ignored, failure) -> timeoutTask.cancel(false)); + return future; + } + + @Override + public R withLock(Function body) { + Objects.requireNonNull(body, "body"); + Guard guard = lock(); + try { + R result = body.apply(value); + guard.release(); + return result; + } catch (RuntimeException | Error failure) { + guard.fail(); + throw failure; + } + } + + public R recover(Function repair) { + Objects.requireNonNull(repair, "repair"); + Object ownerDomain = reserveDomain(false); + boolean acquired = false; + boolean waiting = false; + try { + if (ActorRuntime.inActorExecution()) { + // Recovery is expected to happen after the poisoning guard + // released its permit. Never block an actor if another + // recovery attempt is already in progress. + acquired = permit.tryAcquire(); + if (!acquired) { + throw new WrongMutexDomainException( + "SharedMutex recovery is busy; actor recovery never blocks, retry from a later mailbox turn"); + } + } else { + try { + beginWait(ownerDomain); + waiting = true; + permit.acquire(); + acquired = true; + endWait(ownerDomain); + waiting = false; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new java.util.concurrent.CancellationException( + "SharedMutex recovery wait interrupted"); + } + } + + markOwner(ownerDomain); + if (!poisoned.get()) { + throw new IllegalStateException( + "SharedMutex is not poisoned; recover(...) is only for repairing poisoned state"); + } + try { + R result = repair.apply(value); + poisoned.set(false); + return result; + } catch (RuntimeException | Error failure) { + poisoned.set(true); + throw failure; + } + } finally { + if (waiting) endWait(ownerDomain); + if (acquired) { + clearOwner(ownerDomain); + releasePermitOrHandoff(); + } + releaseDomain(ownerDomain); + } + } + + @Override + public boolean isPoisoned() { + requireActorAccess(); + return poisoned.get(); + } + + /** Runtime transport inspects the protected payload for explicit capability/reference validation. */ + Object transportValue() { + return value; + } + + private final class SharedGuard implements Guard { + private final Object ownerDomain; + private final boolean enforceOwnerDomain; + private final AtomicBoolean released = new AtomicBoolean(); + + private SharedGuard(Object ownerDomain, boolean enforceOwnerDomain) { + this.ownerDomain = ownerDomain; + this.enforceOwnerDomain = enforceOwnerDomain; + } + + private void requireOwnerDomain() { + if (enforceOwnerDomain + && !Objects.equals(ActorRuntime.currentExecutionDomain(), ownerDomain)) { + throw new WrongMutexDomainException( + "SharedMutex guard belongs to another actor/execution domain"); + } + } + + @Override + public T value() { + requireOwnerDomain(); + if (released()) throw new IllegalStateException("MutexGuard has been released"); + return value; + } + + @Override public boolean released() { return released.get(); } + + @Override + public void release() { + requireOwnerDomain(); + releaseFromRuntime(); + } + + private void releaseFromRuntime() { + if (!released.compareAndSet(false, true)) return; + clearOwner(ownerDomain); + releaseDomain(ownerDomain); + releasePermitOrHandoff(); + } + + @Override + public void fail() { + requireOwnerDomain(); + if (!released.compareAndSet(false, true)) return; + poisoned.set(true); + clearOwner(ownerDomain); + releaseDomain(ownerDomain); + releasePermitOrHandoff(); + } + } + } +} diff --git a/src/main/java/dev/oreslang/runtime/OresNull.java b/src/main/java/dev/oreslang/runtime/OresNull.java new file mode 100644 index 00000000..14a4adf9 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/OresNull.java @@ -0,0 +1,24 @@ +package dev.oreslang.runtime; + +import com.oracle.truffle.api.interop.InteropLibrary; +import com.oracle.truffle.api.interop.TruffleObject; +import com.oracle.truffle.api.library.ExportLibrary; +import com.oracle.truffle.api.library.ExportMessage; + +/** Guest-language null/void value safe to expose through the Polyglot API. */ +@ExportLibrary(InteropLibrary.class) +public final class OresNull implements TruffleObject { + public static final OresNull INSTANCE = new OresNull(); + + private OresNull() { } + + @ExportMessage + boolean isNull() { + return true; + } + + @Override + public String toString() { + return "null"; + } +} diff --git a/src/main/java/dev/oreslang/runtime/OresScheduler.java b/src/main/java/dev/oreslang/runtime/OresScheduler.java new file mode 100644 index 00000000..86ca75b7 --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/OresScheduler.java @@ -0,0 +1,655 @@ +package dev.oreslang.runtime; + +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; + +/** + * Scheduler affinity for ordinary Oreslang tasks. + * + *

An Oreslang task owns one scheduler for its complete logical lifetime. + * Physical carrier-thread identity is intentionally not stable: after an + * {@code await}, the continuation may resume on any carrier owned by the same + * scheduler.

+ * + *

Futures do not own schedulers. A suspended task records the scheduler that + * was executing it when it reached {@code await}; Future completion only makes + * that task runnable again. Producer, timer, I/O, JNI, and other completion + * threads never execute the task continuation directly.

+ * + *

The compiler lowers an async callable to {@link Task}: a small resumable + * state machine. Returning {@link Await} suspends the task. Returning + * {@link Done} completes it.

+ */ +public final class OresScheduler implements AutoCloseable { + private static final AtomicLong NEXT_ID = new AtomicLong(); + private static final AtomicLong NEXT_DISPATCH_ID = new AtomicLong(); + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private static final ThreadLocal CURRENT_DISPATCH_ID = new ThreadLocal<>(); + private static final ThreadLocal CURRENT_TASK_DOMAIN = new ThreadLocal<>(); + private static final ThreadLocal SCHEDULER_CARRIER = new ThreadLocal<>(); + private static final int DEFAULT_QUEUE_CAPACITY = 65_536; + + @FunctionalInterface + interface TurnExecutor { + void execute(Runnable turn); + } + + /** One compiler-generated async state-machine turn. */ + @FunctionalInterface + public interface Task { + Step resume(Resume resume) throws Exception; + } + + /** Result of one resumable task turn. */ + public sealed interface Step permits Done, Await { } + + /** The async task has produced its final value. */ + public record Done(T value) implements Step { } + + /** + * The async task is suspended on a Future. The scheduler, not the Future, + * owns the continuation placement. + */ + public record Await(OresFuture future) implements Step { + public Await { + Objects.requireNonNull(future, "future"); + } + } + + /** + * Input delivered to a compiler-generated state machine when it starts or + * resumes after an await. + */ + public record Resume(boolean initial, Object value, Throwable failure) { + private static Resume initialResume() { + return new Resume(true, null, null); + } + + private static Resume completed(Object value, Throwable failure) { + return new Resume(false, value, failure); + } + } + + private final String name; + private final int parallelism; + private final Executor executor; + private final ExecutorService ownedExecutor; + private final TurnExecutor turnExecutor; + private final Set> tasks = ConcurrentHashMap.newKeySet(); + private final AtomicBoolean closed = new AtomicBoolean(); + + /** + * Create a user-owned scheduler with exactly {@code parallelism} carrier + * threads and a bounded ready queue. + */ + public OresScheduler(int parallelism) { + this(parallelism, DEFAULT_QUEUE_CAPACITY, Runnable::run); + } + + public OresScheduler(int parallelism, int queueCapacity) { + this(parallelism, queueCapacity, Runnable::run); + } + + private OresScheduler( + int parallelism, + int queueCapacity, + TurnExecutor turnExecutor) { + if (parallelism <= 0) { + throw new IllegalArgumentException("scheduler parallelism must be positive"); + } + if (queueCapacity <= 0) { + throw new IllegalArgumentException("scheduler queue capacity must be positive"); + } + + this.name = "ores-user-scheduler-" + NEXT_ID.incrementAndGet(); + this.parallelism = parallelism; + this.turnExecutor = Objects.requireNonNull(turnExecutor, "turnExecutor"); + + AtomicInteger carrierId = new AtomicInteger(); + ThreadFactory factory = task -> Thread.ofPlatform() + .daemon(true) + .name(name + "-carrier-" + carrierId.getAndIncrement()) + .unstarted(() -> { + SCHEDULER_CARRIER.set(Boolean.TRUE); + try { + task.run(); + } finally { + SCHEDULER_CARRIER.remove(); + } + }); + ThreadPoolExecutor pool = new ThreadPoolExecutor( + parallelism, + parallelism, + 0L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(queueCapacity), + factory, + new ThreadPoolExecutor.AbortPolicy()); + pool.prestartAllCoreThreads(); + this.executor = pool; + this.ownedExecutor = pool; + } + + private OresScheduler( + String name, + int parallelism, + Executor executor, + ExecutorService ownedExecutor, + TurnExecutor turnExecutor) { + this.name = Objects.requireNonNull(name, "name"); + if (parallelism <= 0) { + throw new IllegalArgumentException("scheduler parallelism must be positive"); + } + this.parallelism = parallelism; + this.executor = Objects.requireNonNull(executor, "executor"); + this.ownedExecutor = ownedExecutor; + this.turnExecutor = Objects.requireNonNull(turnExecutor, "turnExecutor"); + } + + /** + * Runtime-owned scheduler facade over an existing VM executor. Closing the + * facade cancels its tasks but does not shut down the shared VM executor. + */ + static OresScheduler runtimeOwned( + String name, + int parallelism, + Executor executor) { + return runtimeOwned(name, parallelism, executor, Runnable::run); + } + + /** + * Runtime-owned scheduler whose physical carrier dispatch is distinct from + * guest-turn admission. The carrier executor owns only where the task runs; + * the turn executor owns the entered language/context boundary. + * + *

This separation is critical for await completion publication: + * {@link TaskRunner#afterCarrierTurn()} runs after {@code turnExecutor} + * returns, so a terminal Future cannot become externally visible until the + * guest turn has completely left its Truffle context.

+ */ + static OresScheduler runtimeOwned( + String name, + int parallelism, + Executor executor, + TurnExecutor turnExecutor) { + return new OresScheduler( + name, + parallelism, + executor, + null, + turnExecutor); + } + + /** + * Context-owned scheduler with private carriers. Each guest turn is wrapped + * by the owning language context before scheduler binding is installed. + */ + static OresScheduler managed( + int parallelism, + TurnExecutor turnExecutor) { + return new OresScheduler( + parallelism, + DEFAULT_QUEUE_CAPACITY, + turnExecutor); + } + + /** True only on private carriers owned by user-created OresSchedulers. */ + public static boolean isSchedulerCarrierThread() { + return Boolean.TRUE.equals(SCHEDULER_CARRIER.get()); + } + + public String name() { + return name; + } + + public int parallelism() { + return parallelism; + } + + public boolean isClosed() { + return closed.get(); + } + + /** Scheduler currently executing this Ores task turn, if any. */ + public static OresScheduler current() { + return CURRENT.get(); + } + + public static OresScheduler requireCurrent() { + OresScheduler scheduler = CURRENT.get(); + if (scheduler == null) { + throw new IllegalStateException( + "operation requires an executing OresScheduler task"); + } + return scheduler; + } + + /** + * Stable logical execution-domain token for the currently running + * scheduler task, or {@code null} outside a scheduler task. + * + *

The token survives await/resume carrier migration and is intentionally + * distinct for concurrent tasks sharing the same OresScheduler.

+ */ + public static Object currentTaskDomain() { + return CURRENT_TASK_DOMAIN.get(); + } + + /** + * Identifier for the current scheduler dispatch turn, or {@code 0} outside + * an OresScheduler dispatch. A continuation resumed after {@code await} + * always observes a different dispatch id, even when the scheduler chooses + * the same physical carrier thread immediately. + */ + public static long currentDispatchId() { + Long id = CURRENT_DISPATCH_ID.get(); + return id == null ? 0L : id; + } + + public static Step done(T value) { + return new Done<>(value); + } + + public static Step await(OresFuture future) { + return new Await<>(Objects.requireNonNull(future, "future")); + } + + /** + * Start one compiler-lowered async task on this scheduler. + * + *

The returned Future represents the whole task. Awaiting it from another + * scheduler does not move the waiter onto this scheduler.

+ */ + public OresFuture start(Task task) { + Objects.requireNonNull(task, "task"); + ensureOpen(); + + TaskRunner runner = new TaskRunner<>(task); + tasks.add(runner); + try { + runner.scheduleInitial(); + } catch (RuntimeException | Error failure) { + runner.failBeforeStart(failure); + } + return runner.completion; + } + + /** + * Runtime/host bridge for non-suspending work. Source-level synchronous + * lambdas may lower to this path. + */ + public OresFuture startSync(Callable task) { + Objects.requireNonNull(task, "task"); + AtomicBoolean entered = new AtomicBoolean(); + return start(resume -> { + if (!resume.initial() || !entered.compareAndSet(false, true)) { + throw new IllegalStateException( + "synchronous scheduler task was resumed more than once"); + } + return done(task.call()); + }); + } + + /** + * Bind an already-admitted VM CONTROL/root turn to this scheduler without + * hopping threads. Used by the legacy root-task bridge while source-level + * main/async lowering moves to {@link #start(Task)}. + */ + void runBound(Runnable turn) { + Objects.requireNonNull(turn, "turn"); + ensureOpen(); + OresScheduler prior = CURRENT.get(); + Long priorDispatch = CURRENT_DISPATCH_ID.get(); + CURRENT.set(this); + CURRENT_DISPATCH_ID.set(NEXT_DISPATCH_ID.incrementAndGet()); + try { + turn.run(); + } finally { + if (priorDispatch == null) { + CURRENT_DISPATCH_ID.remove(); + } else { + CURRENT_DISPATCH_ID.set(priorDispatch); + } + if (prior == null) { + CURRENT.remove(); + } else { + CURRENT.set(prior); + } + } + } + + private void executeTurn(Runnable turn, Runnable afterTurn) { + Objects.requireNonNull(turn, "turn"); + Objects.requireNonNull(afterTurn, "afterTurn"); + ensureOpen(); + executor.execute(() -> { + try { + turnExecutor.execute(() -> runBound(turn)); + } finally { + afterTurn.run(); + } + }); + } + + private void ensureOpen() { + if (closed.get()) { + throw new RejectedExecutionException( + "OresScheduler " + name + " is closed"); + } + } + + @Override + public void close() { + if (CURRENT.get() == this) { + throw new IllegalStateException( + "OresScheduler cannot be closed from one of its own task turns; " + + "close it from an outside/root scheduler task"); + } + if (!closed.compareAndSet(false, true)) return; + + for (TaskRunner task : Set.copyOf(tasks)) { + task.cancelFromSchedulerClose(); + } + tasks.clear(); + + if (ownedExecutor != null) { + ownedExecutor.shutdownNow(); + if (CURRENT.get() != this) { + try { + if (!ownedExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + throw new IllegalStateException( + "OresScheduler " + name + " carriers did not terminate"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new java.util.concurrent.CancellationException( + "interrupted while closing OresScheduler " + name); + } + } + } + } + + private final class TaskRunner implements BiConsumer { + private static final int NEW = 0; + private static final int QUEUED = 1; + private static final int RUNNING = 2; + private static final int WAITING = 3; + private static final int TERMINAL = 4; + + private final Task task; + private final AtomicInteger phase = new AtomicInteger(NEW); + private final AtomicBoolean executing = new AtomicBoolean(); + private final AtomicReference pendingResume = + new AtomicReference<>(Resume.initialResume()); + private final AtomicReference> terminalOutcome = + new AtomicReference<>(); + private final AtomicReference> + activeAwaitRegistration = new AtomicReference<>(); + private final OresFuture completion; + + private TaskRunner(Task task) { + this.task = task; + this.completion = new OresFuture<>(this::cancelFromFuture); + } + + private void scheduleInitial() { + if (!phase.compareAndSet(NEW, QUEUED)) { + throw new IllegalStateException("scheduler task was already started"); + } + enqueueTurn(); + } + + private void enqueueTurn() { + try { + executeTurn(this::runTurn, this::afterCarrierTurn); + } catch (RuntimeException | Error rejected) { + failTerminal(rejected); + throw rejected; + } + } + + private void runTurn() { + if (!phase.compareAndSet(QUEUED, RUNNING)) return; + if (!executing.compareAndSet(false, true)) { + failTerminal(new IllegalStateException( + "OresScheduler task execution lease violation")); + return; + } + + Object priorTaskDomain = CURRENT_TASK_DOMAIN.get(); + CURRENT_TASK_DOMAIN.set(this); + try { + detachActiveAwaitRegistration(); + Resume resume = pendingResume.getAndSet(null); + if (resume == null) { + failTerminal(new IllegalStateException( + "scheduler resumed a task without an await result")); + return; + } + + final Step step; + try { + step = Objects.requireNonNull( + task.resume(resume), + "OresScheduler task returned null Step"); + } catch (Throwable failure) { + failTerminal(failure); + if (failure instanceof VirtualMachineError fatal) throw fatal; + if (failure instanceof ThreadDeath fatal) throw fatal; + if (failure instanceof LinkageError fatal) throw fatal; + return; + } + + if (step instanceof Done done) { + @SuppressWarnings("unchecked") + T value = (T) done.value(); + finish(value); + return; + } + + if (step instanceof Await await) { + armAwait(await.future()); + return; + } + + failTerminal(new IllegalStateException( + "unknown OresScheduler task step " + step.getClass().getName())); + } finally { + if (priorTaskDomain == null) { + CURRENT_TASK_DOMAIN.remove(); + } else { + CURRENT_TASK_DOMAIN.set(priorTaskDomain); + } + } + } + + /** + * Runs only after the scheduler binding has been removed and the + * carrier has completely unwound the logical guest turn. Publishing a + * task Future earlier can let a host close its Polyglot Context while + * this carrier is still executing guest continuation code. + */ + private void afterCarrierTurn() { + executing.set(false); + publishTerminalIfReady(); + scheduleReadyResume(); + } + + private void armAwait(OresFuture awaited) { + if (!phase.compareAndSet(RUNNING, WAITING)) { + return; + } + + try { + // Hot path: terminal Futures are immutable. Observe their + // already-published terminal state directly and enqueue the + // continuation for a fresh dispatch without allocating a + // waiter, queue node, registration, or capturing callback. + // + // This deliberately does NOT resume inline. executing is still + // true until afterCarrierTurn(), so deliverAwaitCompletion() + // can only make the task ready; the scheduler re-enters it + // through a later dispatch after the current stack unwinds. + Object terminal = awaited.runtimeTerminalStateOrNull(); + if (terminal != null) { + deliverAwaitCompletion( + OresFuture.runtimeTerminalValue(terminal), + OresFuture.runtimeTerminalFailure(terminal)); + return; + } + + // Pending path: TaskRunner itself is the reusable callback + // target, avoiding a new captured lambda for every await. The + // detachable registration remains per suspension so scheduler + // close/task cancellation can sever retention safely. + OresFuture.RuntimeWaiterRegistration registration = + awaited.whenCompleteRuntimeCancellable(this); + + OresFuture.RuntimeWaiterRegistration previous = + activeAwaitRegistration.getAndSet(registration); + if (previous != null) previous.detach(); + + // Settlement may race between the terminal observation above + // and waiter publication. whenCompleteRuntimeCancellable() + // handles that race and may call accept(...) synchronously. + // If it did, clear the already-claimed registration now. + if (phase.get() != WAITING || pendingResume.get() != null) { + detachActiveAwaitRegistration(); + } + } catch (RuntimeException | Error registrationFailure) { + failTerminal(registrationFailure); + } + } + + @Override + public void accept(Object value, Throwable failure) { + deliverAwaitCompletion(value, failure); + } + + private void deliverAwaitCompletion(Object value, Throwable failure) { + if (phase.get() == TERMINAL) return; + + Resume resume = Resume.completed( + value, + failure == null ? null : OresFuture.unwrap(failure)); + if (!pendingResume.compareAndSet(null, resume)) { + failTerminal(new IllegalStateException( + "await delivered more than one resume to the same task")); + return; + } + + if (phase.get() == TERMINAL) { + pendingResume.compareAndSet(resume, null); + return; + } + scheduleReadyResume(); + } + + /** + * Producer completion may happen on any thread. It may only make this + * task runnable. The continuation itself executes after the prior turn + * released its execution lease and only through this scheduler. + */ + private void scheduleReadyResume() { + if (executing.get()) return; + if (pendingResume.get() == null) return; + if (!phase.compareAndSet(WAITING, QUEUED)) return; + + try { + enqueueTurn(); + } catch (RejectedExecutionException rejected) { + // enqueueTurn already failed the task. + } + } + + private void detachActiveAwaitRegistration() { + OresFuture.RuntimeWaiterRegistration registration = + activeAwaitRegistration.getAndSet(null); + if (registration != null) registration.detach(); + } + + private void finish(T value) { + detachActiveAwaitRegistration(); + if (!phase.compareAndSet(RUNNING, TERMINAL)) { + return; + } + terminalOutcome.set(new TerminalSuccess<>(value)); + tasks.remove(this); + // afterCarrierTurn() publishes only after the carrier has fully + // unwound runBound(...). + } + + private void failTerminal(Throwable failure) { + Objects.requireNonNull(failure, "failure"); + detachActiveAwaitRegistration(); + int observed; + do { + observed = phase.get(); + if (observed == TERMINAL) return; + } while (!phase.compareAndSet(observed, TERMINAL)); + + terminalOutcome.compareAndSet(null, new TerminalFailure<>(failure)); + tasks.remove(this); + if (!executing.get()) { + publishTerminalIfReady(); + } + } + + private void publishTerminalIfReady() { + if (phase.get() != TERMINAL || completion.isDone()) return; + TerminalOutcome outcome = terminalOutcome.get(); + if (outcome instanceof TerminalSuccess success) { + @SuppressWarnings("unchecked") + T value = (T) success.value(); + completion.completeFromRuntime(value); + } else if (outcome instanceof TerminalFailure failure) { + completion.failFromRuntime(failure.failure()); + } + } + + private void failBeforeStart(Throwable failure) { + failTerminal(failure); + } + + private void cancelFromFuture() { + detachActiveAwaitRegistration(); + int observed; + do { + observed = phase.get(); + if (observed == TERMINAL) return; + } while (!phase.compareAndSet(observed, TERMINAL)); + tasks.remove(this); + pendingResume.set(null); + } + + private void cancelFromSchedulerClose() { + completion.cancel(false); + } + } + + private sealed interface TerminalOutcome + permits TerminalSuccess, TerminalFailure { } + + private record TerminalSuccess(T value) implements TerminalOutcome { } + + private record TerminalFailure(Throwable failure) + implements TerminalOutcome { + private TerminalFailure { + Objects.requireNonNull(failure, "failure"); + } + } +} diff --git a/src/main/java/dev/oreslang/runtime/RuntimeGarbageCollector.java b/src/main/java/dev/oreslang/runtime/RuntimeGarbageCollector.java new file mode 100644 index 00000000..09fbf05f --- /dev/null +++ b/src/main/java/dev/oreslang/runtime/RuntimeGarbageCollector.java @@ -0,0 +1,418 @@ +package dev.oreslang.runtime; + +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Secondary runtime reclamation for host/interop resources that are not governed + * solely by Oreslang ownership. Guest memory safety remains ownership/borrow + * based; this collector cannot make an invalid ownership program valid. + */ +public final class RuntimeGarbageCollector implements AutoCloseable { + private static final Duration DEFAULT_PERIOD = Duration.ofSeconds(30); + private static final Duration DEFAULT_MIN_PROCESS_GC_INTERVAL = Duration.ofSeconds(5); + private static final int DEFAULT_MAX_TRACKED = 100_000; + private static final int DEFAULT_MAX_TRACKED_PER_ACTOR = 4_096; + private static final int DEFAULT_MAX_ACTOR_SWEEP_ENTRIES = 256; + private static final Object PROCESS_DOMAIN = new Object(); + private static final ScheduledExecutorService SWEEP_TIMER = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "ores-gc-timer"); + thread.setDaemon(true); + return thread; + }); + + public record CollectionReport( + String scope, + long collection, + int trackedBefore, + int inspected, + int cleaned, + int trackedAfter, + int cleanupFailures, + boolean jvmGcRequested) { + public Map asMap() { + return Map.of( + "scope", scope, + "collection", collection, + "tracked_before", trackedBefore, + "inspected", inspected, + "cleaned", cleaned, + "tracked_after", trackedAfter, + "cleanup_failures", cleanupFailures, + "jvm_gc_requested", jvmGcRequested); + } + } + + private static final class TrackedCleanup extends WeakReference { + private final Object domain; + private final Runnable cleanup; + private final AtomicBoolean cleaning = new AtomicBoolean(); + private final AtomicBoolean cleaned = new AtomicBoolean(); + + private TrackedCleanup( + Object owner, + Object domain, + Runnable cleanup, + ReferenceQueue queue) { + super(Objects.requireNonNull(owner), queue); + this.domain = Objects.requireNonNull(domain); + this.cleanup = Objects.requireNonNull(cleanup); + } + + private boolean eligible(Set retiredDomains) { + return get() == null || retiredDomains.contains(domain); + } + + private boolean tryClean() { + if (cleaned.get() || !cleaning.compareAndSet(false, true)) return false; + try { + cleanup.run(); + cleaned.set(true); + return true; + } finally { + cleaning.set(false); + } + } + } + + public final class CleanupHandle implements AutoCloseable { + private final TrackedCleanup entry; + private CleanupHandle(TrackedCleanup entry) { this.entry = entry; } + + @Override + public void close() { + if (entry.cleaned.get()) { + removeTracked(entry); + return; + } + boolean cleanedNow = entry.tryClean(); + if (cleanedNow) removeTracked(entry); + } + } + + private final Set tracked = ConcurrentHashMap.newKeySet(); + private final Set retryableFailures = ConcurrentHashMap.newKeySet(); + private final Map> trackedByDomain = new ConcurrentHashMap<>(); + private final Set retiredDomains = ConcurrentHashMap.newKeySet(); + private final ReferenceQueue referenceQueue = new ReferenceQueue<>(); + private final AtomicLong collections = new AtomicLong(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicLong lastJvmGcNanos = new AtomicLong(Long.MIN_VALUE); + private final Object lifecycleLock = new Object(); + private final Runnable jvmGcRequest; + private final Duration minProcessGcInterval; + private final int maxTracked; + private final int maxTrackedPerActor; + private final int maxActorSweepEntries; + private final ScheduledFuture periodicSweep; + + public RuntimeGarbageCollector() { + this( + System::gc, + DEFAULT_PERIOD, + DEFAULT_MIN_PROCESS_GC_INTERVAL, + DEFAULT_MAX_TRACKED, + DEFAULT_MAX_TRACKED_PER_ACTOR, + DEFAULT_MAX_ACTOR_SWEEP_ENTRIES); + } + + RuntimeGarbageCollector(Runnable jvmGcRequest, Duration period) { + this( + jvmGcRequest, + period, + DEFAULT_MIN_PROCESS_GC_INTERVAL, + DEFAULT_MAX_TRACKED, + DEFAULT_MAX_TRACKED_PER_ACTOR, + DEFAULT_MAX_ACTOR_SWEEP_ENTRIES); + } + + RuntimeGarbageCollector( + Runnable jvmGcRequest, + Duration period, + Duration minProcessGcInterval, + int maxTracked) { + this( + jvmGcRequest, + period, + minProcessGcInterval, + maxTracked, + Math.min(maxTracked, DEFAULT_MAX_TRACKED_PER_ACTOR), + DEFAULT_MAX_ACTOR_SWEEP_ENTRIES); + } + + RuntimeGarbageCollector( + Runnable jvmGcRequest, + Duration period, + Duration minProcessGcInterval, + int maxTracked, + int maxTrackedPerActor, + int maxActorSweepEntries) { + this.jvmGcRequest = Objects.requireNonNull(jvmGcRequest); + this.minProcessGcInterval = requirePositive(minProcessGcInterval, "minimum process GC interval"); + requirePositive(period, "GC sweep period"); + if (maxTracked <= 0) throw new IllegalArgumentException("maxTracked must be positive"); + if (maxTrackedPerActor <= 0) throw new IllegalArgumentException("maxTrackedPerActor must be positive"); + if (maxTrackedPerActor > maxTracked) throw new IllegalArgumentException("maxTrackedPerActor cannot exceed maxTracked"); + if (maxActorSweepEntries <= 0) throw new IllegalArgumentException("maxActorSweepEntries must be positive"); + this.maxTracked = maxTracked; + this.maxTrackedPerActor = maxTrackedPerActor; + this.maxActorSweepEntries = maxActorSweepEntries; + long periodNanos = period.toNanos(); + this.periodicSweep = SWEEP_TIMER.scheduleWithFixedDelay( + this::safePeriodicSweep, + periodNanos, + periodNanos, + TimeUnit.NANOSECONDS); + } + + private static Duration requirePositive(Duration value, String name) { + Objects.requireNonNull(value); + if (value.isNegative() || value.isZero()) throw new IllegalArgumentException(name + " must be positive"); + return value; + } + + /** + * Registers an idempotent cleanup hook. The owner is weakly referenced; + * callers must not capture the owner strongly from the cleanup closure. + */ + public CleanupHandle track(Object owner, Runnable cleanup) { + synchronized (lifecycleLock) { + ensureOpen(); + if (tracked.size() >= maxTracked) { + throw new IllegalStateException("runtime cleanup registry limit exceeded: " + maxTracked); + } + Object actorDomain = ActorRuntime.currentActorExecutionDomain(); + Object domain = actorDomain == null ? PROCESS_DOMAIN : actorDomain; + Set domainEntries = + trackedByDomain.computeIfAbsent(domain, ignored -> ConcurrentHashMap.newKeySet()); + if (actorDomain != null && domainEntries.size() >= maxTrackedPerActor) { + throw new IllegalStateException( + "actor cleanup registry limit exceeded: " + maxTrackedPerActor); + } + TrackedCleanup entry = new TrackedCleanup(owner, domain, cleanup, referenceQueue); + tracked.add(entry); + domainEntries.add(entry); + return new CleanupHandle(entry); + } + } + + public CollectionReport collectProcess() { + ensureOpen(); + boolean requested = requestJvmGcIfAllowed(); + return sweep(null, requested, Integer.MAX_VALUE); + } + + public CollectionReport collectCurrentActor() { + ensureOpen(); + Object actorDomain = ActorRuntime.currentActorExecutionDomain(); + if (actorDomain == null) throw new IllegalStateException("actor.gc() requires execution inside an actor"); + return sweep(actorDomain, false, maxActorSweepEntries); + } + + public CollectionReport collectPeriodic() { + ensureOpen(); + return sweep(null, false, Integer.MAX_VALUE); + } + + /** + * Retires one semantic actor domain. Actor-local runtime resources are + * deterministic actor-lifetime resources: actor termination makes them + * cleanup-eligible even if a stale host reference still exists. + * + * Failed cleanup hooks remain registered and are retried by later process + * or periodic sweeps. The per-actor registry cap bounds exit work. + */ + public CollectionReport retireActorDomain(Object actorDomain) { + Objects.requireNonNull(actorDomain, "actorDomain"); + ensureOpen(); + retiredDomains.add(actorDomain); + return sweep(actorDomain, false, Integer.MAX_VALUE); + } + + private boolean requestJvmGcIfAllowed() { + long now = System.nanoTime(); + long previous = lastJvmGcNanos.get(); + long minGap = minProcessGcInterval.toNanos(); + if (previous != Long.MIN_VALUE && now - previous < minGap) return false; + if (!lastJvmGcNanos.compareAndSet(previous, now)) return false; + try { + jvmGcRequest.run(); + return true; + } catch (VirtualMachineError | ThreadDeath fatal) { + throw fatal; + } catch (Throwable ignored) { + return false; + } + } + + private CollectionReport sweep( + Object requestedDomain, + boolean jvmGcRequested, + int maxEntriesToInspect) { + int before = requestedDomain == null + ? tracked.size() + : trackedByDomain.getOrDefault(requestedDomain, Set.of()).size(); + int inspected = 0; + int cleanedCount = 0; + int cleanupFailures = 0; + + if (requestedDomain == null) { + for (TrackedCleanup entry : Set.copyOf(retryableFailures)) { + if (inspected >= maxEntriesToInspect) break; + if (!tracked.contains(entry)) { + retryableFailures.remove(entry); + continue; + } + inspected++; + try { + if (entry.tryClean()) { + cleanedCount++; + removeTracked(entry); + } + } catch (VirtualMachineError | ThreadDeath fatal) { + throw fatal; + } catch (Throwable cleanupFailure) { + cleanupFailures++; + } + } + + while (inspected < maxEntriesToInspect) { + TrackedCleanup entry = (TrackedCleanup) referenceQueue.poll(); + if (entry == null) break; + if (!tracked.contains(entry)) continue; + inspected++; + try { + if (entry.tryClean()) { + cleanedCount++; + removeTracked(entry); + } + } catch (VirtualMachineError | ThreadDeath fatal) { + throw fatal; + } catch (Throwable cleanupFailure) { + retryableFailures.add(entry); + cleanupFailures++; + } + } + + // Retired actor domains are deterministically cleanup-eligible even + // if a stale host reference keeps an owner strongly reachable. + for (Object retired : Set.copyOf(retiredDomains)) { + if (inspected >= maxEntriesToInspect) break; + Set entries = trackedByDomain.getOrDefault(retired, Set.of()); + for (TrackedCleanup entry : entries) { + if (inspected >= maxEntriesToInspect) break; + inspected++; + try { + if (entry.tryClean()) { + cleanedCount++; + removeTracked(entry); + } + } catch (VirtualMachineError | ThreadDeath fatal) { + throw fatal; + } catch (Throwable cleanupFailure) { + retryableFailures.add(entry); + cleanupFailures++; + } + } + } + } else { + Set candidates = + trackedByDomain.getOrDefault(requestedDomain, Set.of()); + for (TrackedCleanup entry : candidates) { + if (inspected >= maxEntriesToInspect) break; + inspected++; + if (!entry.eligible(retiredDomains)) continue; + try { + if (entry.tryClean()) { + cleanedCount++; + removeTracked(entry); + } + } catch (VirtualMachineError | ThreadDeath fatal) { + throw fatal; + } catch (Throwable cleanupFailure) { + retryableFailures.add(entry); + cleanupFailures++; + } + } + } + + int after = requestedDomain == null + ? tracked.size() + : trackedByDomain.getOrDefault(requestedDomain, Set.of()).size(); + return new CollectionReport( + requestedDomain == null ? "process" : "actor", + collections.incrementAndGet(), + before, + inspected, + cleanedCount, + after, + cleanupFailures, + jvmGcRequested); + } + + private void removeTracked(TrackedCleanup entry) { + synchronized (lifecycleLock) { + tracked.remove(entry); + retryableFailures.remove(entry); + Set domainEntries = trackedByDomain.get(entry.domain); + if (domainEntries == null) return; + domainEntries.remove(entry); + if (domainEntries.isEmpty()) { + trackedByDomain.remove(entry.domain, domainEntries); + retiredDomains.remove(entry.domain); + } + } + } + + private void safePeriodicSweep() { + if (closed.get()) return; + try { + collectPeriodic(); + } catch (VirtualMachineError | ThreadDeath fatal) { + throw fatal; + } catch (Throwable ignored) { + // Periodic housekeeping is best-effort. Cleanup hook failures are + // retained for retry and must not kill the shared timer thread. + } + } + + private void ensureOpen() { + if (closed.get()) throw new IllegalStateException("garbage collector is closed"); + } + + @Override + public void close() { + synchronized (lifecycleLock) { + if (!closed.compareAndSet(false, true)) return; + } + periodicSweep.cancel(false); + for (TrackedCleanup entry : tracked) { + try { + if (entry.tryClean()) removeTracked(entry); + } catch (VirtualMachineError | ThreadDeath fatal) { + throw fatal; + } catch (Throwable ignored) { + // Context shutdown is best-effort for host cleanup hooks. + } + } + synchronized (lifecycleLock) { + tracked.clear(); + retryableFailures.clear(); + trackedByDomain.clear(); + retiredDomains.clear(); + while (referenceQueue.poll() != null) { /* drain */ } + } + } +} diff --git a/src/main/java/dev/oreslang/types/OwnershipChecker.java b/src/main/java/dev/oreslang/types/OwnershipChecker.java new file mode 100644 index 00000000..26395c0c --- /dev/null +++ b/src/main/java/dev/oreslang/types/OwnershipChecker.java @@ -0,0 +1,2125 @@ +package dev.oreslang.types; + +import dev.oreslang.ast.AnnotationExpander; +import dev.oreslang.ast.Ast; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Ownership / borrow / closure-capture analysis. + * + * This pass is intentionally independent of the interpreter. Borrows erase at + * runtime; compile-time ownership remains authoritative for every backend. + * + * Current model: + * - primitive immutable values are Copy; + * - class/list/object/function values are move-only by default; + * - by-value call/binding/return moves move-only values; + * - &T permits shared immutable borrows; + * - &mut T is exclusive and requires a mutable owner; + * - Bar mut b makes an owned parameter mutable inside the callee; + * - escaping closures own non-Copy captures and mutable captures; + * - closures may not capture a borrow (pass it as a lambda parameter instead); + * - moving an outer value from a repeating loop is rejected conservatively. + */ +public final class OwnershipChecker { + private record ResolvedMethod(Ast.ClassDecl owner, Ast.TypeRef ownerType, Ast.MethodDecl method) { } + private record ResolvedField(Ast.ClassDecl owner, Ast.TypeRef ownerType, Ast.FieldDecl field) { } + private record CallSignature(List parameters, Ast.TypeRef result) { } + private final Map functions = new HashMap<>(); + private final Map classes = new HashMap<>(); + private final Set ambiguousFunctions = new HashSet<>(); + private final Set ambiguousClasses = new HashSet<>(); + private int mutexCriticalSectionDepth; + private int loopDepth; + + private OwnershipChecker(Ast.Program program) { + index(program); + } + + public static Ast.Program check(Ast.Program program) { + OwnershipChecker checker = new OwnershipChecker(program); + checker.validate(program); + return program; + } + + private void index(Ast.Program program) { + for (Ast.ModuleDecl module : program.modules()) { + for (Ast.Decl decl : module.declarations()) { + if (decl instanceof Ast.FunctionDecl fn) index(functions, ambiguousFunctions, module.name(), fn.name(), fn); + else if (decl instanceof Ast.ClassDecl klass) index(classes, ambiguousClasses, module.name(), klass.name(), klass); + } + } + } + + private static void index(Map map, Set ambiguous, String module, String name, T value) { + map.put(module + "." + name, value); + T previous = map.putIfAbsent(name, value); + if (previous != null && previous != value) { + map.remove(name); + ambiguous.add(name); + } + } + + private void validate(Ast.Program program) { + for (Ast.ModuleDecl module : program.modules()) { + for (Ast.Decl decl : module.declarations()) { + if (decl instanceof Ast.FunctionDecl fn) checkFunction(fn); + else if (decl instanceof Ast.ClassDecl klass) checkClass(klass); + } + } + } + + private void checkFunction(Ast.FunctionDecl fn) { + Scope scope = new Scope(null, fn.nonLexical()); + for (Ast.Param param : fn.parameters()) { + scope.define(param.name(), stateForParam(param)); + } + checkBlock(fn.body(), scope, fn.returnType()); + scope.close(); + } + + private void checkClass(Ast.ClassDecl klass) { + for (Ast.MethodDecl method : klass.methods()) { + Scope scope = new Scope(null); + if (!method.isStatic()) { + if (klass.actorKind() != Ast.ActorKind.NONE) { + scope.define("self", new VarState( + Ast.TypeRef.borrowed(Ast.TypeRef.simple(klass.name()), true), + false, + ValueKind.MUT_BORROW, + Origin.PARAM)); + } else { + ValueKind receiverKind = AnnotationExpander.isGeneratedFromJsonSetter(method) + ? ValueKind.MUT_BORROW + : ValueKind.IMM_BORROW; + scope.define("self", new VarState(Ast.TypeRef.simple(klass.name()), false, receiverKind, Origin.PARAM)); + } + } + for (Ast.Param param : method.parameters()) scope.define(param.name(), stateForParam(param)); + checkBlock(method.body(), scope, method.returnType()); + scope.close(); + } + } + + private VarState stateForParam(Ast.Param param) { + ValueKind kind = param.structural() && !param.type().isBorrow() ? ValueKind.IMM_BORROW : kindOfType(param.type()); + boolean mutableOwner = param.mutable(); + if (param.type().isBorrow() && param.type().mutableBorrow()) mutableOwner = false; + return new VarState(param.type(), mutableOwner, kind, Origin.PARAM); + } + + private void checkBlock(List body, Scope parent, Ast.TypeRef returnType) { + Scope scope = new Scope(parent); + for (Ast.Stmt stmt : body) checkStatement(stmt, scope, returnType); + scope.close(); + } + + private void checkLoopBlock(List body, Scope parent, Ast.TypeRef returnType) { + loopDepth++; + try { + checkBlock(body, parent, returnType); + } finally { + loopDepth--; + } + } + + private void checkStatement(Ast.Stmt stmt, Scope scope, Ast.TypeRef returnType) { + if (stmt instanceof Ast.BindingStmt binding) { + checkBinding(binding, scope); + return; + } + if (stmt instanceof Ast.DestructureStmt destructure) { + ValueInfo source = checkExpr(destructure.initializer(), scope, true); + for (int i = 0; i < destructure.bindings().size(); i++) { + Ast.DestructureBinding binding = destructure.bindings().get(i); + if (binding.isDiscard()) continue; + Ast.TypeRef bindingType = destructureBindingType(destructure, source.type, i, binding.name()); + ValueKind bindingKind = bindingType.name().equals("$infer$") + ? (source.kind == ValueKind.COPY ? ValueKind.COPY : ValueKind.MOVE_ONLY) + : kindOfType(bindingType); + scope.define(binding.name(), new VarState( + bindingType, + binding.kind() == Ast.BindingKind.LET, + bindingKind, + Origin.LOCAL)); + } + return; + } + if (stmt instanceof Ast.BlockStmt block) { + checkBlock(block.body(), scope, returnType); + return; + } + if (stmt instanceof Ast.BreakStmt) { + if (loopDepth == 0) throw error("'break' may only appear inside loop or for"); + return; + } + if (stmt instanceof Ast.ContinueStmt) { + if (loopDepth == 0) throw error("'continue' may only appear inside loop or for"); + return; + } + if (stmt instanceof Ast.ReturnStmt ret) { + if (ret.value() != null) { + if (mutexCriticalSectionDepth > 0) { + throw error("with_lock/recover critical-section callbacks cannot return a value"); + } + if (ret.value() instanceof Ast.UnaryExpr unary && (unary.operator().equals("&") || unary.operator().equals("&mut"))) { + VarState owner = borrowOwner(unary.operand(), scope); + if (owner.origin == Origin.LOCAL) { + throw error("cannot return a borrow of local value '" + owner.debugName + "'; borrowed value would outlive its owner"); + } + if (isActorConfinedBorrow(owner)) { + throw error("actor self cannot escape its mailbox turn as a returned borrow"); + } + } + ValueInfo returned = checkExpr(ret.value(), scope, true); + if (containsMutexGuardType(returned.type)) { + throw error("MutexGuard values are lexical and cannot be returned from a function/routine"); + } + } + return; + } + if (stmt instanceof Ast.ExprStmt expression) { + ValueInfo value = checkExpr(expression.expression(), scope, false); + if (containsMutexGuardType(value.type)) { + throw error("guard-bearing values cannot be discarded; bind the result with val and release/await it"); + } + return; + } + if (stmt instanceof Ast.DeferStmt defer) { + ValueInfo value = checkExpr(defer.expression(), scope, false); + if (containsMutexGuardType(value.type)) { + throw error("defer cannot produce a guard-bearing value"); + } + return; + } + if (stmt instanceof Ast.IfStmt conditional) { + Map base = stateSnapshot(scope); + List> exits = new ArrayList<>(); + + for (Ast.IfBranch branch : conditional.branches()) { + restoreState(base); + checkExpr(branch.condition(), scope, false); + Scope branchScope = new Scope(scope); + defineConditionAliases(branch.condition(), branchScope, scope); + checkBlock(branch.body(), branchScope, returnType); + branchScope.close(); + exits.add(stateSnapshot(scope)); + } + + restoreState(base); + if (!conditional.elseBody().isEmpty()) { + checkBlock(conditional.elseBody(), scope, returnType); + exits.add(stateSnapshot(scope)); + } else { + exits.add(base); // condition may be false with no else + } + + mergeBranchState(base, exits); + return; + } + if (stmt instanceof Ast.MatchStmt matched) { + ValueInfo subject = checkExpr(matched.subject(), scope, false); + VarState source = matched.subject() instanceof Ast.NameExpr name ? scope.lookup(name.name()) : null; + Map base = stateSnapshot(scope); + List> exits = new ArrayList<>(); + for (Ast.MatchArm arm : matched.arms()) { + restoreState(base); + Scope armScope = new Scope(scope); + definePatternAliases(arm.pattern(), subject.type, source, armScope); + if (arm.guard() != null) checkExpr(arm.guard(), armScope, false); + checkBlock(arm.body(), armScope, returnType); + armScope.close(); + exits.add(stateSnapshot(scope)); + } + mergeBranchState(base, exits); + return; + } + if (stmt instanceof Ast.SwitchStmt switched) { + checkExpr(switched.subject(), scope, false); + for (Ast.SwitchCase arm : switched.cases()) { + for (Ast.Expr constant : arm.constants()) checkExpr(constant, scope, false); + checkBlock(arm.body(), scope, returnType); + } + checkBlock(switched.defaultBody(), scope, returnType); + return; + } + if (stmt instanceof Ast.TryStmt attempted) { + checkBlock(attempted.body(), scope, returnType); + Scope caught = new Scope(scope); + caught.define(attempted.errorName(), new VarState(Ast.TypeRef.inferred(), false, ValueKind.MOVE_ONLY, Origin.LOCAL)); + checkBlock(attempted.catchBody(), caught, returnType); + caught.close(); + checkBlock(attempted.finallyBody(), scope, returnType); + return; + } + if (stmt instanceof Ast.SelectStmt selected) { + if (selected.mode() != Ast.WaitMode.IMMEDIATE + && (mutexCriticalSectionDepth > 0 || scope.hasLiveMutexGuard())) { + throw error("cannot suspend or arm nb select while holding a MutexGuard"); + } + + // Case operands are evaluated once when the selection is armed. + // Public Channel/SelectSet capabilities are execution-domain local + // in the current model, so channel references are borrowed for the + // registration lifetime and cannot cross an actor boundary. This + // prevents writech from becoming an implicit shared-memory escape + // hatch while preserving ordinary actor-local channel use. + List elementTypes = new ArrayList<>(selected.arms().size()); + for (Ast.SelectArm arm : selected.arms()) { + if (arm.operation() == Ast.ChannelOperation.DEFAULT) { + elementTypes.add(Ast.TypeRef.inferred()); + continue; + } + ValueInfo channel = checkExpr(arm.channel(), scope, false); + Ast.TypeRef element = channelElementType(channel.type); + elementTypes.add(element); + if (arm.operation() == Ast.ChannelOperation.WRITE) { + ValueInfo payload = checkExpr(arm.value(), scope, false); + if (containsMutexGuardType(payload.type)) { + throw error("writech cannot transport MutexGuard"); + } + } + } + + List deferredArmCaptures = List.of(); + if (selected.mode() == Ast.WaitMode.NONBLOCKING) { + deferredArmCaptures = + prepareDeferredSelectCaptures(selected, scope); + } + + Map base = stateSnapshot(scope); + List> exits = new ArrayList<>(); + boolean hasDefault = false; + + for (int i = 0; i < selected.arms().size(); i++) { + Ast.SelectArm arm = selected.arms().get(i); + restoreState(base); + Scope armScope = selected.mode() == Ast.WaitMode.NONBLOCKING + ? deferredSelectArmScope(deferredArmCaptures.get(i)) + : new Scope(scope); + if (arm.operation() == Ast.ChannelOperation.DEFAULT) { + hasDefault = true; + } else if (arm.operation() == Ast.ChannelOperation.READ + && arm.bindingName() != null) { + Ast.TypeRef element = elementTypes.get(i); + armScope.define( + arm.bindingName(), + new VarState( + element, + arm.bindingKind() == Ast.BindingKind.LET, + kindOfType(element), + Origin.LOCAL)); + } + + if (selected.mode() == Ast.WaitMode.NONBLOCKING) { + int previousLoopDepth = loopDepth; + loopDepth = 0; + try { + checkBlock( + arm.body(), + armScope, + Ast.TypeRef.simple("void")); + } finally { + loopDepth = previousLoopDepth; + } + } else { + checkBlock(arm.body(), armScope, returnType); + } + armScope.close(); + Map exit = stateSnapshot(scope); + + if (selected.mode() != Ast.WaitMode.NONBLOCKING) { + exits.add(exit); + } + } + + if (selected.mode() == Ast.WaitMode.NONBLOCKING) { + // Captures were transferred before base was snapshotted. + // Arm-local moves happen in detached capture scopes and do not + // mutate the continuing outer ownership state. + restoreState(base); + } else { + if (selected.mode() == Ast.WaitMode.IMMEDIATE && !hasDefault) { + exits.add(base); + } + mergeBranchState(base, exits); + } + return; + } + if (stmt instanceof Ast.ForOfDestructureStmt loop) { + ValueInfo iterable = checkExpr(loop.iterable(), scope, false); + Ast.TypeRef elementType = iterableElementType(iterable.type); + Map before = movedSnapshot(scope); + Scope loopScope = new Scope(scope); + for (int i = 0; i < loop.bindings().size(); i++) { + Ast.DestructureBinding binding = loop.bindings().get(i); + if (binding.isDiscard()) continue; + Ast.TypeRef bindingType = sequenceDestructureBindingType(elementType, i); + loopScope.define(binding.name(), new VarState( + bindingType, + binding.kind() == Ast.BindingKind.LET, + kindOfType(bindingType), + Origin.LOCAL)); + } + checkLoopBlock(loop.body(), loopScope, returnType); + loopScope.close(); + rejectLoopMoves(before, scope); + return; + } + if (stmt instanceof Ast.ForOfStmt loop) { + ValueInfo iterable = checkExpr(loop.iterable(), scope, false); + Ast.TypeRef elementType = iterableElementType(iterable.type); + Map before = movedSnapshot(scope); + Scope loopScope = new Scope(scope); + loopScope.define(loop.bindingName(), new VarState( + elementType, + loop.bindingKind() == Ast.BindingKind.LET, + kindOfType(elementType), + Origin.LOCAL)); + checkLoopBlock(loop.body(), loopScope, returnType); + loopScope.close(); + rejectLoopMoves(before, scope); + return; + } + if (stmt instanceof Ast.ForStmt loop) { + Scope loopScope = new Scope(scope); + if (loop.initializer() != null) checkStatement(loop.initializer(), loopScope, returnType); + if (loop.condition() != null) checkExpr(loop.condition(), loopScope, false); + Map before = movedSnapshot(scope); + checkLoopBlock(loop.body(), loopScope, returnType); + if (loop.update() != null) checkExpr(loop.update(), loopScope, false); + rejectLoopMoves(before, scope); + loopScope.close(); + return; + } + if (stmt instanceof Ast.LoopStmt loop) { + Map before = movedSnapshot(scope); + checkLoopBlock(loop.body(), scope, returnType); + rejectLoopMoves(before, scope); + } + } + + private void checkBinding(Ast.BindingStmt binding, Scope scope) { + boolean recursiveLambda = binding.initializer() instanceof Ast.LambdaExpr; + VarState placeholder = null; + if (recursiveLambda) { + placeholder = new VarState( + binding.declaredType() == null ? Ast.TypeRef.inferred() : binding.declaredType(), + binding.kind() == Ast.BindingKind.LET, + ValueKind.MOVE_ONLY, + Origin.LOCAL); + scope.define(binding.name(), placeholder); + } + + ValueInfo value; + if (binding.initializer() instanceof Ast.UnaryExpr unary && (unary.operator().equals("&") || unary.operator().equals("&mut"))) { + boolean mutableBorrow = unary.operator().equals("&mut"); + VarState owner = borrowOwner(unary.operand(), scope); + beginPersistentBorrow(owner, mutableBorrow); + value = new ValueInfo(Ast.TypeRef.borrowed(owner.type, mutableBorrow), mutableBorrow ? ValueKind.MUT_BORROW : ValueKind.IMM_BORROW, owner); + } else if (binding.initializer() instanceof Ast.LambdaExpr lambda) { + value = checkLambda(lambda, scope, binding.name()); + } else { + value = checkExpr(binding.initializer(), scope, true); + } + + if (recursiveLambda) { + placeholder.type = binding.declaredType() == null ? value.type : binding.declaredType(); + placeholder.kind = value.kind; + placeholder.borrowSource = value.borrowSource; + return; + } + + Ast.TypeRef storedType = binding.declaredType() == null ? value.type : binding.declaredType(); + if (binding.kind() == Ast.BindingKind.LET && containsMutexGuardType(storedType)) { + throw error("guard-bearing values are linear and cannot use let; bind them once with val"); + } + + ValueKind storedKind = binding.declaredType() == null ? value.kind : kindOfType(storedType); + VarState state = new VarState( + storedType, + binding.kind() == Ast.BindingKind.LET, + storedKind, + Origin.LOCAL); + state.borrowSource = value.borrowSource; + scope.define(binding.name(), state); + } + + private ValueInfo checkExpr(Ast.Expr expr, Scope scope, boolean consuming) { + if (expr instanceof Ast.LiteralExpr literal) { + return new ValueInfo(inferLiteralType(literal.value()), ValueKind.COPY, null); + } + if (expr instanceof Ast.NameExpr name) { + VarState state = scope.lookup(name.name()); + if (state == null && name.name().equals("None")) { + return new ValueInfo( + new Ast.TypeRef("Option", List.of(Ast.TypeRef.inferred()), false), + ValueKind.MOVE_ONLY, + null); + } + if (state == null) return new ValueInfo(Ast.TypeRef.inferred(), ValueKind.COPY, null); // function/module/global + state.debugName = name.name(); + requireUsable(state, name.name(), false); + if (consuming && isActorConfinedBorrow(state)) { + throw error("actor self is a non-escapable mailbox capability; access its fields/methods inside the actor turn"); + } + if (consuming && mutexCriticalSectionDepth > 0 && state.kind == ValueKind.MUT_BORROW) { + throw error("protected with_lock/recover state cannot be moved by value; use it through its &mut critical-section borrow"); + } + if (consuming && state.kind == ValueKind.MOVE_ONLY) move(state, name.name()); + if (consuming && state.kind == ValueKind.MUT_BORROW) move(state, name.name()); + return new ValueInfo(state.type, state.kind, state.borrowSource); + } + if (expr instanceof Ast.UnaryExpr unary) { + if (unary.operator().equals("&") || unary.operator().equals("&mut")) { + boolean mutable = unary.operator().equals("&mut"); + VarState owner = borrowOwner(unary.operand(), scope); + validateBorrow(owner, mutable); + return new ValueInfo(Ast.TypeRef.borrowed(owner.type, mutable), mutable ? ValueKind.MUT_BORROW : ValueKind.IMM_BORROW, ownershipRoot(owner)); + } + return checkExpr(unary.operand(), scope, false); + } + if (expr instanceof Ast.AssignExpr assignment) { + checkAssignmentTarget(assignment.target(), scope); + ValueInfo assigned = checkExpr(assignment.value(), scope, true); + if (containsMutexGuardType(assigned.type)) { + throw error("guard-bearing values cannot be assigned or overwritten; bind them once with val"); + } + return assigned; + } + if (expr instanceof Ast.TypeTestExpr test) { + checkExpr(test.value(), scope, false); + return new ValueInfo(Ast.TypeRef.simple("bool"), ValueKind.COPY, null); + } + if (expr instanceof Ast.PatternTestExpr test) { + checkExpr(test.value(), scope, false); + return new ValueInfo(Ast.TypeRef.simple("bool"), ValueKind.COPY, null); + } + if (expr instanceof Ast.CastExpr cast) { + ValueInfo source = checkExpr(cast.value(), scope, consuming); + Ast.TypeRef result = cast.mode() == Ast.CastMode.OPTIONAL + ? new Ast.TypeRef("Option", List.of(cast.targetType()), false) + : cast.targetType(); + return new ValueInfo(result, + cast.mode() == Ast.CastMode.OPTIONAL ? kindOfType(result) : source.kind, + source.borrowSource); + } + if (expr instanceof Ast.BinaryExpr binary) { + checkExpr(binary.left(), scope, false); + if (binary.operator().equals("&&")) { + Scope rightScope = new Scope(scope); + defineConditionAliases(binary.left(), rightScope, scope); + checkExpr(binary.right(), rightScope, false); + rightScope.close(); + } else { + checkExpr(binary.right(), scope, false); + } + return new ValueInfo(Ast.TypeRef.inferred(), ValueKind.COPY, null); + } + if (expr instanceof Ast.ConditionalExpr conditional) { + checkExpr(conditional.condition(), scope, false); + Map base = stateSnapshot(scope); + + ValueInfo left = checkExpr(conditional.whenTrue(), scope, consuming); + Map leftExit = stateSnapshot(scope); + + restoreState(base); + ValueInfo right = checkExpr(conditional.whenFalse(), scope, consuming); + Map rightExit = stateSnapshot(scope); + + mergeBranchState(base, List.of(leftExit, rightExit)); + Ast.TypeRef joinedType = joinConditionalType(left.type, right.type); + return new ValueInfo( + joinedType, + left.kind == ValueKind.COPY && right.kind == ValueKind.COPY + ? ValueKind.COPY + : ValueKind.MOVE_ONLY, + null); + } + if (expr instanceof Ast.CallExpr call) { + return checkCall(call, scope); + } + if (expr instanceof Ast.MemberExpr member) { + if (member.receiver() instanceof Ast.NameExpr receiverName) { + VarState receiverState = scope.lookup(receiverName.name()); + if (receiverState != null && isScopedMutexReceiver(receiverState)) { + requireUsable(receiverState, receiverName.name(), false); + Ast.TypeRef concreteReceiver = receiverType(member.receiver(), scope); + Ast.ClassDecl klass = concreteReceiver == null ? null : findClass(concreteReceiver.name()); + if (klass != null) { + ResolvedField target = findFieldTarget(klass, concreteReceiver, member.member(), new LinkedHashSet<>()); + if (target != null) { + Ast.TypeRef fieldType = substituteType( + ownershipFieldType(target.field()), + genericBindings(target.owner().genericParameters(), target.ownerType().arguments())); + if (!isCopyType(fieldType)) { + throw error("cannot extract move-only field '" + target.owner().name() + "." + member.member() + + "' from protected mutex state; operate on it inside with_lock or replace the field as a whole"); + } + return new ValueInfo(fieldType, ValueKind.COPY, null); + } + if (hasMethodNamed(klass, member.member(), new LinkedHashSet<>())) { + throw error("instance methods are direct-call-only and cannot be extracted from protected mutex state; invoke the method directly while the guard is live or use an explicit lambda where capture is legal"); + } + } + if (isMutexGuardType(receiverState.type)) { + throw error("unknown or non-extractable MutexGuard member '" + member.member() + "'"); + } + } + } + checkExpr(member.receiver(), scope, false); + Ast.TypeRef concreteReceiver = receiverType(member.receiver(), scope); + if (concreteReceiver != null + && concreteReceiver.name().equals("DynamicStruct") + && concreteReceiver.arguments().size() == 1) { + Ast.TypeRef valueType = concreteReceiver.arguments().getFirst(); + return new ValueInfo(valueType, kindOfType(valueType), null); + } + Ast.ClassDecl klass = concreteReceiver == null ? null : findClass(concreteReceiver.name()); + if (klass != null) { + ResolvedField target = findFieldTarget(klass, concreteReceiver, member.member(), new LinkedHashSet<>()); + if (target != null) { + Ast.TypeRef fieldType = substituteType( + ownershipFieldType(target.field()), + genericBindings(target.owner().genericParameters(), target.ownerType().arguments())); + ValueKind fieldKind = kindOfType(fieldType); + if (consuming && isRootedAtActorSelf(member.receiver(), scope) && fieldKind != ValueKind.COPY) { + throw error("cannot move actor-owned field '" + member.member() + + "' out of its mailbox turn; return a copy/immutable value or explicit shared snapshot"); + } + return new ValueInfo(fieldType, fieldKind, null); + } + } + return new ValueInfo(Ast.TypeRef.inferred(), ValueKind.MOVE_ONLY, null); + } + if (expr instanceof Ast.IndexExpr indexed) { + ValueInfo receiver = checkExpr(indexed.receiver(), scope, false); + checkExpr(indexed.index(), scope, false); + Ast.TypeRef elementType = collectionElementType(receiver.type); + ValueKind elementKind = elementType.name().equals("$infer$") ? ValueKind.MOVE_ONLY : kindOfType(elementType); + if (consuming && isRootedAtActorSelf(indexed.receiver(), scope) && elementKind != ValueKind.COPY) { + throw error("cannot move actor-owned indexed state out of its mailbox turn"); + } + return new ValueInfo(elementType, elementKind, null); + } + if (expr instanceof Ast.NewExpr created) { + List argumentTypes = new ArrayList<>(created.arguments().size()); + for (Ast.Expr arg : created.arguments()) { + ValueInfo info = checkExpr(arg, scope, true); + if (containsMutexGuardType(info.type)) { + throw error("MutexGuard cannot be stored in a constructed object"); + } + argumentTypes.add(info.type); + } + Ast.TypeRef constructedType = inferConstructedType(created, argumentTypes); + return new ValueInfo(constructedType, ValueKind.MOVE_ONLY, null); + } + if (expr instanceof Ast.AwaitExpr awaited) { + if (mutexCriticalSectionDepth > 0 || scope.hasLiveMutexGuard()) { + throw error("cannot await while holding a MutexGuard; release the guard before suspension"); + } + ValueInfo awaitedValue = checkExpr(awaited.expression(), scope, consuming); + if (awaitedValue.type.name().equals("Future") && awaitedValue.type.arguments().size() == 1) { + Ast.TypeRef result = awaitedValue.type.arguments().getFirst(); + return new ValueInfo(result, kindOfType(result), null); + } + return awaitedValue; + } + if (expr instanceof Ast.ChannelOpExpr channelOp) { + if (channelOp.mode() != Ast.WaitMode.IMMEDIATE + && (mutexCriticalSectionDepth > 0 || scope.hasLiveMutexGuard())) { + throw error("cannot suspend or register a channel waiter while holding a MutexGuard"); + } + ValueInfo channel = checkExpr(channelOp.channel(), scope, false); + Ast.TypeRef element = channelElementType(channel.type); + + if (channelOp.operation() == Ast.ChannelOperation.WRITE) { + ValueInfo payload = checkExpr(channelOp.value(), scope, false); + if (containsMutexGuardType(payload.type)) { + throw error("writech cannot transport MutexGuard"); + } + Ast.TypeRef result = switch (channelOp.mode()) { + case BLOCKING -> Ast.TypeRef.simple("void"); + case NONBLOCKING -> new Ast.TypeRef( + "Future", List.of(Ast.TypeRef.simple("void")), false); + case IMMEDIATE -> Ast.TypeRef.simple("bool"); + }; + return new ValueInfo(result, kindOfType(result), null); + } + + Ast.TypeRef result = switch (channelOp.mode()) { + case BLOCKING -> element; + case NONBLOCKING -> new Ast.TypeRef("Future", List.of(element), false); + case IMMEDIATE -> new Ast.TypeRef("Option", List.of(element), false); + }; + return new ValueInfo(result, kindOfType(result), null); + } + if (expr instanceof Ast.DynamicSelectExpr selected) { + if (selected.mode() != Ast.WaitMode.IMMEDIATE + && (mutexCriticalSectionDepth > 0 || scope.hasLiveMutexGuard())) { + throw error("cannot suspend or register dynamic select while holding a MutexGuard"); + } + checkExpr(selected.cases(), scope, false); + Ast.TypeRef selectedResult = Ast.TypeRef.simple("SelectResult"); + Ast.TypeRef result = switch (selected.mode()) { + case BLOCKING -> selectedResult; + case NONBLOCKING -> new Ast.TypeRef("Future", List.of(selectedResult), false); + case IMMEDIATE -> new Ast.TypeRef("Option", List.of(selectedResult), false); + }; + return new ValueInfo(result, kindOfType(result), null); + } + if (expr instanceof Ast.ListExpr list) { + Ast.TypeRef elementType = null; + for (Ast.Expr item : list.elements()) { + ValueInfo info = checkExpr(item, scope, true); + if (containsMutexGuardType(info.type)) throw error("MutexGuard cannot be stored in an array/list"); + elementType = elementType == null + ? info.type + : joinConditionalType(elementType, info.type); + } + if (elementType == null) elementType = Ast.TypeRef.inferred(); + return new ValueInfo( + new Ast.TypeRef("Array", List.of(elementType), false), + ValueKind.MOVE_ONLY, + null); + } + if (expr instanceof Ast.TupleExpr tuple) { + boolean copy = true; + for (Ast.Expr item : tuple.elements()) { + ValueInfo info = checkExpr(item, scope, true); + if (containsMutexGuardType(info.type)) throw error("MutexGuard cannot be stored in a tuple"); + copy &= info.kind == ValueKind.COPY; + } + return new ValueInfo(Ast.TypeRef.inferred(), copy ? ValueKind.COPY : ValueKind.MOVE_ONLY, null); + } + if (expr instanceof Ast.ObjectExpr object) { + boolean dynamic = false; + for (Ast.ObjectField field : object.fields()) { + if (field.isDynamic()) { + dynamic = true; + ValueInfo key = checkExpr(field.dynamicName(), scope, false); + if (containsMutexGuardType(key.type)) { + throw error("dynamic object keys cannot contain MutexGuard"); + } + } + ValueInfo info = checkExpr(field.value(), scope, true); + if (containsMutexGuardType(info.type)) throw error("MutexGuard cannot be stored in an object/map"); + } + return new ValueInfo( + dynamic ? new Ast.TypeRef("DynamicStruct", List.of(Ast.TypeRef.inferred()), false) + : Ast.TypeRef.simple("obj"), + ValueKind.MOVE_ONLY, + null); + } + if (expr instanceof Ast.LambdaExpr lambda) return checkLambda(lambda, scope, null); + return new ValueInfo(Ast.TypeRef.inferred(), ValueKind.MOVE_ONLY, null); + } + + private ValueInfo checkCall(Ast.CallExpr call, Scope scope) { + if (call.callee() instanceof Ast.MemberExpr factoryCall + && factoryCall.receiver() instanceof Ast.NameExpr factory + && (factory.name().equals("Mutex") || factory.name().equals("SharedMutex")) + && factoryCall.member().equals("new") + && call.arguments().size() == 1) { + ValueInfo owned = checkExpr(call.arguments().getFirst(), scope, true); + if (owned.type != null && owned.type.isBorrow()) { + throw error(factory.name() + + ".new requires an owned value; borrowed values cannot become mutex state"); + } + if (containsMutexGuardType(owned.type)) { + throw error(factory.name() + ".new cannot hide a guard-bearing value"); + } + Ast.TypeRef mutexType = new Ast.TypeRef(factory.name(), List.of(owned.type), false); + return new ValueInfo(mutexType, factory.name().equals("SharedMutex") ? ValueKind.COPY : ValueKind.MOVE_ONLY, null); + } + + if (call.callee() instanceof Ast.NameExpr name + && (name.name().equals("Some") || name.name().equals("Ok") || name.name().equals("Err"))) { + if (call.arguments().size() != 1) { + return new ValueInfo(Ast.TypeRef.inferred(), ValueKind.MOVE_ONLY, null); + } + ValueInfo payload = checkExpr(call.arguments().getFirst(), scope, true); + if (payload.kind == ValueKind.IMM_BORROW || payload.kind == ValueKind.MUT_BORROW + || (payload.type != null && payload.type.isBorrow())) { + throw error(name.name() + + " cannot store a borrow in an owned sum value until explicit lifetime parameters are supported"); + } + if (name.name().equals("Some")) { + Ast.TypeRef type = new Ast.TypeRef("Option", List.of(payload.type), false); + return new ValueInfo(type, kindOfType(type), null); + } + Ast.TypeRef unknown = Ast.TypeRef.inferred(); + Ast.TypeRef type = name.name().equals("Ok") + ? new Ast.TypeRef("Result", List.of(payload.type, unknown), false) + : new Ast.TypeRef("Result", List.of(unknown, payload.type), false); + return new ValueInfo(type, ValueKind.MOVE_ONLY, null); + } + + if (call.callee() instanceof Ast.NameExpr name) { + Ast.FunctionDecl fn = findFunction(name.name()); + if (fn != null) { + CallSignature signature = specializeCall( + fn.genericParameters(), fn.genericParameters(), + fn.parameters(), fn.returnType(), call, scope, Map.of()); + checkArguments(call.arguments(), signature.parameters(), scope, "function " + fn.name()); + return new ValueInfo(signature.result(), kindOfType(signature.result()), null); + } + } + + if (call.callee() instanceof Ast.MemberExpr qualified + && qualified.receiver() instanceof Ast.NameExpr namespace) { + Ast.FunctionDecl fn = findFunction(namespace.name() + "." + qualified.member()); + if (fn != null) { + CallSignature signature = specializeCall( + fn.genericParameters(), fn.genericParameters(), + fn.parameters(), fn.returnType(), call, scope, Map.of()); + checkArguments( + call.arguments(), + signature.parameters(), + scope, + "function " + namespace.name() + "." + qualified.member()); + return new ValueInfo(signature.result(), kindOfType(signature.result()), null); + } + } + + if (call.callee() instanceof Ast.MemberExpr member) { + ValueInfo sumCall = checkBuiltinSumCall(member, call.arguments(), scope); + if (sumCall != null) return sumCall; + + Ast.ClassDecl staticClass = classNamespaceOf(member.receiver(), scope); + if (staticClass != null) { + Ast.MethodDecl staticFunction = findStaticMethod( + staticClass, member.member(), call.arguments().size(), new LinkedHashSet<>()); + if (staticFunction != null) { + CallSignature signature = specializeCall( + staticFunction.genericParameters(), + staticFunction.genericParameters(), + staticFunction.parameters(), + staticFunction.returnType(), + call, + scope, + Map.of()); + checkArguments( + call.arguments(), + signature.parameters(), + scope, + "static function " + staticClass.name() + "." + staticFunction.name()); + return new ValueInfo(signature.result(), kindOfType(signature.result()), null); + } + } + + if (member.receiver() instanceof Ast.NameExpr receiverName) { + VarState receiverState = scope.lookup(receiverName.name()); + if (receiverState != null) { + requireUsable(receiverState, receiverName.name(), false); + Ast.TypeRef receiverType = receiverState.type; + if ((receiverType.name().equals("Mutex") || receiverType.name().equals("SharedMutex")) + && receiverType.arguments().size() == 1) { + Ast.TypeRef element = receiverType.arguments().getFirst(); + if (member.member().equals("lock") && call.arguments().isEmpty()) { + return new ValueInfo(new Ast.TypeRef("MutexGuard", List.of(element), false), ValueKind.MOVE_ONLY, null); + } + if (member.member().equals("try_lock") && call.arguments().isEmpty()) { + Ast.TypeRef guard = new Ast.TypeRef("MutexGuard", List.of(element), false); + return new ValueInfo(new Ast.TypeRef("Option", List.of(guard), false), ValueKind.MOVE_ONLY, null); + } + if (member.member().equals("lock_async") && call.arguments().isEmpty()) { + Ast.TypeRef guard = new Ast.TypeRef("MutexGuard", List.of(element), false); + return new ValueInfo(new Ast.TypeRef("Future", List.of(guard), false), ValueKind.MOVE_ONLY, null); + } + if ((member.member().equals("with_lock") || member.member().equals("recover")) + && call.arguments().size() == 1) { + if (!(call.arguments().getFirst() instanceof Ast.LambdaExpr lambda)) { + throw error(member.member() + + " requires an inline lambda so protected mutex state remains lexical"); + } + if (member.member().equals("recover") && !receiverType.name().equals("SharedMutex")) { + throw error("recover is only available on SharedMutex"); + } + if (lambda.parameters().size() != 1) { + throw error(member.member() + " callback must accept exactly one protected-value parameter"); + } + Ast.Param original = lambda.parameters().getFirst(); + Ast.Param protectedParam = new Ast.Param( + Ast.TypeRef.borrowed(element, true), + original.name(), + original.structural(), + false); + Ast.LambdaExpr protectedLambda = new Ast.LambdaExpr( + List.of(protectedParam), lambda.expressionBody(), lambda.blockBody()); + mutexCriticalSectionDepth++; + try { + checkLambda(protectedLambda, scope, null); + } finally { + mutexCriticalSectionDepth--; + } + return new ValueInfo(Ast.TypeRef.inferred(), ValueKind.MOVE_ONLY, null); + } + } + if (isMutexGuardType(receiverType) && member.member().equals("release") && call.arguments().isEmpty()) { + move(receiverState, receiverName.name()); + return new ValueInfo(Ast.TypeRef.simple("void"), ValueKind.COPY, null); + } + if (isMutexGuardType(receiverType) && member.member().equals("is_released") && call.arguments().isEmpty()) { + return new ValueInfo(Ast.TypeRef.simple("bool"), ValueKind.COPY, null); + } + } + } + checkExpr(member.receiver(), scope, false); + Ast.TypeRef concreteReceiver = receiverType(member.receiver(), scope); + Ast.ClassDecl klass = concreteReceiver == null ? null : findClass(concreteReceiver.name()); + ResolvedMethod target = klass == null ? null + : findMethodTarget(klass, concreteReceiver, member.member(), call.arguments().size(), new LinkedHashSet<>()); + if (target != null) { + Ast.MethodDecl method = target.method(); + if (AnnotationExpander.isGeneratedFromJsonSetter(method)) { + ensureMutableReceiver(member.receiver(), scope, "generated JSON setter '" + method.name() + "'"); + } + boolean protectedReceiver = false; + if (member.receiver() instanceof Ast.NameExpr receiverName) { + VarState receiverState = scope.lookup(receiverName.name()); + protectedReceiver = receiverState != null && isScopedMutexReceiver(receiverState); + } + Map ownerBindings = genericBindings( + target.owner().genericParameters(), target.ownerType().arguments()); + List allGenerics = new ArrayList<>(target.owner().genericParameters()); + allGenerics.addAll(method.genericParameters()); + CallSignature signature = specializeCall( + allGenerics, method.genericParameters(), + method.parameters(), method.returnType(), call, scope, ownerBindings); + checkArguments(call.arguments(), signature.parameters(), scope, "method " + method.name()); + if (protectedReceiver && !isCopyType(signature.result()) + && !signature.result().name().equals("void")) { + throw error("method '" + target.owner().name() + "." + method.name() + + "' cannot return move-only state through a mutex guard/critical-section borrow"); + } + return new ValueInfo(signature.result(), kindOfType(signature.result()), null); + } + } + + checkExpr(call.callee(), scope, false); + for (Ast.Expr arg : call.arguments()) { + ValueInfo argument = checkExpr(arg, scope, true); + if (containsMutexGuardType(argument.type)) { + throw error("guard-bearing values cannot cross an arbitrary call boundary"); + } + } + return new ValueInfo(Ast.TypeRef.inferred(), ValueKind.MOVE_ONLY, null); + } + + private ValueInfo checkBuiltinSumCall(Ast.MemberExpr member, List arguments, Scope scope) { + boolean query = member.member().equals("is_some") || member.member().equals("is_none") + || member.member().equals("is_ok") || member.member().equals("is_err"); + boolean extracting = member.member().equals("unwrap") || member.member().equals("unwrap_safe") + || member.member().equals("expect") || member.member().equals("unwrap_or"); + if (!query && !extracting) return null; + + ValueInfo receiver = checkExpr(member.receiver(), scope, false); + Ast.TypeRef receiverType = receiver.type; + if (receiverType == null || receiverType.isBorrow()) return null; + boolean option = receiverType.name().equals("Option") && receiverType.arguments().size() == 1; + boolean result = receiverType.name().equals("Result") && receiverType.arguments().size() == 2; + if (!option && !result) return null; + + if (query) { + for (Ast.Expr argument : arguments) checkExpr(argument, scope, true); + return new ValueInfo(Ast.TypeRef.simple("bool"), ValueKind.COPY, null); + } + + if (member.receiver() instanceof Ast.NameExpr receiverName) { + VarState state = scope.lookup(receiverName.name()); + if (state != null && state.kind == ValueKind.MOVE_ONLY) move(state, receiverName.name()); + } + + Ast.TypeRef okType = option ? receiverType.arguments().getFirst() : receiverType.arguments().get(0); + if (okType.isBorrow()) { + throw error(member.member() + + " cannot extract a borrow from an owned Option/Result until explicit lifetime parameters are supported"); + } + + if (member.member().equals("unwrap_or") && containsMutexGuardType(okType)) { + throw error("unwrap_or cannot eagerly discard a MutexGuard fallback; use explicit branching so every guard is released"); + } + for (Ast.Expr argument : arguments) { + ValueInfo arg = checkExpr(argument, scope, true); + if (containsMutexGuardType(arg.type) && !member.member().equals("unwrap_or")) { + throw error(member.member() + " argument cannot contain MutexGuard"); + } + } + + if (member.member().equals("unwrap_safe")) { + if (option) { + Ast.TypeRef safe = new Ast.TypeRef( + "Result", + List.of(okType, Ast.TypeRef.simple("OptionUnwrapError")), + false); + return new ValueInfo(safe, kindOfType(safe), null); + } + return new ValueInfo(receiverType, kindOfType(receiverType), null); + } + return new ValueInfo(okType, kindOfType(okType), null); + } + + private void checkArguments(List arguments, List params, Scope scope, String callable) { + if (arguments.size() != params.size()) return; // arity is TypeChecker's responsibility + for (int i = 0; i < arguments.size(); i++) { + Ast.Expr arg = arguments.get(i); + Ast.Param param = params.get(i); + if (param.structural() && !param.type().isBorrow()) { + ValueInfo argument = checkExpr(arg, scope, false); + if (containsMutexGuardType(argument.type) + || (mutexCriticalSectionDepth > 0 && argument.type.isBorrow())) { + throw error(callable + " argument " + (i + 1) + + " cannot consume protected mutex state through a structural by-value parameter"); + } + continue; + } + if (param.type().isBorrow()) { + boolean mutable = param.type().mutableBorrow(); + if (arg instanceof Ast.UnaryExpr unary && (unary.operator().equals("&") || unary.operator().equals("&mut"))) { + if (mutable && !unary.operator().equals("&mut")) { + throw error(callable + " argument " + (i + 1) + " requires &mut borrow"); + } + VarState owner = borrowOwner(unary.operand(), scope); + validateBorrow(owner, mutable); + if (isActorConfinedBorrow(owner)) { + throw error("actor self borrow cannot cross an ordinary callable boundary"); + } + continue; // temporary borrow ends at call boundary + } + if (arg instanceof Ast.NameExpr name) { + VarState state = requireState(scope, name.name()); + requireUsable(state, name.name(), false); + if (isActorConfinedBorrow(state)) { + throw error("actor self borrow cannot cross an ordinary callable boundary"); + } + if (mutable && state.kind != ValueKind.MUT_BORROW) { + throw error(callable + " argument " + (i + 1) + " requires &mut value"); + } + if (!mutable && state.kind != ValueKind.IMM_BORROW && state.kind != ValueKind.MUT_BORROW) { + throw error(callable + " argument " + (i + 1) + " requires borrowed value; pass &" + name.name()); + } + continue; + } + throw error(callable + " argument " + (i + 1) + " must be an explicit borrow"); + } + ValueInfo argument = checkExpr(arg, scope, true); + if (containsMutexGuardType(argument.type)) { + throw error(callable + " argument " + (i + 1) + " cannot consume a guard-bearing value"); + } + } + } + + private void checkAssignmentTarget(Ast.Expr target, Scope scope) { + if (target instanceof Ast.NameExpr name) { + VarState state = requireState(scope, name.name()); + requireUsable(state, name.name(), true); + if (!state.mutable) throw error("cannot assign immutable binding '" + name.name() + "'; use let or a mut parameter"); + if (state.immutableBorrows > 0 || state.mutableBorrowed) throw error("cannot assign '" + name.name() + "' while it is borrowed"); + return; + } + if (target instanceof Ast.MemberExpr member) { + Ast.ClassDecl klass = classOfReceiver(member.receiver(), scope); + if (klass != null) { + Ast.FieldDecl field = findField(klass, member.member(), new LinkedHashSet<>()); + if (field == null) throw error("unknown field '" + member.member() + "' on " + klass.name()); + if (field.bindingKind() != Ast.BindingKind.LET) { + throw error("field '" + klass.name() + "." + member.member() + "' is immutable; declare the field with let to permit mutation"); + } + } + ensureMutableReceiver(member.receiver(), scope, "field '" + member.member() + "'"); + return; + } + if (target instanceof Ast.IndexExpr indexed) { + ensureMutableReceiver(indexed.receiver(), scope, "indexed value"); + checkExpr(indexed.index(), scope, false); + return; + } + throw error("unsupported assignment target"); + } + + private void ensureMutableReceiver(Ast.Expr receiver, Scope scope, String what) { + if (receiver instanceof Ast.NameExpr name) { + VarState state = requireState(scope, name.name()); + requireUsable(state, name.name(), true); + if (isMutexGuardType(state.type)) return; + boolean mutableBorrow = state.kind == ValueKind.MUT_BORROW || (state.type.isBorrow() && state.type.mutableBorrow()); + if (!state.mutable && !mutableBorrow) { + throw error("cannot mutate " + what + " through immutable parameter/binding '" + name.name() + "'; declare the owned parameter as 'mut' or pass '&mut'"); + } + if (state.kind == ValueKind.IMM_BORROW || (state.type.isBorrow() && !state.type.mutableBorrow())) { + throw error("cannot mutate " + what + " through immutable borrow '" + name.name() + "'"); + } + if (state.kind != ValueKind.MUT_BORROW && (state.immutableBorrows > 0 || state.mutableBorrowed)) { + throw error("cannot mutate '" + name.name() + "' while borrowed"); + } + return; + } + if (receiver instanceof Ast.UnaryExpr unary && unary.operator().equals("&mut")) { + VarState owner = borrowOwner(unary.operand(), scope); + validateBorrow(owner, true); + return; + } + throw error("mutation target must be rooted in a mutable local/parameter or &mut borrow"); + } + + private VarState borrowOwner(Ast.Expr operand, Scope scope) { + if (!(operand instanceof Ast.NameExpr name)) { + throw error("borrows currently require a named owner; borrow the binding before projecting fields/indexes"); + } + VarState owner = requireState(scope, name.name()); + owner.debugName = name.name(); + requireUsable(owner, name.name(), false); + return owner; + } + + private void validateBorrow(VarState owner, boolean mutable) { + VarState root = ownershipRoot(owner); + if (root.moved) throw error("cannot borrow moved value '" + owner.debugName + "'"); + if (mutable) { + if (!owner.mutable) throw error("cannot mutably borrow immutable owner '" + owner.debugName + "'"); + if (root.mutableBorrowed || root.immutableBorrows > 0) throw error("cannot mutably borrow '" + owner.debugName + "' while another borrow is active"); + } else if (root.mutableBorrowed) { + throw error("cannot immutably borrow '" + owner.debugName + "' while a mutable borrow is active"); + } + } + + private void beginPersistentBorrow(VarState owner, boolean mutable) { + validateBorrow(owner, mutable); + VarState root = ownershipRoot(owner); + if (mutable) root.mutableBorrowed = true; + else root.immutableBorrows++; + } + + private ValueInfo checkLambda(Ast.LambdaExpr lambda, Scope outer, String recursiveBinding) { + boolean nonLexical = lambda.nonLexical() || outer.descendantsNonLexical(); + CaptureSet captures = nonLexical ? new CaptureSet() : collectCaptures(lambda, outer, recursiveBinding); + Scope closure = new Scope(null, nonLexical); + + for (Capture capture : captures.values.values()) { + VarState source = capture.source; + source.debugName = capture.name; + requireUsable(source, capture.name, capture.write); + + if (source.kind == ValueKind.IMM_BORROW || source.kind == ValueKind.MUT_BORROW || source.type.isBorrow()) { + throw error("closure cannot capture borrowed value '" + capture.name + "'; capture its owner by value or pass the borrow as a lambda parameter"); + } + if (containsMutexGuardType(source.type)) { + throw error("closure cannot capture guard-bearing value '" + capture.name + "'; MutexGuard values are lexical"); + } + + if (capture.write) { + if (!source.mutable) throw error("closure cannot mutate immutable capture '" + capture.name + "'"); + if (source.immutableBorrows > 0 || source.mutableBorrowed) throw error("closure cannot capture '" + capture.name + "' mutably while borrowed"); + move(source, capture.name); + closure.define(capture.name, new VarState(source.type, true, source.kind, Origin.CAPTURE)); + } else if (source.kind == ValueKind.MOVE_ONLY) { + move(source, capture.name); + closure.define(capture.name, new VarState(source.type, false, ValueKind.MOVE_ONLY, Origin.CAPTURE)); + } else { + closure.define(capture.name, new VarState(source.type, false, ValueKind.COPY, Origin.CAPTURE)); + } + } + + for (Ast.Param param : lambda.parameters()) closure.define(param.name(), stateForParam(param)); + int previousLoopDepth = loopDepth; + loopDepth = 0; + try { + for (Ast.Stmt stmt : lambda.blockBody()) checkStatement(stmt, closure, Ast.TypeRef.inferred()); + } finally { + loopDepth = previousLoopDepth; + closure.close(); + } + return new ValueInfo(Ast.TypeRef.simple("Fnc"), ValueKind.MOVE_ONLY, null); + } + + private CaptureSet collectCaptures(Ast.LambdaExpr lambda, Scope outer, String recursiveBinding) { + CaptureSet captures = new CaptureSet(); + Set locals = new HashSet<>(); + for (Ast.Param param : lambda.parameters()) locals.add(param.name()); + scanStatements(lambda.blockBody(), locals, outer, recursiveBinding, captures); + return captures; + } + + private void scanStatements(List statements, Set locals, Scope outer, String recursiveBinding, CaptureSet captures) { + Set blockLocals = new HashSet<>(locals); + for (Ast.Stmt stmt : statements) { + if (stmt instanceof Ast.BindingStmt binding) { + scanExpr(binding.initializer(), blockLocals, outer, recursiveBinding, captures, false); + blockLocals.add(binding.name()); + } else if (stmt instanceof Ast.DestructureStmt destructure) { + scanExpr(destructure.initializer(), blockLocals, outer, recursiveBinding, captures, false); + for (Ast.DestructureBinding binding : destructure.bindings()) { + if (!binding.isDiscard()) blockLocals.add(binding.name()); + } + } else if (stmt instanceof Ast.ReturnStmt ret && ret.value() != null) { + scanExpr(ret.value(), blockLocals, outer, recursiveBinding, captures, false); + } else if (stmt instanceof Ast.ExprStmt e) scanExpr(e.expression(), blockLocals, outer, recursiveBinding, captures, false); + else if (stmt instanceof Ast.DeferStmt e) scanExpr(e.expression(), blockLocals, outer, recursiveBinding, captures, false); + else if (stmt instanceof Ast.BlockStmt s) { + scanStatements(s.body(), blockLocals, outer, recursiveBinding, captures); + } else if (stmt instanceof Ast.LoopStmt s) { + scanStatements(s.body(), blockLocals, outer, recursiveBinding, captures); + } else if (stmt instanceof Ast.IfStmt s) { + for (Ast.IfBranch b : s.branches()) { + scanExpr(b.condition(), blockLocals, outer, recursiveBinding, captures, false); + Set branchLocals = new HashSet<>(blockLocals); + collectConditionBindingNames(b.condition(), branchLocals); + scanStatements(b.body(), branchLocals, outer, recursiveBinding, captures); + } + scanStatements(s.elseBody(), blockLocals, outer, recursiveBinding, captures); + } else if (stmt instanceof Ast.MatchStmt s) { + scanExpr(s.subject(), blockLocals, outer, recursiveBinding, captures, false); + for (Ast.MatchArm arm : s.arms()) { + Set armLocals = new HashSet<>(blockLocals); + collectPatternBindingNames(arm.pattern(), armLocals); + if (arm.guard() != null) scanExpr(arm.guard(), armLocals, outer, recursiveBinding, captures, false); + scanStatements(arm.body(), armLocals, outer, recursiveBinding, captures); + } + } else if (stmt instanceof Ast.SwitchStmt s) { + scanExpr(s.subject(), blockLocals, outer, recursiveBinding, captures, false); + for (Ast.SwitchCase arm : s.cases()) { + for (Ast.Expr constant : arm.constants()) scanExpr(constant, blockLocals, outer, recursiveBinding, captures, false); + scanStatements(arm.body(), blockLocals, outer, recursiveBinding, captures); + } + scanStatements(s.defaultBody(), blockLocals, outer, recursiveBinding, captures); + } else if (stmt instanceof Ast.TryStmt s) { + scanStatements(s.body(), blockLocals, outer, recursiveBinding, captures); + Set caught = new HashSet<>(blockLocals); + caught.add(s.errorName()); + scanStatements(s.catchBody(), caught, outer, recursiveBinding, captures); + scanStatements(s.finallyBody(), blockLocals, outer, recursiveBinding, captures); + } else if (stmt instanceof Ast.SelectStmt s) { + for (Ast.SelectArm arm : s.arms()) { + Set armLocals = new HashSet<>(blockLocals); + if (arm.operation() != Ast.ChannelOperation.DEFAULT) { + scanExpr( + arm.channel(), + blockLocals, + outer, + recursiveBinding, + captures, + false); + } + if (arm.operation() == Ast.ChannelOperation.WRITE) { + scanExpr( + arm.value(), + blockLocals, + outer, + recursiveBinding, + captures, + false); + } + if (arm.bindingName() != null) { + armLocals.add(arm.bindingName()); + } + scanStatements( + arm.body(), + armLocals, + outer, + recursiveBinding, + captures); + } + } else if (stmt instanceof Ast.ForOfDestructureStmt s) { + scanExpr(s.iterable(), blockLocals, outer, recursiveBinding, captures, false); + Set loop = new HashSet<>(blockLocals); + for (Ast.DestructureBinding binding : s.bindings()) { + if (!binding.isDiscard()) loop.add(binding.name()); + } + scanStatements(s.body(), loop, outer, recursiveBinding, captures); + } else if (stmt instanceof Ast.ForOfStmt s) { + scanExpr(s.iterable(), blockLocals, outer, recursiveBinding, captures, false); + Set loop = new HashSet<>(blockLocals); + loop.add(s.bindingName()); + scanStatements(s.body(), loop, outer, recursiveBinding, captures); + } else if (stmt instanceof Ast.ForStmt s) { + if (s.initializer() instanceof Ast.ExprStmt e) scanExpr(e.expression(), blockLocals, outer, recursiveBinding, captures, false); + if (s.condition() != null) scanExpr(s.condition(), blockLocals, outer, recursiveBinding, captures, false); + if (s.update() != null) scanExpr(s.update(), blockLocals, outer, recursiveBinding, captures, false); + scanStatements(s.body(), blockLocals, outer, recursiveBinding, captures); + } + } + } + + private void scanExpr(Ast.Expr expr, Set locals, Scope outer, String recursiveBinding, CaptureSet captures, boolean write) { + if (expr instanceof Ast.NameExpr name) { + if (name.name().equals(recursiveBinding)) return; + if (!locals.contains(name.name())) { + VarState state = outer.lookup(name.name()); + if (state != null) captures.add(name.name(), state, write); + } + return; + } + if (expr instanceof Ast.AssignExpr assignment) { + scanExpr(assignment.target(), locals, outer, recursiveBinding, captures, true); + scanExpr(assignment.value(), locals, outer, recursiveBinding, captures, false); + } else if (expr instanceof Ast.TypeTestExpr e) { + scanExpr(e.value(), locals, outer, recursiveBinding, captures, false); + } else if (expr instanceof Ast.PatternTestExpr e) { + scanExpr(e.value(), locals, outer, recursiveBinding, captures, false); + } else if (expr instanceof Ast.CastExpr e) { + scanExpr(e.value(), locals, outer, recursiveBinding, captures, false); + } else if (expr instanceof Ast.BinaryExpr e) { + scanExpr(e.left(), locals, outer, recursiveBinding, captures, false); + scanExpr(e.right(), locals, outer, recursiveBinding, captures, false); + } else if (expr instanceof Ast.UnaryExpr e) scanExpr(e.operand(), locals, outer, recursiveBinding, captures, false); + else if (expr instanceof Ast.ConditionalExpr e) { + scanExpr(e.condition(), locals, outer, recursiveBinding, captures, false); + scanExpr(e.whenTrue(), locals, outer, recursiveBinding, captures, false); + scanExpr(e.whenFalse(), locals, outer, recursiveBinding, captures, false); + } else if (expr instanceof Ast.CallExpr e) { + scanExpr(e.callee(), locals, outer, recursiveBinding, captures, false); + for (Ast.Expr arg : e.arguments()) scanExpr(arg, locals, outer, recursiveBinding, captures, false); + } else if (expr instanceof Ast.MemberExpr e) scanExpr(e.receiver(), locals, outer, recursiveBinding, captures, write); + else if (expr instanceof Ast.IndexExpr e) { + scanExpr(e.receiver(), locals, outer, recursiveBinding, captures, write); + scanExpr(e.index(), locals, outer, recursiveBinding, captures, false); + } else if (expr instanceof Ast.NewExpr e) for (Ast.Expr arg : e.arguments()) scanExpr(arg, locals, outer, recursiveBinding, captures, false); + else if (expr instanceof Ast.AwaitExpr e) scanExpr(e.expression(), locals, outer, recursiveBinding, captures, false); + else if (expr instanceof Ast.ChannelOpExpr e) { + scanExpr(e.channel(), locals, outer, recursiveBinding, captures, false); + if (e.value() != null) { + scanExpr(e.value(), locals, outer, recursiveBinding, captures, false); + } + } + else if (expr instanceof Ast.DynamicSelectExpr e) { + scanExpr(e.cases(), locals, outer, recursiveBinding, captures, false); + } + else if (expr instanceof Ast.ListExpr e) for (Ast.Expr item : e.elements()) scanExpr(item, locals, outer, recursiveBinding, captures, false); + else if (expr instanceof Ast.TupleExpr e) for (Ast.Expr item : e.elements()) scanExpr(item, locals, outer, recursiveBinding, captures, false); + else if (expr instanceof Ast.ObjectExpr e) { + for (Ast.ObjectField field : e.fields()) { + if (field.isDynamic()) { + scanExpr(field.dynamicName(), locals, outer, recursiveBinding, captures, false); + } + scanExpr(field.value(), locals, outer, recursiveBinding, captures, false); + } + } + else if (expr instanceof Ast.LambdaExpr) { + // Nested lambda performs its own capture analysis when checked. + } + } + + private void defineConditionAliases(Ast.Expr condition, Scope destination, Scope source) { + if (condition instanceof Ast.TypeTestExpr test && test.binding() != null) { + VarState original = test.value() instanceof Ast.NameExpr name ? source.lookup(name.name()) : null; + defineRefinementAlias(test.binding(), test.targetType(), original, destination); + return; + } + if (condition instanceof Ast.PatternTestExpr test) { + VarState original = test.value() instanceof Ast.NameExpr name ? source.lookup(name.name()) : null; + Ast.TypeRef type = original == null ? Ast.TypeRef.inferred() : original.type; + definePatternAliases(test.pattern(), type, original, destination); + return; + } + if (condition instanceof Ast.BinaryExpr binary && binary.operator().equals("&&")) { + defineConditionAliases(binary.left(), destination, source); + defineConditionAliases(binary.right(), destination, source); + } + } + + private void definePatternAliases(Ast.Pattern pattern, Ast.TypeRef subjectType, VarState source, Scope destination) { + if (pattern instanceof Ast.BindingPattern binding) { + defineRefinementAlias(binding.name(), subjectType, source, destination); + return; + } + if (pattern instanceof Ast.TypePattern typed) { + if (typed.binding() != null) defineRefinementAlias(typed.binding(), typed.type(), source, destination); + return; + } + if (pattern instanceof Ast.ConstructorPattern constructor) { + List argTypes = constructorPatternTypes(constructor.constructor(), subjectType); + for (int i = 0; i < constructor.arguments().size(); i++) { + Ast.TypeRef argType = i < argTypes.size() ? argTypes.get(i) : Ast.TypeRef.inferred(); + definePatternAliases(constructor.arguments().get(i), argType, source, destination); + } + } + } + + private List constructorPatternTypes(String constructor, Ast.TypeRef subjectType) { + if (subjectType == null) return List.of(); + if (constructor.equals("Some") && subjectType.name().equals("Option") && subjectType.arguments().size() == 1) { + return List.of(subjectType.arguments().getFirst()); + } + if (constructor.equals("Ok") && subjectType.name().equals("Result") && subjectType.arguments().size() == 2) { + return List.of(subjectType.arguments().get(0)); + } + if (constructor.equals("Err") && subjectType.name().equals("Result") && subjectType.arguments().size() == 2) { + return List.of(subjectType.arguments().get(1)); + } + return List.of(); + } + + private void defineRefinementAlias(String name, Ast.TypeRef narrowedType, VarState source, Scope destination) { + ValueKind kind = source == null ? kindOfType(narrowedType) : source.kind; + VarState alias = new VarState(narrowedType, false, kind, Origin.LOCAL); + alias.aliasSource = source == null ? null : ownershipRoot(source); + destination.define(name, alias); + } + + private void collectConditionBindingNames(Ast.Expr condition, Set names) { + if (condition instanceof Ast.TypeTestExpr test && test.binding() != null) names.add(test.binding()); + else if (condition instanceof Ast.PatternTestExpr test) collectPatternBindingNames(test.pattern(), names); + else if (condition instanceof Ast.BinaryExpr binary && binary.operator().equals("&&")) { + collectConditionBindingNames(binary.left(), names); + collectConditionBindingNames(binary.right(), names); + } + } + + private void collectPatternBindingNames(Ast.Pattern pattern, Set names) { + if (pattern instanceof Ast.BindingPattern binding) names.add(binding.name()); + else if (pattern instanceof Ast.TypePattern typed && typed.binding() != null) names.add(typed.binding()); + else if (pattern instanceof Ast.ConstructorPattern constructor) { + for (Ast.Pattern nested : constructor.arguments()) collectPatternBindingNames(nested, names); + } + } + + private Ast.ClassDecl classNamespaceOf(Ast.Expr expr, Scope scope) { + if (expr instanceof Ast.NameExpr name) { + if (scope.lookup(name.name()) != null) return null; + return findClass(name.name()); + } + if (expr instanceof Ast.MemberExpr member + && member.receiver() instanceof Ast.NameExpr namespace + && scope.lookup(namespace.name()) == null) { + return findClass(namespace.name() + "." + member.member()); + } + return null; + } + + private Ast.TypeRef receiverType(Ast.Expr receiver, Scope scope) { + Ast.TypeRef type = null; + if (receiver instanceof Ast.NameExpr name) { + VarState state = scope.lookup(name.name()); + if (state != null) type = state.type; + } else if (receiver instanceof Ast.NewExpr created) type = created.type(); + if (type == null) return null; + if (type.isBorrow()) type = type.borrowedTarget(); + if (isMutexGuardType(type)) type = type.arguments().getFirst(); + return type; + } + + private Ast.ClassDecl classOfReceiver(Ast.Expr receiver, Scope scope) { + Ast.TypeRef type = receiverType(receiver, scope); + return type == null ? null : findClass(type.name()); + } + + private Map genericBindings(List names, List arguments) { + Map result = new HashMap<>(); + if (names.size() != arguments.size()) return result; + for (int i = 0; i < names.size(); i++) result.put(names.get(i), arguments.get(i)); + return result; + } + + private Ast.TypeRef substituteType(Ast.TypeRef type, Map bindings) { + if (type == null) return null; + if (type.isBorrow()) { + return Ast.TypeRef.borrowed(substituteType(type.borrowedTarget(), bindings), type.mutableBorrow()); + } + Ast.TypeRef replacement = bindings.get(type.name()); + if (replacement != null && type.arguments().isEmpty() && !type.inferArguments()) return replacement; + return new Ast.TypeRef( + type.name(), + type.arguments().stream().map(arg -> substituteType(arg, bindings)).toList(), + type.inferArguments()); + } + + private Ast.TypeRef concreteParentType(Ast.TypeRef parentRef, Ast.ClassDecl child, Ast.TypeRef childType) { + return substituteType(parentRef, genericBindings(child.genericParameters(), childType.arguments())); + } + + private Ast.TypeRef inferConstructedType(Ast.NewExpr created, List argumentTypes) { + if (!created.type().inferArguments()) return created.type(); + Ast.ClassDecl klass = findClass(created.type().name()); + if (klass == null || klass.genericParameters().isEmpty()) return created.type(); + + Ast.TypeRef patternType = new Ast.TypeRef( + created.type().name(), + klass.genericParameters().stream().map(Ast.TypeRef::simple).toList(), + false); + List fields = effectiveFieldTargets(klass, patternType, new LinkedHashSet<>()); + Map bindings = new HashMap<>(); + Set genericNames = Set.copyOf(klass.genericParameters()); + + for (int i = 0; i < Math.min(argumentTypes.size(), fields.size()); i++) { + ResolvedField target = fields.get(i); + Ast.TypeRef fieldPattern = substituteType( + target.field().type(), + genericBindings(target.owner().genericParameters(), target.ownerType().arguments())); + inferGenericBindings(fieldPattern, argumentTypes.get(i), genericNames, bindings, Set.of()); + } + + List inferred = new ArrayList<>(klass.genericParameters().size()); + for (String generic : klass.genericParameters()) { + Ast.TypeRef bound = bindings.get(generic); + if (bound == null || bound.name().equals("$infer$")) return created.type(); + inferred.add(bound); + } + return new Ast.TypeRef(created.type().name(), inferred, false); + } + + private List effectiveFieldTargets( + Ast.ClassDecl klass, Ast.TypeRef concreteType, Set stack) { + if (!stack.add(klass)) return List.of(); + LinkedHashMap fields = new LinkedHashMap<>(); + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent == null) continue; + Ast.TypeRef parentType = concreteParentType(parentRef, klass, concreteType); + for (ResolvedField field : effectiveFieldTargets(parent, parentType, stack)) { + fields.putIfAbsent(field.field().name(), field); + } + } + for (Ast.FieldDecl field : klass.fields()) { + fields.put(field.name(), new ResolvedField(klass, concreteType, field)); + } + stack.remove(klass); + return List.copyOf(fields.values()); + } + + private ResolvedField findFieldTarget( + Ast.ClassDecl klass, Ast.TypeRef concreteType, String name, Set seen) { + if (!seen.add(klass)) return null; + for (Ast.FieldDecl field : klass.fields()) { + if (field.name().equals(name)) { + seen.remove(klass); + return new ResolvedField(klass, concreteType, field); + } + } + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent == null) continue; + Ast.TypeRef parentType = concreteParentType(parentRef, klass, concreteType); + ResolvedField found = findFieldTarget(parent, parentType, name, seen); + if (found != null) { + seen.remove(klass); + return found; + } + } + seen.remove(klass); + return null; + } + + private Ast.MethodDecl findStaticMethod( + Ast.ClassDecl klass, String name, int arity, Set seen) { + if (!seen.add(klass)) return null; + for (Ast.MethodDecl method : klass.methods()) { + if (method.isStatic() && method.name().equals(name) && method.parameters().size() == arity) { + seen.remove(klass); + return method; + } + } + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent == null) continue; + Ast.MethodDecl found = findStaticMethod(parent, name, arity, seen); + if (found != null) { + seen.remove(klass); + return found; + } + } + seen.remove(klass); + return null; + } + + private ResolvedMethod findMethodTarget( + Ast.ClassDecl klass, Ast.TypeRef concreteType, String name, int arity, Set seen) { + if (!seen.add(klass)) return null; + for (Ast.MethodDecl method : klass.methods()) { + if (!method.isStatic() && method.name().equals(name) && method.parameters().size() == arity) { + seen.remove(klass); + return new ResolvedMethod(klass, concreteType, method); + } + } + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent == null) continue; + Ast.TypeRef parentType = concreteParentType(parentRef, klass, concreteType); + ResolvedMethod found = findMethodTarget(parent, parentType, name, arity, seen); + if (found != null) { + seen.remove(klass); + return found; + } + } + seen.remove(klass); + return null; + } + + private Ast.TypeRef syntacticType(Ast.Expr expr, Scope scope) { + if (expr instanceof Ast.LiteralExpr literal) return inferLiteralType(literal.value()); + if (expr instanceof Ast.NameExpr name) { + VarState state = scope.lookup(name.name()); + return state == null ? Ast.TypeRef.inferred() : state.type; + } + if (expr instanceof Ast.NewExpr created) return created.type(); + if (expr instanceof Ast.UnaryExpr unary) { + Ast.TypeRef operand = syntacticType(unary.operand(), scope); + if (unary.operator().equals("&") || unary.operator().equals("&mut")) { + return Ast.TypeRef.borrowed(operand, unary.operator().equals("&mut")); + } + return operand; + } + if (expr instanceof Ast.TupleExpr tuple) { + return Ast.TypeRef.tupleType(tuple.elements().stream().map(item -> syntacticType(item, scope)).toList()); + } + if (expr instanceof Ast.ListExpr list && !list.elements().isEmpty()) { + return new Ast.TypeRef("Array", List.of(syntacticType(list.elements().getFirst(), scope)), false); + } + return Ast.TypeRef.inferred(); + } + + private void inferGenericBindings( + Ast.TypeRef pattern, + Ast.TypeRef actual, + Set genericNames, + Map bindings, + Set fixed) { + if (pattern == null || actual == null || actual.name().equals("$infer$")) return; + if (genericNames.contains(pattern.name()) && pattern.arguments().isEmpty() && !pattern.inferArguments()) { + if (!fixed.contains(pattern.name())) bindings.putIfAbsent(pattern.name(), actual); + return; + } + if (pattern.isBorrow() && actual.isBorrow()) { + inferGenericBindings(pattern.borrowedTarget(), actual.borrowedTarget(), genericNames, bindings, fixed); + return; + } + if (pattern.name().equals(actual.name()) && pattern.arguments().size() == actual.arguments().size()) { + for (int i = 0; i < pattern.arguments().size(); i++) { + inferGenericBindings(pattern.arguments().get(i), actual.arguments().get(i), genericNames, bindings, fixed); + } + } + } + + private CallSignature specializeCall( + List allGenericNames, + List explicitGenericNames, + List parameters, + Ast.TypeRef result, + Ast.CallExpr call, + Scope scope, + Map initialBindings) { + Map bindings = new HashMap<>(initialBindings); + Set fixed = new HashSet<>(initialBindings.keySet()); + if (!call.typeArguments().isEmpty() && call.typeArguments().size() == explicitGenericNames.size()) { + for (int i = 0; i < explicitGenericNames.size(); i++) { + bindings.put(explicitGenericNames.get(i), call.typeArguments().get(i)); + fixed.add(explicitGenericNames.get(i)); + } + } + for (int i = 0; i < Math.min(parameters.size(), call.arguments().size()); i++) { + inferGenericBindings(parameters.get(i).type(), syntacticType(call.arguments().get(i), scope), + Set.copyOf(allGenericNames), bindings, fixed); + } + List specialized = parameters.stream() + .map(param -> new Ast.Param( + substituteType(param.type(), bindings), + param.name(), param.structural(), param.mutable())) + .toList(); + return new CallSignature(specialized, substituteType(result, bindings)); + } + + private Ast.FieldDecl findField(Ast.ClassDecl klass, String name, Set seen) { + if (!seen.add(klass)) return null; + for (Ast.FieldDecl field : klass.fields()) { + if (field.name().equals(name)) { + seen.remove(klass); + return field; + } + } + for (Ast.TypeRef parent : klass.parents()) { + Ast.ClassDecl p = findClass(parent.name()); + if (p == null) continue; + Ast.FieldDecl found = findField(p, name, seen); + if (found != null) { + seen.remove(klass); + return found; + } + } + seen.remove(klass); + return null; + } + + private boolean hasMethodNamed(Ast.ClassDecl klass, String name, Set seen) { + if (!seen.add(klass)) return false; + for (Ast.MethodDecl method : klass.methods()) { + if (!method.isStatic() && method.name().equals(name)) { + seen.remove(klass); + return true; + } + } + for (Ast.TypeRef parent : klass.parents()) { + Ast.ClassDecl p = findClass(parent.name()); + if (p == null) continue; + if (hasMethodNamed(p, name, seen)) { + seen.remove(klass); + return true; + } + } + seen.remove(klass); + return false; + } + + private Ast.FunctionDecl findFunction(String name) { + if (ambiguousFunctions.contains(name)) return null; + return functions.get(name); + } + + private Ast.ClassDecl findClass(String name) { + if (ambiguousClasses.contains(name)) return null; + return classes.get(name); + } + + private VarState ownershipRoot(VarState state) { + VarState current = state; + Set seen = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + while (current.aliasSource != null && seen.add(current)) current = current.aliasSource; + return current; + } + + private void requireUsable(VarState state, String name, boolean write) { + VarState root = ownershipRoot(state); + if (root.moved) throw error("use of moved value '" + name + "'"); + if (write) { + if (root.mutableBorrowed && state.kind != ValueKind.MUT_BORROW) throw error("cannot mutate '" + name + "' while mutably borrowed"); + if (root.immutableBorrows > 0) throw error("cannot mutate '" + name + "' while immutably borrowed"); + } else if (root.mutableBorrowed && state.kind != ValueKind.MUT_BORROW) { + throw error("cannot read '" + name + "' while it is mutably borrowed"); + } + } + + private void move(VarState state, String name) { + VarState root = ownershipRoot(state); + requireUsable(root, name, false); + if (root.immutableBorrows > 0 || root.mutableBorrowed) throw error("cannot move '" + name + "' while it is borrowed"); + root.moved = true; + } + + private VarState requireState(Scope scope, String name) { + VarState state = scope.lookup(name); + if (state == null) throw error("unknown owned binding '" + name + "'"); + state.debugName = name; + return state; + } + + private Map stateSnapshot(Scope scope) { + Map result = new IdentityHashMap<>(); + for (VarState state : scope.visibleStates()) { + VarState root = ownershipRoot(state); + result.putIfAbsent(root, new StateSnapshot(root.moved, root.immutableBorrows, root.mutableBorrowed)); + } + return result; + } + + private void restoreState(Map snapshot) { + for (Map.Entry entry : snapshot.entrySet()) { + VarState state = entry.getKey(); + StateSnapshot saved = entry.getValue(); + state.moved = saved.moved(); + state.immutableBorrows = saved.immutableBorrows(); + state.mutableBorrowed = saved.mutableBorrowed(); + } + } + + private void mergeBranchState(Map base, List> exits) { + restoreState(base); + for (VarState state : base.keySet()) { + boolean movedOnAnyPath = base.get(state).moved(); + for (Map exit : exits) { + StateSnapshot saved = exit.get(state); + if (saved != null) movedOnAnyPath |= saved.moved(); + } + state.moved = movedOnAnyPath; + } + } + + private Map movedSnapshot(Scope scope) { + Map result = new IdentityHashMap<>(); + for (VarState state : scope.visibleStates()) { + VarState root = ownershipRoot(state); + result.putIfAbsent(root, root.moved); + } + return result; + } + + private void rejectLoopMoves(Map before, Scope after) { + for (Map.Entry entry : before.entrySet()) { + if (!entry.getValue() && entry.getKey().moved && entry.getKey().kind != ValueKind.COPY) { + throw error("cannot move outer value '" + entry.getKey().debugName + "' from a repeating loop; borrow it or move it before entering the loop"); + } + } + } + + private boolean isRootedAtActorSelf(Ast.Expr expr, Scope scope) { + Ast.Expr current = expr; + while (true) { + if (current instanceof Ast.MemberExpr member) current = member.receiver(); + else if (current instanceof Ast.IndexExpr indexed) current = indexed.receiver(); + else break; + } + if (!(current instanceof Ast.NameExpr name)) return false; + VarState root = scope.lookup(name.name()); + return root != null && isActorConfinedBorrow(root); + } + + private boolean isActorConfinedBorrow(VarState state) { + VarState current = state; + Set seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + while (current != null && seen.add(current)) { + if ("self".equals(current.debugName) + && current.kind == ValueKind.MUT_BORROW + && current.origin == Origin.PARAM) { + Ast.TypeRef type = current.type.isBorrow() ? current.type.borrowedTarget() : current.type; + Ast.ClassDecl klass = findClass(type.name()); + return klass != null && klass.actorKind() != Ast.ActorKind.NONE; + } + current = current.aliasSource != null ? current.aliasSource : current.borrowSource; + } + return false; + } + + private Ast.TypeRef channelElementType(Ast.TypeRef type) { + if (type == null) return Ast.TypeRef.inferred(); + Ast.TypeRef concrete = type.isBorrow() ? type.borrowedTarget() : type; + if (concrete.name().equals("Channel") && concrete.arguments().size() == 1) { + return concrete.arguments().getFirst(); + } + return Ast.TypeRef.inferred(); + } + + private List prepareDeferredSelectCaptures( + Ast.SelectStmt selected, + Scope outer) { + ArrayList perArm = + new ArrayList<>(selected.arms().size()); + CaptureSet union = new CaptureSet(); + + for (Ast.SelectArm arm : selected.arms()) { + CaptureSet captures = new CaptureSet(); + Set locals = new HashSet<>(); + if (arm.bindingName() != null) locals.add(arm.bindingName()); + scanStatements(arm.body(), locals, outer, null, captures); + perArm.add(captures); + + for (Capture capture : captures.values.values()) { + union.add(capture.name, capture.source, capture.write); + } + } + + for (Capture capture : union.values.values()) { + VarState source = capture.source; + source.debugName = capture.name; + requireUsable(source, capture.name, capture.write); + + // self is a runtime-owned actor borrow. The continuation is + // guaranteed to re-enter the same actor under its single-turn + // execution lease, so this borrow may span the deferred arm. + if (isActorConfinedBorrow(source)) continue; + + if (source.kind == ValueKind.IMM_BORROW + || source.kind == ValueKind.MUT_BORROW + || source.type.isBorrow()) { + throw error("nb select continuation cannot capture borrowed value '" + + capture.name + + "'; capture owned data or actor self instead"); + } + if (containsMutexGuardType(source.type)) { + throw error("nb select continuation cannot capture guard-bearing value '" + + capture.name + "'"); + } + + if (capture.write) { + if (!source.mutable) { + throw error("nb select continuation cannot mutate immutable capture '" + + capture.name + "'"); + } + if (source.immutableBorrows > 0 || source.mutableBorrowed) { + throw error("nb select continuation cannot capture '" + + capture.name + "' mutably while borrowed"); + } + move(source, capture.name); + } else if (source.kind == ValueKind.MOVE_ONLY) { + // Registration owns this value from this point forward. + move(source, capture.name); + } + } + + return List.copyOf(perArm); + } + + private Scope deferredSelectArmScope(CaptureSet captures) { + Scope continuation = new Scope(null); + for (Capture capture : captures.values.values()) { + VarState source = capture.source; + + if (isActorConfinedBorrow(source)) { + VarState self = new VarState( + source.type, + false, + ValueKind.MUT_BORROW, + Origin.PARAM); + continuation.define(capture.name, self); + continue; + } + + boolean mutable = capture.write; + continuation.define( + capture.name, + new VarState( + source.type, + mutable, + source.kind, + Origin.CAPTURE)); + } + return continuation; + } + + private Ast.TypeRef collectionElementType(Ast.TypeRef type) { + if (type == null) return Ast.TypeRef.inferred(); + Ast.TypeRef concrete = type.isBorrow() ? type.borrowedTarget() : type; + if ((concrete.name().equals("Array") || concrete.name().equals("List") + || concrete.name().equals("DynamicStruct")) && concrete.arguments().size() == 1) { + return concrete.arguments().getFirst(); + } + if (concrete.isTupleType() && !concrete.arguments().isEmpty()) { + Ast.TypeRef first = concrete.arguments().getFirst(); + boolean same = concrete.arguments().stream().allMatch(first::equals); + return same ? first : Ast.TypeRef.inferred(); + } + return Ast.TypeRef.inferred(); + } + + private Ast.TypeRef sequenceDestructureBindingType(Ast.TypeRef source, int index) { + if (source == null) return Ast.TypeRef.inferred(); + Ast.TypeRef concrete = source.isBorrow() ? source.borrowedTarget() : source; + if (concrete.isTupleType() && index < concrete.arguments().size()) { + return concrete.arguments().get(index); + } + if ((concrete.name().equals("Array") || concrete.name().equals("List")) + && concrete.arguments().size() == 1) { + return concrete.arguments().getFirst(); + } + return Ast.TypeRef.inferred(); + } + + private Ast.TypeRef destructureBindingType(Ast.DestructureStmt destructure, Ast.TypeRef source, int index, String name) { + if (source == null) return Ast.TypeRef.inferred(); + if (destructure.kind() == Ast.DestructureKind.SEQUENCE) { + return sequenceDestructureBindingType(source, index); + } else { + Ast.TypeRef concrete = source.isBorrow() ? source.borrowedTarget() : source; + if (concrete.isRecordType()) { + Ast.TypeRef member = concrete.recordMembers().get(name); + if (member != null) return member; + } + if (concrete.name().equals("DynamicStruct") && concrete.arguments().size() == 1) { + return concrete.arguments().getFirst(); + } + Ast.ClassDecl klass = findClass(concrete.name()); + if (klass != null) { + ResolvedField target = findFieldTarget(klass, concrete, name, new LinkedHashSet<>()); + if (target != null && target.field().visibility() == Ast.Visibility.PUBLIC) { + return substituteType( + target.field().type(), + genericBindings(target.owner().genericParameters(), target.ownerType().arguments())); + } + } + } + return Ast.TypeRef.inferred(); + } + + private Ast.TypeRef iterableElementType(Ast.TypeRef iterableType) { + if (iterableType == null) return Ast.TypeRef.inferred(); + if ((iterableType.name().equals("Array") || iterableType.name().equals("List")) + && iterableType.arguments().size() == 1) { + return iterableType.arguments().getFirst(); + } + return Ast.TypeRef.inferred(); + } + + private Ast.TypeRef joinConditionalType(Ast.TypeRef left, Ast.TypeRef right) { + if (left == null) return right == null ? Ast.TypeRef.inferred() : right; + if (right == null) return left; + if (left.equals(right)) return left; + if (left.name().equals("$infer$")) return right; + if (right.name().equals("$infer$")) return left; + return Ast.TypeRef.union(List.of(left, right)); + } + + private ValueKind kindOfType(Ast.TypeRef type) { + if (type == null) return ValueKind.MOVE_ONLY; + if (type.isBorrow()) return type.mutableBorrow() ? ValueKind.MUT_BORROW : ValueKind.IMM_BORROW; + return isCopyType(type) ? ValueKind.COPY : ValueKind.MOVE_ONLY; + } + + private boolean isCopyType(Ast.TypeRef type) { + if (type == null || type.isBorrow()) return false; + if (type.isUnion()) return type.arguments().stream().allMatch(this::isCopyType); + return switch (type.name()) { + case "i8","i16","i32","i64","u8","u16","u32","u64","int","uint","bigint", + "f32","f64","float","decimal","complex64","complex128","complex", + "bool","Bool","string","String","void","SharedMutex", + "Channel","SelectCase","SelectSet","OptionUnwrapError" -> true; + case "Option" -> type.arguments().size() == 1 && isCopyType(type.arguments().getFirst()); + case "Result" -> type.arguments().size() == 2 + && isCopyType(type.arguments().get(0)) + && isCopyType(type.arguments().get(1)); + default -> false; + }; + } + + private boolean isScopedMutexReceiver(VarState state) { + return isMutexGuardType(state.type) + || (mutexCriticalSectionDepth > 0 && state.type.isBorrow() && state.type.mutableBorrow()); + } + + private static boolean isMutexGuardType(Ast.TypeRef type) { + return type != null && !type.isBorrow() && type.name().equals("MutexGuard") && type.arguments().size() == 1; + } + + private static boolean containsMutexGuardType(Ast.TypeRef type) { + if (type == null) return false; + if (type.isBorrow()) return containsMutexGuardType(type.borrowedTarget()); + if (isMutexGuardType(type)) return true; + for (Ast.TypeRef argument : type.arguments()) { + if (containsMutexGuardType(argument)) return true; + } + return false; + } + + private static boolean containsAcquiredMutexGuardType(Ast.TypeRef type) { + if (type == null || type.isBorrow()) return false; + if (isMutexGuardType(type)) return true; + if (type.name().equals("Future")) return false; + for (Ast.TypeRef argument : type.arguments()) { + if (containsAcquiredMutexGuardType(argument)) return true; + } + return false; + } + + private Ast.TypeRef ownershipFieldType(Ast.FieldDecl field) { + if (field.type() != null) return field.type(); + if (field.initializer() instanceof Ast.LiteralExpr literal) { + return inferLiteralType(literal.value()); + } + return Ast.TypeRef.inferred(); + } + + private Ast.TypeRef inferLiteralType(Object value) { + if (value instanceof Boolean) return Ast.TypeRef.simple("bool"); + if (value instanceof Long) return Ast.TypeRef.simple("int"); + if (value instanceof Double) return Ast.TypeRef.simple("float"); + if (value instanceof String) return Ast.TypeRef.simple("String"); + if (value instanceof Ast.Imaginary) return Ast.TypeRef.simple("complex"); + return Ast.TypeRef.inferred(); + } + + private IllegalArgumentException error(String message) { + return new IllegalArgumentException("Oreslang ownership error: " + message); + } + + private enum ValueKind { COPY, MOVE_ONLY, IMM_BORROW, MUT_BORROW } + private enum Origin { PARAM, LOCAL, CAPTURE } + + private static final class ValueInfo { + private final Ast.TypeRef type; + private final ValueKind kind; + private final VarState borrowSource; + private ValueInfo(Ast.TypeRef type, ValueKind kind, VarState borrowSource) { + this.type = type; + this.kind = kind; + this.borrowSource = borrowSource; + } + } + + private static final class VarState { + private Ast.TypeRef type; + private final boolean mutable; + private ValueKind kind; + private final Origin origin; + private boolean moved; + private int immutableBorrows; + private boolean mutableBorrowed; + private VarState borrowSource; + /** Narrowing/pattern alias: ownership operations are forwarded to this root place. */ + private VarState aliasSource; + private String debugName = ""; + + private VarState(Ast.TypeRef type, boolean mutable, ValueKind kind, Origin origin) { + this.type = type; + this.mutable = mutable; + this.kind = kind; + this.origin = origin; + } + } + + private static final class Scope { + private final Scope parent; + private final boolean descendantsNonLexical; + private final Map locals = new LinkedHashMap<>(); + private boolean closed; + + private Scope(Scope parent) { this(parent, parent != null && parent.descendantsNonLexical); } + private Scope(Scope parent, boolean descendantsNonLexical) { + this.parent = parent; + this.descendantsNonLexical = descendantsNonLexical; + } + private boolean descendantsNonLexical() { return descendantsNonLexical; } + + private void define(String name, VarState state) { + if (locals.putIfAbsent(name, state) != null) throw new IllegalArgumentException("Oreslang ownership error: duplicate binding '" + name + "'"); + state.debugName = name; + } + + private VarState lookup(String name) { + VarState local = locals.get(name); + return local != null ? local : parent == null ? null : parent.lookup(name); + } + + private List visibleStates() { + ArrayList result = new ArrayList<>(); + if (parent != null) result.addAll(parent.visibleStates()); + result.addAll(locals.values()); + return result; + } + + private boolean hasLiveMutexGuard() { + for (VarState state : visibleStates()) { + if (!state.moved && containsAcquiredMutexGuardType(state.type)) return true; + } + return false; + } + + private void close() { + if (closed) return; + closed = true; + for (VarState state : locals.values()) { + if (state.borrowSource != null) { + if (state.kind == ValueKind.MUT_BORROW) state.borrowSource.mutableBorrowed = false; + else if (state.kind == ValueKind.IMM_BORROW) state.borrowSource.immutableBorrows--; + } + } + } + } + + private record StateSnapshot(boolean moved, int immutableBorrows, boolean mutableBorrowed) { } + + private record Capture(String name, VarState source, boolean write) { } + + private static final class CaptureSet { + private final Map values = new LinkedHashMap<>(); + private void add(String name, VarState state, boolean write) { + Capture existing = values.get(name); + values.put(name, existing == null ? new Capture(name, state, write) + : new Capture(name, state, existing.write() || write)); + } + } +} diff --git a/src/main/java/dev/oreslang/types/TypeChecker.java b/src/main/java/dev/oreslang/types/TypeChecker.java new file mode 100644 index 00000000..a2b4d5e3 --- /dev/null +++ b/src/main/java/dev/oreslang/types/TypeChecker.java @@ -0,0 +1,3806 @@ +package dev.oreslang.types; + +import dev.oreslang.ast.AnnotationExpander; +import dev.oreslang.ast.Ast; +import dev.oreslang.imports.ImportRules; +import dev.oreslang.parser.Parser; +import dev.oreslang.types.Types.Function; +import dev.oreslang.types.Types.Borrow; +import dev.oreslang.types.Types.ClassNamespace; +import dev.oreslang.types.Types.Generic; +import dev.oreslang.types.Types.ListType; +import dev.oreslang.types.Types.Named; +import dev.oreslang.types.Types.Primitive; +import dev.oreslang.types.Types.Record; +import dev.oreslang.types.Types.StringLiteral; +import dev.oreslang.types.Types.Tuple; +import dev.oreslang.types.Types.Union; +import dev.oreslang.types.Types.Type; +import dev.oreslang.types.Types.Unknown; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Static semantic pass run before Oreslang code is lowered/executed. */ +public final class TypeChecker { + private record ResolvedMethod(Ast.ClassDecl owner, Named ownerType, Ast.MethodDecl method) { } + private record ResolvedInterfaceFunction( + Ast.InterfaceDecl owner, + Named ownerType, + Ast.InterfaceFunctionDecl function) { } + private record ResolvedField(Ast.ClassDecl owner, Named ownerType, Ast.FieldDecl field) { } + private final Map functions = new HashMap<>(); + private final Map classes = new HashMap<>(); + private final Map interfaces = new HashMap<>(); + private final Map typeAliases = new HashMap<>(); + private final Map modules = new HashMap<>(); + + private final IdentityHashMap classOwners = new IdentityHashMap<>(); + private final IdentityHashMap interfaceOwners = new IdentityHashMap<>(); + private final IdentityHashMap classShapeCache = new IdentityHashMap<>(); + + private final Set ambiguousFunctions = new HashSet<>(); + private final Set ambiguousClasses = new HashSet<>(); + private final Set ambiguousInterfaces = new HashSet<>(); + private final Set ambiguousTypeAliases = new HashSet<>(); + private final Set importedValues = new HashSet<>(); + private final Set resolvingAliases = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + private Ast.ActorKind currentActorKind = Ast.ActorKind.NONE; + private Ast.ClassDecl currentClassOwner; + private int loopDepth; + + public static Ast.Program check(Ast.Program program) { + program = AnnotationExpander.expand(program); + TypeChecker checker = new TypeChecker(); + checker.validateImports(program); + checker.collect(program); + checker.validate(program); + OwnershipChecker.check(program); + return program; + } + + private void validateImports(Ast.Program program) { + Set exposed = new HashSet<>(); + Set localNames = new HashSet<>(Set.of( + "stdio", "process", "actor", "print", "Some", "None", "Ok", "Err", + "Mutex", "SharedMutex", "Channel", "SelectCase", "SelectSet", "SelectResult", + "Object", "List", "Option", "Result", "Future", + "int", "uint", "float", "decimal", "complex", "bool", "String", "void", + "self", "null")); + + for (Ast.ModuleDecl module : program.modules()) { + if (!module.name().equals(Parser.ROOT_MODULE)) localNames.add(module.name()); + for (Ast.Decl declaration : module.declarations()) { + if (declaration instanceof Ast.FunctionDecl fn) localNames.add(fn.name()); + else if (declaration instanceof Ast.ClassDecl klass) localNames.add(klass.name()); + else if (declaration instanceof Ast.InterfaceDecl iface) localNames.add(iface.name()); + else if (declaration instanceof Ast.FieldDecl field) localNames.add(field.name()); + else if (declaration instanceof Ast.TypeAliasDecl alias) localNames.add(alias.name()); + } + } + + for (Ast.ImportDecl imported : program.imports()) { + ImportRules.validate(imported); + for (String name : ImportRules.exposedBindings(imported)) { + if (!exposed.add(name)) throw new IllegalArgumentException("duplicate imported name '" + name + "'"); + if (localNames.contains(name)) { + throw new IllegalArgumentException("imported name '" + name + "' conflicts with a local or builtin name"); + } + // Cross-file imports are opaque to this per-unit typechecker. + // Runtime-bearing imports enter the value namespace. Oreslang + // classes also have a value-side constructor/static namespace, + // but actors/interfaces/traits/structs/type aliases do not. + if (!ImportRules.isTypeOnlyKind(imported.kind()) + || imported.kind() == Ast.ImportKind.CLASS + || ImportRules.isJavaPath(imported.path())) { + importedValues.add(name); + } + } + } + } + + private void collect(Ast.Program program) { + for (Ast.ModuleDecl module : program.modules()) { + if (modules.putIfAbsent(module.name(), module) != null) throw new IllegalArgumentException("duplicate module '" + module.name() + "'"); + for (Ast.Decl decl : module.declarations()) { + if (decl instanceof Ast.FunctionDecl fn) { + putQualified(functions, ambiguousFunctions, module.name(), fn.name(), fn, fn.kind() == Ast.CallableKind.ROUTINE ? "routine" : "function"); + } else if (decl instanceof Ast.ClassDecl klass) { + putQualified(classes, ambiguousClasses, module.name(), klass.name(), klass, "class"); + classOwners.put(klass, module.name()); + } else if (decl instanceof Ast.InterfaceDecl iface) { + putQualified(interfaces, ambiguousInterfaces, module.name(), iface.name(), iface, "interface"); + interfaceOwners.put(iface, module.name()); + } else if (decl instanceof Ast.TypeAliasDecl alias) { + putQualified(typeAliases, ambiguousTypeAliases, module.name(), alias.name(), alias, "type alias"); + } + } + } + } + + private static void putQualified(Map map, Set ambiguous, String module, String name, T value, String kind) { + String qualified = module + "." + name; + if (map.putIfAbsent(qualified, value) != null) { + throw new IllegalArgumentException("duplicate " + kind + " '" + qualified + "'; fnc/routine declarations cannot overload"); + } + T previous = map.putIfAbsent(name, value); + if (previous != null && previous != value) { + ambiguous.add(name); + map.remove(name); + } + } + + private void validate(Ast.Program program) { + for (Ast.ClassDecl klass : classOwners.keySet()) classShape(klass, new LinkedHashSet<>()); + for (Ast.InterfaceDecl iface : interfaceOwners.keySet()) interfaceShape(iface, Set.copyOf(iface.genericParameters()), new LinkedHashSet<>()); + + for (Ast.ModuleDecl module : program.modules()) { + validateModuleValueNamespace(module); + checkModuleAdherence(module); + for (Ast.Decl decl : module.declarations()) { + if (decl instanceof Ast.FunctionDecl fn) checkFunction(module.name(), fn); + else if (decl instanceof Ast.ClassDecl klass) checkClass(module.name(), klass); + else if (decl instanceof Ast.InterfaceDecl iface) checkInterface(iface); + else if (decl instanceof Ast.TypeAliasDecl alias) { + Set aliasGenerics = uniqueGenerics(alias.genericParameters(), "type alias " + alias.name()); + resolve(alias.target(), aliasGenerics, null); + } + else if (decl instanceof Ast.FieldDecl field) checkModuleBinding(field); + } + } + } + + private void validateModuleValueNamespace(Ast.ModuleDecl module) { + Map categories = new LinkedHashMap<>(); + for (Ast.Decl decl : module.declarations()) { + String name; + String category; + if (decl instanceof Ast.FunctionDecl fn) { + name = fn.name(); + category = fn.kind() == Ast.CallableKind.ROUTINE ? "routine" : "fnc"; + } else if (decl instanceof Ast.ClassDecl klass) { + name = klass.name(); + category = "class"; + } else if (decl instanceof Ast.FieldDecl field) { + name = field.name(); + category = "binding"; + } else { + continue; // interfaces and aliases occupy the type namespace + } + + String previous = categories.putIfAbsent(name, category); + if (previous != null && !previous.equals(category)) { + throw new IllegalArgumentException( + "module value member '" + module.name() + "." + name + + "' is ambiguous between " + previous + " and " + category + + "; callable/class/binding names share one runtime value namespace"); + } + } + } + + private void checkModuleAdherence(Ast.ModuleDecl module) { + for (Ast.Annotation annotation : module.annotations()) { + if (!annotation.name().equals("AdheresTo")) continue; + if (annotation.arguments().isEmpty()) throw new IllegalArgumentException("@AdheresTo requires at least one interface"); + Record actual = moduleShape(module); + for (Ast.TypeRef ref : annotation.arguments()) { + Ast.InterfaceDecl iface = findInterface(ref.name()); + if (iface == null) throw new IllegalArgumentException("unknown module interface '" + ref.name() + "'"); + Type resolvedRef = resolve(ref, Set.of(), null); + if (!(resolvedRef instanceof Named namedRef)) throw new IllegalArgumentException("@AdheresTo target must be a named interface type"); + Record expectedTemplate = interfaceShape(iface, Set.copyOf(iface.genericParameters()), new LinkedHashSet<>()); + Record expected = (Record) substituteGenerics(expectedTemplate, + genericBindings(iface.genericParameters(), namedRef.arguments(), "interface " + iface.name())); + if (!assignable(actual, expected)) { + throw new IllegalArgumentException("module '" + module.name() + "' does not adhere to interface '" + ref.name() + "': expected " + expected + " but got " + actual); + } + } + } + } + + private Record moduleShape(Ast.ModuleDecl module) { + Map members = new LinkedHashMap<>(); + for (Ast.Decl decl : module.declarations()) { + if (decl instanceof Ast.FunctionDecl fn && fn.visibility() == Ast.Visibility.PUBLIC + && fn.actorKind() == Ast.ActorKind.NONE) { + Type signature = callableContractType( + fn.genericParameters(), fn.parameters(), fn.returnType(), fn.async(), Set.of(), null); + + // Only concretely reifiable fnc declarations become raw + // function-valued namespace members. Routines remain + // direct-call-only, and generic fnc values still require + // direct-call specialization because polymorphic function + // values are not implemented. + if (fn.kind() == Ast.CallableKind.FNC && fn.genericParameters().isEmpty()) { + mergeMember(members, fn.name(), signature, "module " + module.name()); + } + mergeMember(members, + methodContractKey(fn.name(), fn.parameters().size(), fn.genericParameters().size()), + signature, + "module " + module.name()); + } else if (decl instanceof Ast.FieldDecl field && field.visibility() == Ast.Visibility.PUBLIC) { + Type type = field.type() == null ? typeOf(field.initializer(), new Env(null), Set.of(), null) : resolve(field.type(), Set.of(), null); + mergeMember(members, field.name(), type, "module " + module.name()); + } + } + return new Record(members); + } + + private void checkInterface(Ast.InterfaceDecl iface) { + Set generics = uniqueGenerics(iface.genericParameters(), "interface " + iface.name()); + + Set interfaceFieldNames = new LinkedHashSet<>(); + collectInterfaceFieldNames(iface, interfaceFieldNames, new LinkedHashSet<>()); + Set interfaceMethodNames = new LinkedHashSet<>(); + collectInterfaceMethodNames(iface, interfaceMethodNames, new LinkedHashSet<>()); + for (String name : interfaceFieldNames) { + if (interfaceMethodNames.contains(name)) { + throw new IllegalArgumentException( + "interface member '" + iface.name() + "." + name + + "' cannot be both a field and a method across inheritance; " + + "member-value and direct-call syntax must remain unambiguous"); + } + } + + Set memberKeys = new HashSet<>(); + for (Ast.TypeRef parentRef : iface.parents()) { + if (findInterface(parentRef.name()) == null) throw new IllegalArgumentException("unknown parent interface '" + parentRef.name() + "' for " + iface.name()); + resolve(parentRef, generics, null); + } + + for (Ast.InterfaceMember member : iface.members()) { + if (member instanceof Ast.InterfaceFunctionDecl fn) { + String key = methodKey(fn.name(), fn.parameters().size()); + if (!memberKeys.add(key)) throw new IllegalArgumentException("duplicate interface method '" + iface.name() + "." + fn.name() + "' with arity " + fn.parameters().size()); + Set all = new HashSet<>(generics); + for (String generic : fn.genericParameters()) { + if (!all.add(generic)) throw new IllegalArgumentException("duplicate/shadowed generic '" + generic + "' in interface " + iface.name() + "." + fn.name()); + } + functionType(fn.parameters(), fn.returnType(), false, all, null); + } else { + Ast.InterfaceFieldDecl field = (Ast.InterfaceFieldDecl) member; + if (!memberKeys.add(field.name())) throw new IllegalArgumentException("duplicate interface member '" + iface.name() + "." + field.name() + "'"); + resolve(field.type(), generics, null); + } + } + } + + private void checkFunction(String module, Ast.FunctionDecl fn) { + if (fn.name().equals("init")) { + Ast.TypeRef initReturn = fn.returnType(); + if (fn.visibility() != Ast.Visibility.PRIVATE + || fn.async() + || fn.nonLexical() + || fn.actorKind() != Ast.ActorKind.NONE + || !fn.genericParameters().isEmpty() + || !fn.parameters().isEmpty() + || initReturn == null + || !"void".equals(initReturn.name()) + || !initReturn.arguments().isEmpty() + || initReturn.inferArguments()) { + throw new IllegalArgumentException( + "init hook must be a private synchronous non-actor non-generic zero-arity " + + "fnc/routine returning void"); + } + } + if (fn.name().equals("main") && fn.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException( + "program entrypoint 'main' cannot be an actor fnc; main must run synchronously and explicitly launch actors"); + } + if (fn.async() && fn.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException( + "async actor callables require mailbox continuation lowering; use an ordinary async fnc or a mailbox actor"); + } + if (fn.async() && !fn.genericParameters().isEmpty()) { + throw new IllegalArgumentException( + "generic async callables require an explicit task-safe/sendable generic bound, which is not available yet"); + } + Set generics = uniqueGenerics(fn.genericParameters(), (fn.kind() == Ast.CallableKind.ROUTINE ? "routine " : "function ") + fn.name()); + Env env = new Env(moduleBindingEnv(module), fn.nonLexical()); + for (Ast.Param param : fn.parameters()) { + Type parameterType = resolveParam(param, generics, null); + if (fn.actorKind() != Ast.ActorKind.NONE) { + validateActorCallableBoundaryType( + parameterType, + fn.actorKind(), + false, + "parameter '" + param.name() + "' of actor callable '" + module + "." + fn.name() + "'"); + } + if (fn.async()) { + validateAsyncBoundaryType( + parameterType, + false, + "parameter '" + param.name() + "' of async callable '" + module + "." + fn.name() + "'"); + } + env.define( + param.name(), + parameterType, + param.mutable() ? Ast.BindingKind.LET : Ast.BindingKind.VAL); + } + Type returns = resolve(fn.returnType(), generics, null); + if (fn.actorKind() != Ast.ActorKind.NONE) { + validateActorCallableBoundaryType( + returns, + fn.actorKind(), + true, + "return type of actor callable '" + module + "." + fn.name() + "'"); + } + if (fn.async()) { + validateAsyncBoundaryType( + returns, + true, + "return type of async callable '" + module + "." + fn.name() + "'"); + } + Ast.ActorKind previousActorKind = currentActorKind; + currentActorKind = fn.actorKind(); + try { + checkBlock(fn.body(), env, generics, returns, null); + } finally { + currentActorKind = previousActorKind; + } + if (returns != Primitive.VOID && !definitelyReturns(fn.body())) { + throw new IllegalArgumentException("non-void " + fn.kind().name().toLowerCase() + " '" + module + "." + fn.name() + "' must explicitly return on every path"); + } + if (fn.visibility() == Ast.Visibility.PUBLIC && hasInferredArgs(fn.returnType())) { + throw new IllegalArgumentException("public callable '" + fn.name() + "' cannot export unresolved <> type arguments"); + } + } + + private void checkClass(String module, Ast.ClassDecl klass) { + Set classGenerics = uniqueGenerics(klass.genericParameters(), "class " + klass.name()); + Type self = nominalClassType(klass); + + Set parentNames = new HashSet<>(); + for (Ast.TypeRef parent : klass.parents()) { + if (!parentNames.add(parent.name())) throw new IllegalArgumentException("duplicate parent class '" + parent.name() + "' on " + klass.name()); + if (parent.name().equals("Object") || parent.name().equals("List")) { + if (parent.inferArguments() || !parent.arguments().isEmpty()) { + throw new IllegalArgumentException("built-in inheritance marker '" + parent.name() + "' does not take type arguments"); + } + resolveClassParent(parent, klass); + if (klass.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException("actor '" + klass.name() + + "' cannot extend built-in class '" + parent.name() + + "'; actor inheritance must preserve the actor isolation domain"); + } + continue; + } + resolve(parent, classGenerics, self); + Ast.ClassDecl resolvedParent = resolveClassParent(parent, klass); + if (resolvedParent != null && klass.actorKind() != resolvedParent.actorKind()) { + throw new IllegalArgumentException("actor isolation kind must be preserved across inheritance: " + + klass.name() + " is " + klass.actorKind() + " but parent " + + resolvedParent.name() + " is " + resolvedParent.actorKind()); + } + } + + validateFieldLayout(klass); + + Set effectiveFieldNames = new LinkedHashSet<>(); + for (ResolvedField field : effectiveFieldTargets( + klass, nominalClassType(klass), new LinkedHashSet<>())) { + effectiveFieldNames.add(field.field().name()); + } + Set effectiveInstanceMethodNames = new LinkedHashSet<>(); + collectInstanceMethodNames(klass, effectiveInstanceMethodNames, new LinkedHashSet<>()); + for (String name : effectiveFieldNames) { + if (effectiveInstanceMethodNames.contains(name)) { + throw new IllegalArgumentException( + "class member '" + klass.name() + "." + name + + "' cannot be both a field and an instance method across inheritance; " + + "member-value and direct-call syntax must remain unambiguous"); + } + } + + Set localMethodSignatures = new HashSet<>(); + for (Ast.MethodDecl method : klass.methods()) { + String memberKind = method.isStatic() ? "static:" : "instance:"; + String signature = memberKind + methodKey(method.name(), method.arity()); + if (!localMethodSignatures.add(signature)) { + String label = method.isStatic() ? "static function" : "method"; + throw new IllegalArgumentException(label + " '" + klass.name() + "." + method.name() + "' already has arity " + method.arity() + + "; class callables may overload only by arity within their own static/instance namespace"); + } + } + + for (Ast.FieldDecl field : klass.fields()) { + if (klass.actorKind() != Ast.ActorKind.NONE && field.visibility() == Ast.Visibility.PUBLIC) { + throw new IllegalArgumentException("actor state field '" + klass.name() + "." + field.name() + + "' cannot be public; expose state through mailbox-dispatched methods"); + } + Type fieldType = classFieldType(klass, field); + if (field.initializer() != null && field.type() != null) { + Ast.ClassDecl previousClassOwner = currentClassOwner; + currentClassOwner = klass; + try { + Type actual = typeOf(field.initializer(), new Env(null), classGenerics, self); + requireAssignable(actual, fieldType, "field initializer " + klass.name() + "." + field.name()); + } finally { + currentClassOwner = previousClassOwner; + } + } + if (field.bindingKind() == Ast.BindingKind.CONST && field.initializer() != null && !constant(field.initializer())) { + throw new IllegalArgumentException("const field '" + field.name() + "' needs a compile-time constant initializer"); + } + } + + for (Ast.MethodDecl method : klass.methods()) { + if (method.async() && !method.isStatic()) { + throw new IllegalArgumentException( + "async instance method '" + klass.name() + "." + method.name() + + "' requires moving/borrowing the receiver into the task; use an async static fnc for now"); + } + if (method.async() && !method.genericParameters().isEmpty()) { + throw new IllegalArgumentException( + "generic async static functions require an explicit task-safe/sendable generic bound"); + } + Set generics = new HashSet<>(); + if (!method.isStatic()) generics.addAll(classGenerics); + for (String generic : method.genericParameters()) { + if (classGenerics.contains(generic) || !generics.add(generic)) { + throw new IllegalArgumentException("duplicate/shadowed generic '" + generic + "' in " + klass.name() + "." + method.name()); + } + } + + if (method.isStatic()) { + for (Ast.Param param : method.parameters()) { + rejectStaticClassGenericReference(param.type(), classGenerics, klass, method); + } + rejectStaticClassGenericReference(method.returnType(), classGenerics, klass, method); + for (Ast.Annotation annotation : method.annotations()) { + for (Ast.TypeRef argument : annotation.arguments()) { + rejectStaticClassGenericReference(argument, classGenerics, klass, method); + } + } + rejectStaticClassGenericReferences(method.body(), classGenerics, klass, method); + } + + if (method.explicitReceiverType() != null) { + Ast.TypeRef receiverRef = method.explicitReceiverType(); + boolean namesEnclosingClass = receiverRef.name().equals(klass.name()) || receiverRef.name().equals(qualifiedClassName(klass)) || receiverRef.name().equals("self"); + if (!namesEnclosingClass) { + Type receiver = resolve(receiverRef, generics, self); + requireAssignable(self, receiver, "explicit self receiver in " + klass.name() + "." + method.name()); + } + } + + Type callableSelf = method.isStatic() ? null : self; + Env env = new Env(null); + if (!method.isStatic()) env.define("self", self, Ast.BindingKind.VAL); + for (Ast.Param param : method.parameters()) { + Type parameterType = resolveParam(param, generics, callableSelf); + if (method.async()) { + validateAsyncBoundaryType( + parameterType, + false, + "parameter '" + param.name() + "' of async static function '" + klass.name() + "." + method.name() + "'"); + } + env.define(param.name(), parameterType, param.mutable() ? Ast.BindingKind.LET : Ast.BindingKind.VAL); + } + Type returns = resolve(method.returnType(), generics, callableSelf); + if (method.async()) { + validateAsyncBoundaryType( + returns, + true, + "return type of async static function '" + klass.name() + "." + method.name() + "'"); + } + Ast.ActorKind previousActorKind = currentActorKind; + Ast.ClassDecl previousClassOwner = currentClassOwner; + currentActorKind = method.isStatic() ? Ast.ActorKind.NONE : klass.actorKind(); + currentClassOwner = klass; + try { + checkBlock(method.body(), env, generics, returns, callableSelf); + } finally { + currentActorKind = previousActorKind; + currentClassOwner = previousClassOwner; + } + if (!method.isAbstract() && returns != Primitive.VOID && !definitelyReturns(method.body())) { + String label = method.isStatic() ? "static function" : "method"; + throw new IllegalArgumentException("non-void " + label + " '" + module + "." + klass.name() + "." + method.name() + "' must explicitly return on every path"); + } + } + + Set implemented = new HashSet<>(); + for (Ast.TypeRef interfaceRef : klass.interfaces()) { + if (!implemented.add(interfaceRef.name())) throw new IllegalArgumentException("duplicate implemented interface '" + interfaceRef.name() + "' on " + klass.name()); + Ast.InterfaceDecl iface = findInterface(interfaceRef.name()); + if (iface == null) throw new IllegalArgumentException("unknown interface '" + interfaceRef.name() + "' implemented by " + klass.name()); + Type resolvedInterface = resolve(interfaceRef, classGenerics, self); + if (!(resolvedInterface instanceof Named interfaceType)) throw new IllegalArgumentException("implemented interface must resolve to a named type"); + Record expectedTemplate = interfaceShape(iface, Set.copyOf(iface.genericParameters()), new LinkedHashSet<>()); + Record expected = (Record) substituteGenerics(expectedTemplate, + genericBindings(iface.genericParameters(), interfaceType.arguments(), "interface " + iface.name())); + Record actual = publicClassShape(klass, new LinkedHashSet<>()); + if (!assignable(actual, expected)) { + throw new IllegalArgumentException("class '" + klass.name() + "' does not implement interface '" + interfaceRef.name() + "': expected " + expected + " but got " + actual); + } + } + } + + private Env moduleBindingEnv(String moduleName) { + Env env = new Env(null); + Ast.ModuleDecl module = modules.get(moduleName); + if (module == null) return env; + for (Ast.Decl declaration : module.declarations()) { + if (!(declaration instanceof Ast.FieldDecl field) || field.initializer() == null) continue; + Type type = field.type() == null + ? typeOf(field.initializer(), env, Set.of(), null) + : resolve(field.type(), Set.of(), null); + env.define(field.name(), type, field.bindingKind()); + } + return env; + } + + private void checkModuleBinding(Ast.FieldDecl field) { + if (field.initializer() == null) throw new IllegalArgumentException("module binding '" + field.name() + "' requires an initializer"); + Type actual = typeOf(field.initializer(), new Env(null), Set.of(), null); + if (field.type() != null) requireAssignable(actual, resolve(field.type(), Set.of(), null), "initializer for " + field.name()); + if (field.bindingKind() == Ast.BindingKind.CONST && !constant(field.initializer())) { + throw new IllegalArgumentException("const '" + field.name() + "' needs a compile-time constant initializer"); + } + } + + private void checkBlock(List body, Env parent, Set generics, Type expectedReturn, Type self) { + Env env = new Env(parent); + for (Ast.Stmt stmt : body) checkStatement(stmt, env, generics, expectedReturn, self); + } + + private void checkLoopBlock(List body, Env parent, Set generics, Type expectedReturn, Type self) { + loopDepth++; + try { + checkBlock(body, parent, generics, expectedReturn, self); + } finally { + loopDepth--; + } + } + + private void checkCallableBlock(List body, Env parent, Set generics, Type expectedReturn, Type self) { + int previousLoopDepth = loopDepth; + loopDepth = 0; + try { + checkBlock(body, parent, generics, expectedReturn, self); + } finally { + loopDepth = previousLoopDepth; + } + } + + private void checkStatement(Ast.Stmt stmt, Env env, Set generics, Type expectedReturn, Type self) { + if (stmt instanceof Ast.BindingStmt binding) { + Type declaredAhead = binding.declaredType() == null ? null : resolve(binding.declaredType(), generics, self); + boolean recursiveLambda = binding.initializer() instanceof Ast.LambdaExpr; + if (recursiveLambda && declaredAhead instanceof Function) { + env.define(binding.name(), declaredAhead, binding.kind()); + } + if (binding.initializer() instanceof Ast.LambdaExpr lambda && declaredAhead instanceof Function expectedFunction) { + validateLambdaAgainstExpected(lambda, expectedFunction, env, generics, self); + } + Type actual = typeOfAgainstExpected(binding.initializer(), declaredAhead, env, generics, self); + Type declared = declaredAhead == null ? actual : declaredAhead; + requireAssignable(actual, declared, "initializer for " + binding.name()); + if (binding.kind() == Ast.BindingKind.CONST && !constant(binding.initializer())) { + throw new IllegalArgumentException("const '" + binding.name() + "' needs a compile-time constant initializer"); + } + if (recursiveLambda && declaredAhead instanceof Function) env.replace(binding.name(), declared, binding.kind()); + else env.define(binding.name(), declared, binding.kind()); + return; + } + if (stmt instanceof Ast.DestructureStmt destructure) { + Type source = deref(typeOf(destructure.initializer(), env, generics, self)); + if (destructure.kind() == Ast.DestructureKind.SEQUENCE) { + List elementTypes = sequenceDestructureTypes(source, destructure.bindings().size()); + for (int i = 0; i < destructure.bindings().size(); i++) { + defineDestructureBinding(env, destructure.bindings().get(i), elementTypes.get(i)); + } + } else { + for (Ast.DestructureBinding binding : destructure.bindings()) { + if (binding.isDiscard()) continue; + env.define(binding.name(), objectDestructureMemberType(source, binding.name()), binding.kind()); + } + } + return; + } + if (stmt instanceof Ast.BlockStmt block) { + checkBlock(block.body(), env, generics, expectedReturn, self); + return; + } + if (stmt instanceof Ast.BreakStmt) { + if (loopDepth == 0) throw new IllegalArgumentException("'break' may only appear inside loop or for"); + return; + } + if (stmt instanceof Ast.ContinueStmt) { + if (loopDepth == 0) throw new IllegalArgumentException("'continue' may only appear inside loop or for"); + return; + } + if (stmt instanceof Ast.ReturnStmt ret) { + if (ret.value() instanceof Ast.LambdaExpr lambda && expectedReturn instanceof Function expectedFunction) { + validateLambdaAgainstExpected(lambda, expectedFunction, env, generics, self); + } + Type actual = ret.value() == null + ? Primitive.VOID + : typeOfAgainstExpected(ret.value(), expectedReturn, env, generics, self); + requireAssignable(actual, expectedReturn, "return value"); + return; + } + if (stmt instanceof Ast.ExprStmt expression) { typeOf(expression.expression(), env, generics, self); return; } + if (stmt instanceof Ast.DeferStmt defer) { typeOf(defer.expression(), env, generics, self); return; } + if (stmt instanceof Ast.IfStmt conditional) { + for (Ast.IfBranch branch : conditional.branches()) { + requireAssignable(typeOfCondition(branch.condition(), env, generics, self), Primitive.BOOL, "if condition"); + Env branchEnv = new Env(env); + defineConditionBindings(branch.condition(), branchEnv, env, generics, self); + checkBlock(branch.body(), branchEnv, generics, expectedReturn, self); + } + checkBlock(conditional.elseBody(), env, generics, expectedReturn, self); + return; + } + if (stmt instanceof Ast.MatchStmt matched) { + Type subject = deref(typeOf(matched.subject(), env, generics, self)); + checkMatch(matched, subject, env, generics, expectedReturn, self); + return; + } + if (stmt instanceof Ast.SwitchStmt switched) { + Type subject = deref(typeOf(switched.subject(), env, generics, self)); + Set seen = new HashSet<>(); + for (Ast.SwitchCase arm : switched.cases()) { + if (arm.constants().isEmpty()) throw new IllegalArgumentException("switch case requires at least one constant"); + for (Ast.Expr constant : arm.constants()) { + if (!constant(constant)) throw new IllegalArgumentException("switch case must be a compile-time constant"); + Type actual = typeOf(constant, env, generics, self); + if (!assignable(actual, subject) && !assignable(subject, actual)) { + throw new IllegalArgumentException("switch case type " + actual + " is incompatible with " + subject); + } + String key = constant.toString(); + if (!seen.add(key)) throw new IllegalArgumentException("duplicate switch case: " + key); + } + checkBlock(arm.body(), env, generics, expectedReturn, self); + } + checkBlock(switched.defaultBody(), env, generics, expectedReturn, self); + return; + } + if (stmt instanceof Ast.TryStmt attempted) { + checkBlock(attempted.body(), env, generics, expectedReturn, self); + Env caught = new Env(env); + caught.define(attempted.errorName(), Unknown.INSTANCE, Ast.BindingKind.VAL); + checkBlock(attempted.catchBody(), caught, generics, expectedReturn, self); + checkBlock(attempted.finallyBody(), env, generics, expectedReturn, self); + return; + } + if (stmt instanceof Ast.SelectStmt selected) { + if (selected.mode() == Ast.WaitMode.NONBLOCKING + && currentActorKind == Ast.ActorKind.NONE) { + throw new IllegalArgumentException( + "static 'nb select { ... }' requires an actor execution domain " + + "because its selected branch executes later; " + + "use 'nb select from cases' when only a Future is needed"); + } + for (Ast.SelectArm arm : selected.arms()) { + Env armEnv = new Env(env); + if (arm.operation() != Ast.ChannelOperation.DEFAULT) { + Type element = channelElementType( + typeOf(arm.channel(), env, generics, self), + "select " + arm.operation().name().toLowerCase()); + if (arm.operation() == Ast.ChannelOperation.WRITE) { + requireAssignable( + typeOf(arm.value(), env, generics, self), + element, + "writech select value"); + } else if (arm.bindingName() != null) { + armEnv.define( + arm.bindingName(), + element, + arm.bindingKind()); + } + } + if (selected.mode() == Ast.WaitMode.NONBLOCKING) { + // nb select arms run later as detached actor continuations. + // return; exits the arm itself and loop control cannot cross + // back into an already-continued enclosing loop. + checkCallableBlock( + arm.body(), + armEnv, + generics, + Primitive.VOID, + self); + } else { + checkBlock(arm.body(), armEnv, generics, expectedReturn, self); + } + } + return; + } + if (stmt instanceof Ast.ForOfDestructureStmt loop) { + Type iterable = typeOf(loop.iterable(), env, generics, self); + Type element = iterableElementType(iterable); + List elementTypes = sequenceDestructureTypes(element, loop.bindings().size()); + Env loopEnv = new Env(env); + for (int i = 0; i < loop.bindings().size(); i++) { + defineDestructureBinding(loopEnv, loop.bindings().get(i), elementTypes.get(i)); + } + checkLoopBlock(loop.body(), loopEnv, generics, expectedReturn, self); + return; + } + if (stmt instanceof Ast.ForOfStmt loop) { + Type iterable = typeOf(loop.iterable(), env, generics, self); + Type element = iterableElementType(iterable); + Env loopEnv = new Env(env); + loopEnv.define(loop.bindingName(), element, loop.bindingKind()); + checkLoopBlock(loop.body(), loopEnv, generics, expectedReturn, self); + return; + } + if (stmt instanceof Ast.ForStmt loop) { + Env loopEnv = new Env(env); + if (loop.initializer() != null) checkStatement(loop.initializer(), loopEnv, generics, expectedReturn, self); + if (loop.condition() != null) requireAssignable(typeOf(loop.condition(), loopEnv, generics, self), Primitive.BOOL, "for condition"); + if (loop.update() != null) typeOf(loop.update(), loopEnv, generics, self); + checkLoopBlock(loop.body(), loopEnv, generics, expectedReturn, self); + return; + } + if (stmt instanceof Ast.LoopStmt loop) { + checkLoopBlock(loop.body(), env, generics, expectedReturn, self); + } + } + + private Type typeOf(Ast.Expr expr, Env env, Set generics, Type self) { + if (expr instanceof Ast.LiteralExpr literal) { + Object value = literal.value(); + if (value == null) throw new IllegalArgumentException("standalone null values are forbidden; use Option"); + if (value instanceof Long) return Primitive.INT; + if (value instanceof Double) return Primitive.FLOAT; + if (value instanceof Boolean) return Primitive.BOOL; + if (value instanceof String s) return new StringLiteral(s); + if (value instanceof Ast.Imaginary) return Primitive.COMPLEX; + return Unknown.INSTANCE; + } + if (expr instanceof Ast.NameExpr name) { + Env.Binding local = env.lookup(name.name()); + if (local != null) return local.type(); + if (name.name().equals("stdio") || name.name().equals("process") || name.name().equals("actor")) return new Named(name.name(), List.of()); + if (name.name().equals("Mutex") || name.name().equals("SharedMutex") + || name.name().equals("Channel") || name.name().equals("SelectCase") + || name.name().equals("SelectSet")) { + return new Named("$" + name.name() + "Factory", List.of()); + } + if (name.name().equals("print")) return new Function(List.of(Unknown.INSTANCE), Primitive.VOID); + if (name.name().equals("None")) return new Named("Option", List.of(Unknown.INSTANCE)); + Ast.ModuleDecl moduleNamespace = modules.get(name.name()); + if (moduleNamespace != null) return moduleShape(moduleNamespace); + Ast.ClassDecl classNamespace = findClass(name.name()); + if (classNamespace != null) return new ClassNamespace(qualifiedClassName(classNamespace)); + if (importedValues.contains(name.name())) return Unknown.INSTANCE; + Ast.FunctionDecl fn = findFunction(name.name()); + if (fn != null) { + if (fn.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException("actor callable '" + fn.name() + + "' is an actor entry point, not an ordinary function value; spawn it through the actor runtime"); + } + if (fn.kind() == Ast.CallableKind.ROUTINE) { + throw new IllegalArgumentException("routine '" + fn.name() + + "' is direct-call-only and cannot be used as a first-class callable value; " + + "wrap the call in an explicit lambda when a callback is required"); + } + if (!fn.genericParameters().isEmpty()) { + throw new IllegalArgumentException( + "generic callable '" + fn.name() + + "' must be specialized by a direct call; polymorphic function values are not supported yet"); + } + return functionType(fn.parameters(), fn.returnType(), fn.async(), Set.of(), null); + } + throw new IllegalArgumentException("unknown name '" + name.name() + "'"); + } + if (expr instanceof Ast.AssignExpr assignment) { + Type targetType; + String where; + if (assignment.target() instanceof Ast.NameExpr name) { + Env.Binding binding = env.lookup(name.name()); + if (binding == null) throw new IllegalArgumentException("cannot assign unknown name '" + name.name() + "'"); + if (binding.kind() != Ast.BindingKind.LET) throw new IllegalArgumentException("cannot reassign " + binding.kind().name().toLowerCase() + " binding '" + name.name() + "'"); + targetType = binding.type(); + where = name.name(); + } else if (assignment.target() instanceof Ast.MemberExpr member) { + targetType = memberType(member, env, generics, self); + where = member.member(); + } else if (assignment.target() instanceof Ast.IndexExpr indexed) { + Type receiver = deref(typeOf(indexed.receiver(), env, generics, self)); + Type index = typeOf(indexed.index(), env, generics, self); + if (receiver instanceof Named dynamic && dynamic.name().equals("DynamicStruct")) { + if (dynamic.arguments().size() != 1) { + throw new IllegalArgumentException("DynamicStruct requires exactly one value type"); + } + requireAssignable(index, Primitive.STRING, "DynamicStruct key"); + targetType = dynamic.arguments().getFirst(); + } else { + requireAssignable(index, Primitive.INT, "array/list index"); + if (receiver instanceof ListType list) targetType = list.element(); + else if (receiver instanceof Tuple tuple) targetType = tuple.elements().stream().reduce(Unknown.INSTANCE, this::commonType); + else throw new IllegalArgumentException("indexed assignment requires an array/list, tuple, or DynamicStruct"); + } + where = "index"; + } else throw new IllegalArgumentException("unsupported assignment target"); + Type value = typeOf(assignment.value(), env, generics, self); + requireAssignable(value, targetType, "assignment to " + where); + return targetType; + } + if (expr instanceof Ast.ConditionalExpr conditional) { + requireAssignable(typeOf(conditional.condition(), env, generics, self), Primitive.BOOL, "ternary condition"); + Type left = typeOf(conditional.whenTrue(), env, generics, self); + Type right = typeOf(conditional.whenFalse(), env, generics, self); + return commonType(left, right); + } + if (expr instanceof Ast.TypeTestExpr test) { + requireRuntimeReifiableType(test.targetType(), "is"); + Type source = deref(typeOf(test.value(), env, generics, self)); + Type target = deref(resolve(test.targetType(), generics, self)); + if (!typesMayOverlap(source, target)) { + throw new IllegalArgumentException("impossible type test: " + source + " cannot be " + target); + } + return Primitive.BOOL; + } + if (expr instanceof Ast.PatternTestExpr test) { + Type subject = deref(typeOf(test.value(), env, generics, self)); + checkPattern(test.pattern(), subject, new Env(env), generics, self); + return Primitive.BOOL; + } + if (expr instanceof Ast.CastExpr cast) { + requireRuntimeReifiableType(cast.targetType(), cast.mode() == Ast.CastMode.OPTIONAL ? "as?" : "as"); + Type source = deref(typeOf(cast.value(), env, generics, self)); + Type target = deref(resolve(cast.targetType(), generics, self)); + if (!typesMayOverlap(source, target)) { + throw new IllegalArgumentException("impossible cast: " + source + " cannot be cast to " + target); + } + return cast.mode() == Ast.CastMode.OPTIONAL + ? new Named("Option", List.of(target)) + : target; + } + if (expr instanceof Ast.UnaryExpr unary) { + Type operand = typeOf(unary.operand(), env, generics, self); + if (unary.operator().equals("&")) return new Borrow(operand, false); + if (unary.operator().equals("&mut")) return new Borrow(operand, true); + if (unary.operator().equals("!")) { + requireAssignable(operand, Primitive.BOOL, "! operand"); + return Primitive.BOOL; + } + if (unary.operator().equals("~")) { + requireInteger(operand, "~ operand"); + return Primitive.INT; + } + if (!Types.isNumeric(operand)) throw new IllegalArgumentException("unary " + unary.operator() + " needs a numeric operand"); + return operand; + } + if (expr instanceof Ast.BinaryExpr binary) { + Type left = typeOf(binary.left(), env, generics, self); + Type right = typeOf(binary.right(), env, generics, self); + return switch (binary.operator()) { + case "&&", "||", "^^" -> { + requireAssignable(left, Primitive.BOOL, "logical operand"); + requireAssignable(right, Primitive.BOOL, "logical operand"); + yield Primitive.BOOL; + } + case "&", "|", "^", "<<", ">>", ">>>" -> { + requireInteger(left, "bitwise left operand"); + requireInteger(right, "bitwise right operand"); + yield Primitive.INT; + } + case "==", "!=" -> Primitive.BOOL; + case "<", "<=", ">", ">=" -> { + if (!(Types.isNumeric(left) && Types.isNumeric(right)) && !(isStringLike(left) && isStringLike(right))) { + throw new IllegalArgumentException("comparison operands must both be numeric or both strings"); + } + yield Primitive.BOOL; + } + case "+" -> isStringLike(left) && isStringLike(right) ? Primitive.STRING : numericJoin(left, right, "+"); + case "-", "*", "/", "%" -> numericJoin(left, right, binary.operator()); + default -> Unknown.INSTANCE; + }; + } + if (expr instanceof Ast.CallExpr call) { + if (call.callee() instanceof Ast.NameExpr name + && name.name().equals("init")) { + throw new IllegalArgumentException( + "init is a lifecycle hook and cannot be called directly; startup invokes it exactly once"); + } + if (call.callee() instanceof Ast.MemberExpr member + && member.member().equals("init") + && member.receiver() instanceof Ast.NameExpr namespace + && modules.containsKey(namespace.name())) { + throw new IllegalArgumentException( + "module init is a lifecycle hook and cannot be called directly; startup invokes it exactly once"); + } + if (call.callee() instanceof Ast.NameExpr functionName + && env.lookup(functionName.name()) == null) { + Ast.FunctionDecl target = findFunction(functionName.name()); + if (target != null) { + if (target.actorKind() != Ast.ActorKind.NONE + && currentActorKind != Ast.ActorKind.NONE) { + throw new IllegalArgumentException( + "actor callable '" + target.name() + + "' cannot be synchronously invoked from another actor turn; " + + "use mailbox-oriented actor composition"); + } + String label = "function " + functionName.name(); + validateCallTypeArgumentMarker(call, target.genericParameters(), label); + Type result = checkGenericCallable( + target.genericParameters(), + target.parameters(), + target.returnType(), + call.arguments(), + env, generics, self, + null, + explicitGenericBindings(target.genericParameters(), call.typeArguments(), generics, self, label), + label); + return asyncResult(target.async(), result); + } + } + if (call.callee() instanceof Ast.MemberExpr qualifiedCall + && qualifiedCall.receiver() instanceof Ast.NameExpr namespace + && env.lookup(namespace.name()) == null + && modules.containsKey(namespace.name())) { + Ast.FunctionDecl target = functions.get(namespace.name() + "." + qualifiedCall.member()); + if (target != null) { + if (target.actorKind() != Ast.ActorKind.NONE + && currentActorKind != Ast.ActorKind.NONE) { + throw new IllegalArgumentException( + "actor callable '" + namespace.name() + "." + target.name() + + "' cannot be synchronously invoked from another actor turn; " + + "use mailbox-oriented actor composition"); + } + String label = "function " + namespace.name() + "." + qualifiedCall.member(); + validateCallTypeArgumentMarker(call, target.genericParameters(), label); + Type result = checkGenericCallable( + target.genericParameters(), + target.parameters(), + target.returnType(), + call.arguments(), + env, generics, self, + null, + explicitGenericBindings(target.genericParameters(), call.typeArguments(), generics, self, label), + label); + return asyncResult(target.async(), result); + } + } + if (call.callee() instanceof Ast.MemberExpr channelCall + && channelCall.receiver() instanceof Ast.NameExpr factory + && factory.name().equals("Channel") + && channelCall.member().equals("new")) { + if (!call.typeArgumentsPresent() || call.typeArguments().size() != 1) { + throw new IllegalArgumentException( + "Channel.new(capacity) requires exactly one explicit element type"); + } + if (call.arguments().size() != 1) { + throw new IllegalArgumentException("Channel.new expects exactly one capacity"); + } + requireAssignable( + typeOf(call.arguments().getFirst(), env, generics, self), + Primitive.INT, + "Channel.new capacity"); + Type element = resolve(call.typeArguments().getFirst(), generics, self); + if (element == Primitive.VOID) { + throw new IllegalArgumentException( + "Channel cannot carry a value; use an explicit signal/unit type"); + } + return new Named("Channel", List.of(element)); + } + + if (call.callee() instanceof Ast.MemberExpr selectCaseCall + && selectCaseCall.receiver() instanceof Ast.NameExpr factory + && factory.name().equals("SelectCase")) { + if (call.typeArgumentsPresent()) { + throw new IllegalArgumentException( + "SelectCase constructors infer channel element types"); + } + return switch (selectCaseCall.member()) { + case "read" -> { + if (call.arguments().size() != 1) { + throw new IllegalArgumentException("SelectCase.read expects one Channel"); + } + channelElementType( + typeOf(call.arguments().getFirst(), env, generics, self), + "SelectCase.read"); + yield new Named("SelectCase", List.of()); + } + case "write" -> { + if (call.arguments().size() != 2) { + throw new IllegalArgumentException( + "SelectCase.write expects Channel, value"); + } + Type element = channelElementType( + typeOf(call.arguments().get(0), env, generics, self), + "SelectCase.write"); + requireAssignable( + typeOf(call.arguments().get(1), env, generics, self), + element, + "SelectCase.write value"); + yield new Named("SelectCase", List.of()); + } + case "default" -> { + if (!call.arguments().isEmpty()) { + throw new IllegalArgumentException("SelectCase.default expects no arguments"); + } + yield new Named("SelectCase", List.of()); + } + default -> throw new IllegalArgumentException( + "unknown SelectCase constructor '" + selectCaseCall.member() + "'"); + }; + } + + if (call.callee() instanceof Ast.MemberExpr selectSetCall + && selectSetCall.receiver() instanceof Ast.NameExpr factory + && factory.name().equals("SelectSet") + && selectSetCall.member().equals("new")) { + if (call.typeArgumentsPresent()) { + throw new IllegalArgumentException("SelectSet.new does not accept type arguments"); + } + if (call.arguments().size() != 1) { + throw new IllegalArgumentException( + "SelectSet.new expects one list/map of SelectCase values"); + } + typeOf(call.arguments().getFirst(), env, generics, self); + return new Named("SelectSet", List.of()); + } + + if (call.callee() instanceof Ast.MemberExpr factoryCall + && factoryCall.receiver() instanceof Ast.NameExpr factory + && (factory.name().equals("Mutex") || factory.name().equals("SharedMutex")) + && factoryCall.member().equals("new")) { + if (call.typeArgumentsPresent()) throw new IllegalArgumentException(factory.name() + ".new does not accept call-site type arguments"); + if (call.arguments().size() != 1) throw new IllegalArgumentException(factory.name() + ".new expects exactly one value"); + Type element = typeOf(call.arguments().getFirst(), env, generics, self); + if (element instanceof Borrow) { + throw new IllegalArgumentException( + factory.name() + " requires owned data; borrowed values cannot become mutex state"); + } + if (factory.name().equals("SharedMutex") && !isSharedSafe(element, new LinkedHashSet<>(), Map.of())) { + throw new IllegalArgumentException( + "SharedMutex requires shared-safe owned data; borrows, Mutex, MutexGuard, Future, closures, and unresolved generic/dynamic values are not shareable"); + } + return new Named(factory.name(), List.of(element)); + } + if (call.callee() instanceof Ast.NameExpr name + && (name.name().equals("Some") || name.name().equals("Ok") || name.name().equals("Err"))) { + if (call.typeArgumentsPresent()) { + throw new IllegalArgumentException(name.name() + " does not accept call-site type arguments"); + } + if (call.arguments().size() != 1) { + throw new IllegalArgumentException(name.name() + " expects exactly one value"); + } + Type value = widenCollectionElement(typeOf(call.arguments().getFirst(), env, generics, self)); + return switch (name.name()) { + case "Some" -> new Named("Option", List.of(value)); + case "Ok" -> new Named("Result", List.of(value, Unknown.INSTANCE)); + case "Err" -> new Named("Result", List.of(Unknown.INSTANCE, value)); + default -> throw new IllegalStateException("unreachable sum constructor"); + }; + } + if (call.callee() instanceof Ast.MemberExpr member) { + Type receiver = deref(typeOf(member.receiver(), env, generics, self)); + + if (receiver instanceof Named guard + && guard.name().equals("MutexGuard") + && guard.arguments().size() == 1) { + Type guardBuiltin = builtinMutexMember(receiver, member.member()); + if (guardBuiltin instanceof Function builtin) { + if (call.typeArgumentsPresent()) { + throw new IllegalArgumentException("MutexGuard." + member.member() + + " does not accept call-site type arguments"); + } + if (builtin.parameters().size() != call.arguments().size()) { + throw new IllegalArgumentException("MutexGuard." + member.member() + " call arity mismatch"); + } + for (int i = 0; i < builtin.parameters().size(); i++) { + requireAssignable( + typeOf(call.arguments().get(i), env, generics, self), + builtin.parameters().get(i), + "argument " + (i + 1)); + } + return builtin.result(); + } + // Non-builtin members are direct calls on the protected value. + // Unwrap only for call resolution; plain guard.method remains + // non-reifiable through the ordinary MemberExpr path. + receiver = unwrapMutexGuard(receiver); + } + + if (receiver instanceof Record record) { + String prefix = methodKey(member.member(), call.arguments().size()) + "$generics"; + List> candidates = record.members().entrySet().stream() + .filter(entry -> entry.getKey().startsWith(prefix)) + .toList(); + if (candidates.size() > 1) { + throw new IllegalArgumentException( + "ambiguous structural method '" + member.member() + + "' with arity " + call.arguments().size()); + } + if (candidates.size() == 1) { + Map.Entry candidate = candidates.getFirst(); + int genericArity = Integer.parseInt(candidate.getKey().substring(prefix.length())); + if (!(candidate.getValue() instanceof Function fn)) { + throw new IllegalArgumentException( + "structural method contract for '" + member.member() + "' is not callable"); + } + + String label = "structural method " + member.member(); + Set canonicalGenerics = new LinkedHashSet<>(); + for (int i = 0; i < genericArity; i++) { + canonicalGenerics.add("$callable" + i); + } + + Map bindings = new HashMap<>(); + Set fixedBindings = new HashSet<>(); + if (call.typeArgumentsPresent() && !call.typeArguments().isEmpty()) { + if (call.typeArguments().size() != genericArity) { + throw new IllegalArgumentException( + label + " expects " + genericArity + + " explicit type argument(s), got " + + call.typeArguments().size()); + } + for (int i = 0; i < genericArity; i++) { + String genericName = "$callable" + i; + bindings.put( + genericName, + resolve(call.typeArguments().get(i), generics, self)); + fixedBindings.add(genericName); + } + } else if (call.typeArgumentsPresent() && genericArity == 0) { + throw new IllegalArgumentException( + label + " is not generic and cannot be called with <>"); + } + + List actuals = new ArrayList<>(call.arguments().size()); + for (int i = 0; i < call.arguments().size(); i++) { + Type actual = typeOf(call.arguments().get(i), env, generics, self); + actuals.add(actual); + inferGenericBindings( + fn.parameters().get(i), + actual, + bindings, + fixedBindings, + label); + } + + Set unbound = new HashSet<>(canonicalGenerics); + unbound.removeAll(bindings.keySet()); + for (int i = 0; i < call.arguments().size(); i++) { + Type expected = substituteGenerics(fn.parameters().get(i), bindings); + if (containsGenericNamed(expected, unbound)) { + throw new IllegalArgumentException( + "cannot infer all generic parameters for " + + label + " from argument " + (i + 1)); + } + validateLambdaArgument( + call.arguments().get(i), expected, env, generics, self); + requireAssignable( + actuals.get(i), + expected, + "argument " + (i + 1)); + } + + Type result = substituteGenerics(fn.result(), bindings); + if (containsGenericNamed(result, unbound)) { + throw new IllegalArgumentException( + "cannot infer generic return type for " + label + + "; provide explicit type arguments or an inferable value parameter"); + } + return result; + } + } + + if (receiver instanceof ClassNamespace classNamespace) { + Ast.ClassDecl klass = findClass(classNamespace.className()); + if (klass == null) throw new IllegalArgumentException("unknown class namespace '" + classNamespace.className() + "'"); + Ast.MethodDecl fn = findStaticFunction(klass, member.member(), call.arguments().size(), new LinkedHashSet<>()); + if (fn == null) throw new IllegalArgumentException("no static function '" + member.member() + "' with arity " + call.arguments().size() + " on " + klass.name()); + Ast.ClassDecl fnOwner = findDeclaringClass(klass, fn, new LinkedHashSet<>()); + requireClassMemberVisible(fn.visibility(), fnOwner, "static function", fn.name()); + validateCallTypeArgumentMarker(call, fn.genericParameters(), "static function " + fnOwner.name() + "." + fn.name()); + List callableGenerics = new ArrayList<>(fn.genericParameters()); + String label = "static function " + klass.name() + "." + fn.name(); + Type result = checkGenericCallable( + callableGenerics, + fn.parameters(), + fn.returnType(), + call.arguments(), + env, generics, self, + null, + explicitGenericBindings(fn.genericParameters(), call.typeArguments(), generics, self, label), + label); + return asyncResult(fn.async(), result); + } + if (receiver instanceof Named named) { + if (named.name().equals("SharedMutex") + && currentActorKind != Ast.ActorKind.NONE + && member.member().equals("with_lock")) { + throw new IllegalArgumentException( + "actor code cannot use blocking SharedMutex.with_lock(); use try_lock() or await lock_async()"); + } + if ((named.name().equals("Mutex") || named.name().equals("SharedMutex")) + && named.arguments().size() == 1 + && (member.member().equals("with_lock") || member.member().equals("recover"))) { + if (member.member().equals("recover") && !named.name().equals("SharedMutex")) { + throw new IllegalArgumentException("recover is only available on SharedMutex"); + } + if (call.arguments().size() != 1) { + throw new IllegalArgumentException(member.member() + " expects exactly one callback"); + } + Ast.Expr callback = call.arguments().getFirst(); + if (!(callback instanceof Ast.LambdaExpr lambda)) { + throw new IllegalArgumentException( + member.member() + " requires an inline one-argument lambda so the protected borrow cannot escape"); + } + validateMutexCallback(lambda, named.arguments().getFirst(), env, generics, self); + return Primitive.VOID; + } + Ast.ClassDecl klass = findClass(named.name()); + if (klass != null) { + ResolvedMethod target = findMethodTarget( + klass, named, member.member(), call.arguments().size(), new LinkedHashSet<>()); + if (target == null) { + ResolvedField field = findFieldTarget( + klass, named, member.member(), new LinkedHashSet<>()); + if (field != null) { + requireClassMemberVisible( + field.field().visibility(), field.owner(), "field", field.field().name()); + if (call.typeArgumentsPresent()) { + throw new IllegalArgumentException( + "function-valued field '" + member.member() + + "' does not accept call-site type arguments"); + } + Type fieldType = substituteGenerics( + classFieldType(field.owner(), field.field()), + classGenericBindings(field.owner(), field.ownerType())); + if (!(fieldType instanceof Function fn)) { + throw new IllegalArgumentException( + "field '" + member.member() + "' on " + named.name() + + " is not callable"); + } + if (fn.parameters().size() != call.arguments().size()) { + throw new IllegalArgumentException( + "function-valued field '" + member.member() + "' call arity mismatch"); + } + for (int i = 0; i < fn.parameters().size(); i++) { + validateLambdaArgument( + call.arguments().get(i), fn.parameters().get(i), env, generics, self); + requireAssignable( + typeOf(call.arguments().get(i), env, generics, self), + fn.parameters().get(i), + "argument " + (i + 1)); + } + return fn.result(); + } + throw new IllegalArgumentException( + "no method or callable field '" + member.member() + + "' with arity " + call.arguments().size() + + " on " + named.name()); + } + Ast.MethodDecl method = target.method(); + Ast.ClassDecl owner = target.owner(); + Named ownerType = target.ownerType(); + requireClassMemberVisible(method.visibility(), owner, "method", method.name()); + validateCallTypeArgumentMarker(call, method.genericParameters(), "method " + owner.name() + "." + method.name()); + List callableGenerics = new ArrayList<>(owner.genericParameters()); + callableGenerics.addAll(method.genericParameters()); + String label = "method " + owner.name() + "." + method.name(); + Map bindings = new HashMap<>(classGenericBindings(owner, ownerType)); + bindings.putAll(explicitGenericBindings(method.genericParameters(), call.typeArguments(), generics, self, label)); + return checkGenericCallable( + callableGenerics, + method.parameters(), + method.returnType(), + call.arguments(), + env, generics, self, + ownerType, + bindings, + label); + } + + Ast.InterfaceDecl iface = findInterface(named.name()); + if (iface != null) { + ResolvedInterfaceFunction target = findInterfaceFunctionTarget( + iface, named, member.member(), call.arguments().size(), new LinkedHashSet<>()); + if (target == null) { + throw new IllegalArgumentException( + "no interface method '" + member.member() + "' with arity " + + call.arguments().size() + " on " + named.name()); + } + Ast.InterfaceFunctionDecl method = target.function(); + Ast.InterfaceDecl owner = target.owner(); + Named ownerType = target.ownerType(); + String label = "interface method " + owner.name() + "." + method.name(); + validateCallTypeArgumentMarker(call, method.genericParameters(), label); + + List callableGenerics = new ArrayList<>(owner.genericParameters()); + callableGenerics.addAll(method.genericParameters()); + Map bindings = new HashMap<>(genericBindings( + owner.genericParameters(), ownerType.arguments(), "interface " + owner.name())); + bindings.putAll(explicitGenericBindings( + method.genericParameters(), call.typeArguments(), generics, self, label)); + + return checkGenericCallable( + callableGenerics, + method.parameters(), + method.returnType(), + call.arguments(), + env, generics, self, + null, + bindings, + label); + } + } + } + if (call.typeArgumentsPresent()) { + throw new IllegalArgumentException("call-site type arguments require a declared generic function or method"); + } + Type callee = typeOf(call.callee(), env, generics, self); + if (!(callee instanceof Function fn)) return Unknown.INSTANCE; + if (fn.parameters().size() != call.arguments().size()) throw new IllegalArgumentException("call arity mismatch"); + for (int i = 0; i < fn.parameters().size(); i++) { + validateLambdaArgument(call.arguments().get(i), fn.parameters().get(i), env, generics, self); + requireAssignable(typeOf(call.arguments().get(i), env, generics, self), fn.parameters().get(i), "argument " + (i + 1)); + } + return fn.result(); + } + if (expr instanceof Ast.MemberExpr member) { + if (member.receiver() instanceof Ast.NameExpr namespace + && env.lookup(namespace.name()) == null + && modules.containsKey(namespace.name())) { + Ast.FunctionDecl moduleFunction = functions.get(namespace.name() + "." + member.member()); + if (moduleFunction != null) { + if (moduleFunction.kind() == Ast.CallableKind.ROUTINE) { + throw new IllegalArgumentException("routine '" + namespace.name() + "." + member.member() + + "' is direct-call-only and cannot be used as a first-class callable value; " + + "invoke it directly or wrap the call in an explicit lambda"); + } + if (!moduleFunction.genericParameters().isEmpty()) { + throw new IllegalArgumentException( + "generic callable '" + namespace.name() + "." + member.member() + + "' must be specialized by a direct call; polymorphic function values are not supported yet"); + } + return functionType(moduleFunction.parameters(), moduleFunction.returnType(), moduleFunction.async(), Set.of(), null); + } + Ast.ClassDecl memberClass = classes.get(namespace.name() + "." + member.member()); + if (memberClass != null) return new ClassNamespace(qualifiedClassName(memberClass)); + } + if (member.receiver() instanceof Ast.NameExpr name && name.name().equals("stdio")) { + if (member.member().equals("print") || member.member().equals("println")) return new Function(List.of(Unknown.INSTANCE), Primitive.VOID); + if (member.member().equals("stdout")) return new Named("stdio.stdout", List.of()); + } + Type receiver = typeOf(member.receiver(), env, generics, self); + Type sumReceiver = deref(receiver); + Type sumMember = builtinOptionResultMember(sumReceiver, member.member()); + if (sumMember != null) return sumMember; + if (sumReceiver instanceof Named sumNamed + && (sumNamed.name().equals("Option") || sumNamed.name().equals("Result"))) { + throw new IllegalArgumentException( + "unknown " + sumNamed.name() + " member '" + member.member() + "'"); + } + Type mutexMember = builtinMutexMember(receiver, member.member()); + if (mutexMember != null) return mutexMember; + + Type selectedReceiver = deref(receiver); + if (selectedReceiver instanceof Named selected + && selected.name().equals("SelectResult")) { + return switch (member.member()) { + case "index" -> Primitive.INT; + case "operation" -> Primitive.STRING; + case "value" -> Unknown.INSTANCE; + default -> throw new IllegalArgumentException( + "unknown SelectResult member '" + member.member() + "'"); + }; + } + + receiver = unwrapMutexGuard(receiver); + if (receiver instanceof Named named && named.name().equals("stdio.stdout") && member.member().equals("write")) { + return new Function(List.of(Unknown.INSTANCE), Primitive.VOID); + } + if (member.receiver() instanceof Ast.NameExpr name + && (name.name().equals("process") || name.name().equals("actor"))) return Unknown.INSTANCE; + if (member.receiver() instanceof Ast.NameExpr name && importedValues.contains(name.name())) return Unknown.INSTANCE; + + if (receiver instanceof ClassNamespace classNamespace) { + Ast.ClassDecl klass = findClass(classNamespace.className()); + if (klass == null) throw new IllegalArgumentException("unknown class namespace '" + classNamespace.className() + "'"); + List functions = findStaticFunctionsByName(klass, member.member(), new LinkedHashSet<>()); + if (functions.size() == 1) { + Ast.MethodDecl fn = functions.getFirst(); + Ast.ClassDecl fnOwner = findDeclaringClass(klass, fn, new LinkedHashSet<>()); + requireClassMemberVisible(fn.visibility(), fnOwner, "static function", fn.name()); + if (!fn.genericParameters().isEmpty()) { + throw new IllegalArgumentException( + "generic static function '" + klass.name() + "." + fn.name() + + "' must be specialized by a direct call; polymorphic function values are not supported yet"); + } + return functionType(fn.parameters(), fn.returnType(), fn.async(), Set.of(), null); + } + if (functions.size() > 1) throw new IllegalArgumentException("overloaded static function '" + member.member() + "' must be called so arity can select the overload"); + throw new IllegalArgumentException("unknown static member '" + member.member() + "' on " + klass.name()); + } + + if (receiver instanceof Record record) { + Type result = record.members().get(member.member()); + if (result != null) return result; + + String methodPrefix = member.member() + "$arity"; + boolean structuralMethod = record.members().keySet().stream() + .anyMatch(key -> key.startsWith(methodPrefix) && key.contains("$generics")); + if (structuralMethod) { + throw new IllegalArgumentException( + "structural method '" + member.member() + + "' is direct-call-only and cannot be used as a first-class callable value; " + + "invoke it directly or wrap that call in an explicit lambda"); + } + throw new IllegalArgumentException("unknown structural member '" + member.member() + "'"); + } + if (receiver instanceof Named dynamic && dynamic.name().equals("DynamicStruct")) { + if (dynamic.arguments().size() != 1) { + throw new IllegalArgumentException("DynamicStruct requires exactly one value type"); + } + return dynamic.arguments().getFirst(); + } + if (receiver instanceof Named named) { + Ast.ClassDecl klass = findClass(named.name()); + if (klass != null) { + ResolvedField field = findFieldTarget(klass, named, member.member(), new LinkedHashSet<>()); + if (field != null) { + requireClassMemberVisible( + field.field().visibility(), field.owner(), "field", field.field().name()); + Type pattern = classFieldType(field.owner(), field.field()); + return substituteGenerics(pattern, classGenericBindings(field.owner(), field.ownerType())); + } + List methods = findMethodsByName(klass, member.member(), new LinkedHashSet<>()); + if (!methods.isEmpty()) { + throw new IllegalArgumentException( + "instance method '" + klass.name() + "." + member.member() + + "' is direct-call-only and cannot be used as a first-class callable value; " + + "invoke it as receiver." + member.member() + + "(...) or wrap that call in an explicit lambda"); + } + } + + Ast.InterfaceDecl iface = findInterface(named.name()); + if (iface != null && hasInterfaceFunctionNamed( + iface, member.member(), new LinkedHashSet<>())) { + throw new IllegalArgumentException( + "interface method '" + iface.name() + "." + member.member() + + "' is direct-call-only and cannot be used as a first-class callable value; " + + "invoke it as receiver." + member.member() + + "(...) or wrap that call in an explicit lambda"); + } + } + return Unknown.INSTANCE; + } + if (expr instanceof Ast.IndexExpr indexed) { + Type receiver = deref(typeOf(indexed.receiver(), env, generics, self)); + Type index = typeOf(indexed.index(), env, generics, self); + if (receiver instanceof Named dynamic && dynamic.name().equals("DynamicStruct")) { + if (dynamic.arguments().size() != 1) { + throw new IllegalArgumentException("DynamicStruct requires exactly one value type"); + } + requireAssignable(index, Primitive.STRING, "DynamicStruct key"); + return dynamic.arguments().getFirst(); + } + if (receiver instanceof Record record) { + requireAssignable(index, Primitive.STRING, "object/map key"); + if (index instanceof StringLiteral key) { + Type member = record.members().get(key.value()); + if (member == null) { + throw new IllegalArgumentException("unknown object/map key '" + key.value() + "'"); + } + return member; + } + if (record.members().isEmpty()) return Unknown.INSTANCE; + return record.members().values().stream().reduce(Unknown.INSTANCE, this::commonType); + } + requireAssignable(index, Primitive.INT, "array/list index"); + if (receiver instanceof ListType list) return list.element(); + if (receiver instanceof Tuple tuple) return tuple.elements().stream().reduce(Unknown.INSTANCE, this::commonType); + throw new IllegalArgumentException("indexing requires an array/list, tuple, or DynamicStruct"); + } + if (expr instanceof Ast.NewExpr created) { + if (created.type().name().equals("DynamicStruct")) { + Type dynamic = resolve(created.type(), generics, self); + if (!created.arguments().isEmpty()) { + throw new IllegalArgumentException("DynamicStruct constructor takes no positional arguments"); + } + return dynamic; + } + Ast.ClassDecl klass = findClass(created.type().name()); + if (klass == null) return resolve(created.type(), generics, self); + if (klass.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException("actor '" + klass.name() + + "' cannot be constructed with new; actor instances must be created through the actor runtime"); + } + + Named nominal; + if (created.type().inferArguments()) { + if (!created.type().arguments().isEmpty()) { + throw new IllegalArgumentException("inferred constructor type arguments must use an empty <> marker"); + } + nominal = inferConstructedClassType(klass, created.arguments(), env, generics, self); + } else { + Type resolvedCreated = resolve(created.type(), generics, self); + if (!(resolvedCreated instanceof Named named)) { + throw new IllegalArgumentException("constructor target must resolve to a named class type"); + } + nominal = named; + } + + List fields = effectiveFieldTargets(klass, nominal, new LinkedHashSet<>()); + if (created.arguments().size() > fields.size()) throw new IllegalArgumentException("constructor for " + klass.name() + " received too many positional fields"); + for (int i = 0; i < fields.size(); i++) { + ResolvedField resolvedField = fields.get(i); + Ast.FieldDecl field = resolvedField.field(); + if (i < created.arguments().size()) { + Type fieldPattern = classFieldType(resolvedField.owner(), field); + Type expected = substituteGenerics(fieldPattern, classGenericBindings(resolvedField.owner(), resolvedField.ownerType())); + requireAssignable(typeOf(created.arguments().get(i), env, generics, self), + expected, "constructor field " + field.name()); + } else if (field.initializer() == null) { + throw new IllegalArgumentException("constructor for " + klass.name() + " is missing field '" + field.name() + "'"); + } + } + return nominal; + } + if (expr instanceof Ast.AwaitExpr awaited) { + Type awaitedType = typeOf(awaited.expression(), env, generics, self); + if (awaitedType instanceof Named named + && named.name().equals("Future") + && named.arguments().size() == 1) { + return named.arguments().getFirst(); + } + throw new IllegalArgumentException( + "await requires Future; got " + awaitedType); + } + if (expr instanceof Ast.ChannelOpExpr channelOp) { + Type element = channelElementType( + typeOf(channelOp.channel(), env, generics, self), + channelOp.operation() == Ast.ChannelOperation.READ ? "readch" : "writech"); + + if (channelOp.operation() == Ast.ChannelOperation.WRITE) { + requireAssignable( + typeOf(channelOp.value(), env, generics, self), + element, + "writech value"); + return switch (channelOp.mode()) { + case BLOCKING -> Primitive.VOID; + case NONBLOCKING -> new Named("Future", List.of(Primitive.VOID)); + case IMMEDIATE -> Primitive.BOOL; + }; + } + + return switch (channelOp.mode()) { + case BLOCKING -> element; + case NONBLOCKING -> new Named("Future", List.of(element)); + case IMMEDIATE -> new Named("Option", List.of(element)); + }; + } + if (expr instanceof Ast.DynamicSelectExpr selected) { + // Dynamic select accepts a SelectSet directly or a runtime + // list/map of SelectCase values. The exact case element type may + // remain Unknown until collection generic constraints are richer. + typeOf(selected.cases(), env, generics, self); + Type result = new Named("SelectResult", List.of()); + return switch (selected.mode()) { + case BLOCKING -> result; + case NONBLOCKING -> new Named("Future", List.of(result)); + case IMMEDIATE -> new Named("Option", List.of(result)); + }; + } + if (expr instanceof Ast.ListExpr list) { + if (list.elements().isEmpty()) return new ListType(Unknown.INSTANCE); + Type element = widenCollectionElement(typeOf(list.elements().getFirst(), env, generics, self)); + for (int i = 1; i < list.elements().size(); i++) { + element = collectionElementJoin(element, widenCollectionElement(typeOf(list.elements().get(i), env, generics, self))); + } + return new ListType(element); + } + if (expr instanceof Ast.TupleExpr tuple) { + return new Tuple(tuple.elements().stream().map(item -> typeOf(item, env, generics, self)).toList()); + } + if (expr instanceof Ast.ObjectExpr object) { + Map members = new LinkedHashMap<>(); + boolean dynamicKeys = false; + Type dynamicValue = null; + for (Ast.ObjectField field : object.fields()) { + Type exactValueType = typeOf(field.value(), env, generics, self); + Type dynamicValueType = widenCollectionElement(exactValueType); + dynamicValue = dynamicValue == null + ? dynamicValueType + : collectionElementJoin(dynamicValue, dynamicValueType); + if (field.isDynamic()) { + dynamicKeys = true; + requireAssignable( + typeOf(field.dynamicName(), env, generics, self), + Primitive.STRING, + "dynamic obj key"); + } else if (members.putIfAbsent(field.name(), exactValueType) != null) { + throw new IllegalArgumentException("duplicate obj field '" + field.name() + "'"); + } + } + if (dynamicKeys) { + return new Named("DynamicStruct", List.of( + dynamicValue == null ? Unknown.INSTANCE : dynamicValue)); + } + return new Record(members); + } + if (expr instanceof Ast.LambdaExpr lambda) { + boolean nonLexical = lambda.nonLexical() || env.descendantsNonLexical(); + Env lambdaEnv = new Env(nonLexical ? null : env, nonLexical); + List parameters = new ArrayList<>(); + for (Ast.Param param : lambda.parameters()) { + Type type = resolveParam(param, generics, self); + parameters.add(type); + lambdaEnv.define(param.name(), type, param.mutable() ? Ast.BindingKind.LET : Ast.BindingKind.VAL); + } + if (lambda.expressionBody() != null) { + throw new IllegalArgumentException("expression-body lambdas are not supported; lambdas require braces and explicit return"); + } + Ast.ClassDecl previousClassOwner = currentClassOwner; + if (nonLexical) currentClassOwner = null; + try { + checkCallableBlock(lambda.blockBody(), lambdaEnv, generics, Unknown.INSTANCE, self); + } finally { + currentClassOwner = previousClassOwner; + } + return new Function(parameters, Unknown.INSTANCE); + } + return Unknown.INSTANCE; + } + + private Type channelElementType(Type channel, String where) { + channel = deref(channel); + if (channel == Unknown.INSTANCE) return Unknown.INSTANCE; + if (channel instanceof Named named + && named.name().equals("Channel") + && named.arguments().size() == 1) { + return named.arguments().getFirst(); + } + throw new IllegalArgumentException( + where + " requires Channel; got " + channel); + } + + private Type typeOfAgainstExpected(Ast.Expr expr, Type expected, Env env, Set generics, Type self) { + if (expr instanceof Ast.LambdaExpr lambda && expected instanceof Function fn) { + validateLambdaAgainstExpected(lambda, fn, env, generics, self); + return fn; + } + if (expr instanceof Ast.ListExpr list) { + if (expected instanceof Tuple) { + return new Tuple(list.elements().stream().map(item -> typeOf(item, env, generics, self)).toList()); + } + if (expected instanceof Union union + && union.options().stream().allMatch(option -> option instanceof Tuple)) { + return new Tuple(list.elements().stream().map(item -> typeOf(item, env, generics, self)).toList()); + } + } + return typeOf(expr, env, generics, self); + } + + private void defineDestructureBinding(Env env, Ast.DestructureBinding binding, Type type) { + if (!binding.isDiscard()) env.define(binding.name(), type, binding.kind()); + } + + private List sequenceDestructureTypes(Type source, int arity) { + source = deref(source); + if (source instanceof Tuple tuple) { + if (tuple.elements().size() != arity) { + throw new IllegalArgumentException("destructure arity mismatch: tuple has " + tuple.elements().size() + + " element(s), pattern has " + arity); + } + return tuple.elements(); + } + if (source instanceof ListType list) { + return java.util.Collections.nCopies(arity, list.element()); + } + if (source instanceof Union union) { + List> alternatives = new ArrayList<>(union.options().size()); + for (Type option : union.options()) { + alternatives.add(sequenceDestructureTypes(option, arity)); + } + List joined = new ArrayList<>(arity); + for (int i = 0; i < arity; i++) { + final int slot = i; + joined.add(Types.unionOf(alternatives.stream().map(items -> items.get(slot)).toList())); + } + return List.copyOf(joined); + } + throw new IllegalArgumentException("sequence destructuring requires a tuple or array/list value"); + } + + private Type objectDestructureMemberType(Type source, String memberName) { + source = deref(source); + if (source instanceof Record record) { + Type member = record.members().get(memberName); + if (member == null) throw new IllegalArgumentException("object destructure requires member '" + memberName + "'"); + return member; + } + if (source instanceof Named named) { + Ast.ClassDecl klass = findClass(named.name()); + if (klass == null) throw new IllegalArgumentException("object destructuring requires a record/map-like value"); + ResolvedField field = findFieldTarget( + klass, named, memberName, new LinkedHashSet<>()); + if (field == null) { + throw new IllegalArgumentException( + "object destructure requires field '" + memberName + "'"); + } + requireClassMemberVisible( + field.field().visibility(), field.owner(), "field", field.field().name()); + Type pattern = classFieldType(field.owner(), field.field()); + return substituteGenerics( + pattern, + classGenericBindings(field.owner(), field.ownerType())); + } + if (source instanceof Union union) { + List alternatives = new ArrayList<>(union.options().size()); + for (Type option : union.options()) alternatives.add(objectDestructureMemberType(option, memberName)); + return Types.unionOf(alternatives); + } + if (source == Unknown.INSTANCE) return Unknown.INSTANCE; + throw new IllegalArgumentException("object destructuring requires a record/map-like value"); + } + + private void validateMutexCallback(Ast.LambdaExpr lambda, Type expectedParameter, Env parent, Set generics, Type self) { + if (lambda.expressionBody() != null) { + throw new IllegalArgumentException("mutex callbacks require a block body"); + } + if (lambda.parameters().size() != 1) { + throw new IllegalArgumentException("mutex callback must accept exactly one protected-value parameter"); + } + Ast.Param param = lambda.parameters().getFirst(); + Type declared = param.type().name().equals("$infer$") ? expectedParameter : resolveParam(param, generics, self); + requireAssignable(expectedParameter, declared, "mutex callback parameter " + param.name()); + requireAssignable(declared, expectedParameter, "mutex callback parameter " + param.name()); + boolean nonLexical = lambda.nonLexical() || parent.descendantsNonLexical(); + Env lambdaEnv = new Env(nonLexical ? null : parent, nonLexical); + lambdaEnv.define(param.name(), declared, param.mutable() ? Ast.BindingKind.LET : Ast.BindingKind.VAL); + Ast.ClassDecl previousClassOwner = currentClassOwner; + if (nonLexical) currentClassOwner = null; + try { + checkCallableBlock(lambda.blockBody(), lambdaEnv, generics, Primitive.VOID, self); + } finally { + currentClassOwner = previousClassOwner; + } + } + + + private void validateLambdaArgument(Ast.Expr argument, Type expected, Env env, Set generics, Type self) { + if (argument instanceof Ast.LambdaExpr lambda && expected instanceof Function fn) { + validateLambdaAgainstExpected(lambda, fn, env, generics, self); + } + } + + private void validateLambdaAgainstExpected(Ast.LambdaExpr lambda, Function expected, Env parent, Set generics, Type self) { + if (lambda.expressionBody() != null) { + throw new IllegalArgumentException("lambdas always require a block body and explicit return for non-void results"); + } + if (lambda.parameters().size() != expected.parameters().size()) { + throw new IllegalArgumentException("lambda arity " + lambda.parameters().size() + " does not match expected function arity " + expected.parameters().size()); + } + boolean nonLexical = lambda.nonLexical() || parent.descendantsNonLexical(); + Env lambdaEnv = new Env(nonLexical ? null : parent, nonLexical); + for (int i = 0; i < lambda.parameters().size(); i++) { + Ast.Param param = lambda.parameters().get(i); + Type expectedParam = expected.parameters().get(i); + Type declared = param.type().name().equals("$infer$") ? expectedParam : resolveParam(param, generics, self); + requireAssignable(expectedParam, declared, "lambda parameter " + param.name()); + requireAssignable(declared, expectedParam, "lambda parameter " + param.name()); + lambdaEnv.define(param.name(), declared, param.mutable() ? Ast.BindingKind.LET : Ast.BindingKind.VAL); + } + Ast.ClassDecl previousClassOwner = currentClassOwner; + if (nonLexical) currentClassOwner = null; + try { + checkCallableBlock(lambda.blockBody(), lambdaEnv, generics, expected.result(), self); + } finally { + currentClassOwner = previousClassOwner; + } + if (expected.result() != Primitive.VOID && !definitelyReturns(lambda.blockBody())) { + throw new IllegalArgumentException("non-void lambda must explicitly return on every path"); + } + } + + private Type deref(Type type) { + return type instanceof Borrow borrow ? borrow.target() : type; + } + + private Type memberType(Ast.MemberExpr member, Env env, Set generics, Type self) { + Type receiver = unwrapMutexGuard(deref(typeOf(member.receiver(), env, generics, self))); + if (receiver instanceof Record record) { + Type result = record.members().get(member.member()); + if (result == null) throw new IllegalArgumentException("unknown structural member '" + member.member() + "'"); + return result; + } + if (receiver instanceof Named dynamic && dynamic.name().equals("DynamicStruct")) { + if (dynamic.arguments().size() != 1) { + throw new IllegalArgumentException("DynamicStruct requires exactly one value type"); + } + return dynamic.arguments().getFirst(); + } + if (receiver instanceof Named named) { + Ast.ClassDecl klass = findClass(named.name()); + if (klass != null) { + ResolvedField field = findFieldTarget(klass, named, member.member(), new LinkedHashSet<>()); + if (field != null) { + requireClassMemberVisible( + field.field().visibility(), field.owner(), "field", field.field().name()); + Type pattern = resolve(field.field().type(), Set.copyOf(field.owner().genericParameters()), field.ownerType()); + return substituteGenerics(pattern, classGenericBindings(field.owner(), field.ownerType())); + } + } + } + throw new IllegalArgumentException("assignment target '" + member.member() + "' is not a mutable data field"); + } + + private void validateAsyncBoundaryType( + Type type, + boolean returnPosition, + String where) { + if (returnPosition && type == Primitive.VOID) return; + if (type == Primitive.VOID + || !isSharedSafe(type, new LinkedHashSet<>(), Map.of())) { + throw new IllegalArgumentException( + where + " must be concrete owned task-safe data; borrows, futures, mutexes, functions, actor values, host capabilities, and unresolved generics cannot cross an async task boundary"); + } + } + + private void validateActorCallableBoundaryType( + Type type, + Ast.ActorKind actorKind, + boolean returnPosition, + String where) { + if (returnPosition && type == Primitive.VOID) return; + if (type == Primitive.VOID) { + throw new IllegalArgumentException(where + " cannot be void"); + } + if (type == Unknown.INSTANCE || type instanceof Generic || type instanceof Borrow + || type instanceof Function || type instanceof ClassNamespace) { + throw new IllegalArgumentException( + where + " must be a concrete owned/sendable data type; borrows, functions, and unresolved types cannot cross an actor boundary"); + } + if (type instanceof Primitive || type instanceof StringLiteral) return; + if (type instanceof ListType list) { + validateActorCallableBoundaryType(list.element(), actorKind, false, where + " element"); + return; + } + if (type instanceof Tuple tuple) { + for (int i = 0; i < tuple.elements().size(); i++) { + validateActorCallableBoundaryType(tuple.elements().get(i), actorKind, false, where + " tuple element " + i); + } + return; + } + if (type instanceof Union union) { + for (Type option : union.options()) { + validateActorCallableBoundaryType(option, actorKind, false, where + " union member"); + } + return; + } + if (type instanceof Record record) { + for (Map.Entry member : record.members().entrySet()) { + validateActorCallableBoundaryType(member.getValue(), actorKind, false, where + " field '" + member.getKey() + "'"); + } + return; + } + if (!(type instanceof Named named)) { + throw new IllegalArgumentException(where + " is not actor-boundary sendable: " + type); + } + + if (named.name().equals("Mutex") + || named.name().equals("MutexGuard") + || named.name().equals("Future") + || named.name().equals("Channel") + || named.name().equals("SelectCase") + || named.name().equals("SelectSet") + || named.name().equals("SelectResult")) { + throw new IllegalArgumentException( + where + " cannot use " + named.name() + + " across an actor boundary; actor communication uses ActorRef/mailbox transport"); + } + if (named.name().equals("DynamicStruct")) { + if (named.arguments().size() != 1) { + throw new IllegalArgumentException(where + " requires DynamicStruct with one value type"); + } + validateActorCallableBoundaryType( + named.arguments().getFirst(), + actorKind, + false, + where + " value"); + return; + } + if (named.name().equals("SharedMutex")) { + if (actorKind == Ast.ActorKind.PRIVATE) { + throw new IllegalArgumentException( + where + " cannot use SharedMutex with isoactor/private actors"); + } + if (named.arguments().size() != 1 + || !isSharedSafe(named.arguments().getFirst(), new LinkedHashSet<>(), Map.of())) { + throw new IllegalArgumentException( + where + " requires SharedMutex to contain shared-safe owned data"); + } + return; + } + + for (Type argument : named.arguments()) { + validateActorCallableBoundaryType(argument, actorKind, false, where + " type argument"); + } + + // Built-in sum/container values are data-only when their arguments pass. + if (named.name().equals("Option") || named.name().equals("Result") + || named.name().equals("OptionUnwrapError")) { + return; + } + + Ast.ClassDecl klass = findClass(named.name()); + if (klass == null) { + // Runtime capability types (for example ActorId/ActorRef) are + // validated again by transport. Do not silently admit known + // mutable/suspending primitives above, but avoid rejecting opaque + // capability types solely because they have no source class body. + return; + } + if (klass.actorKind() != Ast.ActorKind.NONE) { + throw new IllegalArgumentException( + where + " cannot transport an actor instance by value; pass an actor capability/reference"); + } + + if (!isSharedSafe(type, new LinkedHashSet<>(), Map.of())) { + throw new IllegalArgumentException( + where + " contains state that is not safe to transport across an actor boundary"); + } + } + + private boolean isSharedSafe(Type type, Set seen, Map genericBindings) { + if (type == Unknown.INSTANCE || type instanceof Borrow || type instanceof Function || type instanceof ClassNamespace) return false; + if (type instanceof Primitive primitive) return primitive != Primitive.VOID; + if (type instanceof StringLiteral) return true; + if (type instanceof Generic generic) { + Type bound = genericBindings.get(generic.name()); + return bound != null && bound != type && isSharedSafe(bound, seen, genericBindings); + } + if (type instanceof ListType list) return isSharedSafe(list.element(), seen, genericBindings); + if (type instanceof Union union) { + for (Type option : union.options()) if (!isSharedSafe(option, seen, genericBindings)) return false; + return true; + } + if (type instanceof Tuple tuple) { + for (Type element : tuple.elements()) if (!isSharedSafe(element, seen, genericBindings)) return false; + return true; + } + if (type instanceof Record record) { + for (Type member : record.members().values()) if (!isSharedSafe(member, seen, genericBindings)) return false; + return true; + } + if (!(type instanceof Named named)) return false; + + if (named.name().equals("Mutex") || named.name().equals("MutexGuard") + || named.name().equals("Future") || named.name().equals("SharedMutex")) return false; + if (named.name().equals("DynamicStruct")) { + return named.arguments().size() == 1 + && isSharedSafe(named.arguments().getFirst(), seen, genericBindings); + } + if (named.name().equals("OptionUnwrapError")) return named.arguments().isEmpty(); + if (named.name().equals("Option")) { + return named.arguments().size() == 1 && isSharedSafe(named.arguments().getFirst(), seen, genericBindings); + } + if (named.name().equals("Result")) { + return named.arguments().size() == 2 + && isSharedSafe(named.arguments().get(0), seen, genericBindings) + && isSharedSafe(named.arguments().get(1), seen, genericBindings); + } + + for (Type argument : named.arguments()) { + if (!isSharedSafe(argument, seen, genericBindings)) return false; + } + + Ast.ClassDecl klass = findClass(named.name()); + if (klass == null) return false; + if (klass.actorKind() != Ast.ActorKind.NONE) return false; + if (!seen.add(klass)) return true; + + try { + Map classBindings = new HashMap<>(genericBindings); + if (named.arguments().size() != klass.genericParameters().size()) return false; + for (int i = 0; i < klass.genericParameters().size(); i++) { + classBindings.put(klass.genericParameters().get(i), + resolveSharedGeneric(named.arguments().get(i), genericBindings)); + } + + Type nominal = nominalClassType(klass); + Set classGenerics = Set.copyOf(klass.genericParameters()); + + // Check only fields declared by this class under this class's own + // generic environment. Flattening inherited fields here is unsafe: + // a parent T must never be resolved as an unrelated child T. + for (Ast.FieldDecl field : klass.fields()) { + Type fieldType = resolveSharedGeneric( + resolve(field.type(), classGenerics, nominal), + classBindings); + if (!isSharedSafe(fieldType, seen, classBindings)) return false; + } + + // Recurse into each parent after substituting the child's generic + // bindings into the parent's concrete type arguments. + for (Ast.TypeRef parentRef : klass.parents()) { + if (parentRef.name().equals("Object") || parentRef.name().equals("List")) continue; + Type parentType = resolveSharedGeneric(resolve(parentRef, classGenerics, nominal), classBindings); + if (!isSharedSafe(parentType, seen, classBindings)) return false; + } + + return true; + } finally { + seen.remove(klass); + } + } + + private Type typeOfCondition(Ast.Expr expr, Env env, Set generics, Type self) { + if (expr instanceof Ast.BinaryExpr binary && binary.operator().equals("&&")) { + requireAssignable(typeOfCondition(binary.left(), env, generics, self), Primitive.BOOL, "logical operand"); + Env rightEnv = new Env(env); + defineConditionBindings(binary.left(), rightEnv, env, generics, self); + requireAssignable(typeOfCondition(binary.right(), rightEnv, generics, self), Primitive.BOOL, "logical operand"); + return Primitive.BOOL; + } + return typeOf(expr, env, generics, self); + } + + private void defineConditionBindings( + Ast.Expr condition, Env destination, Env sourceEnv, Set generics, Type self) { + if (condition instanceof Ast.TypeTestExpr test && test.binding() != null) { + Type source = deref(typeOf(test.value(), sourceEnv, generics, self)); + Type target = deref(resolve(test.targetType(), generics, self)); + if (!typesMayOverlap(source, target)) { + throw new IllegalArgumentException("impossible type refinement: " + source + " cannot be " + target); + } + destination.define(test.binding(), target, Ast.BindingKind.VAL); + return; + } + if (condition instanceof Ast.PatternTestExpr test) { + Type subject = deref(typeOf(test.value(), sourceEnv, generics, self)); + checkPattern(test.pattern(), subject, destination, generics, self); + return; + } + if (condition instanceof Ast.BinaryExpr binary && binary.operator().equals("&&")) { + defineConditionBindings(binary.left(), destination, sourceEnv, generics, self); + defineConditionBindings(binary.right(), destination, destination, generics, self); + } + } + + private void checkMatch( + Ast.MatchStmt matched, + Type subject, + Env env, + Set generics, + Type expectedReturn, + Type self) { + List arms = matched.arms(); + for (int i = 0; i < arms.size(); i++) { + Ast.MatchArm arm = arms.get(i); + boolean fallback = isFallbackArm(arm); + if (!matched.ordered() && fallback && i != arms.size() - 1) { + throw new IllegalArgumentException("exclusive match fallback '_'/'else'/catch-all binding must be the final arm"); + } + + Env armEnv = new Env(env); + checkPattern(arm.pattern(), subject, armEnv, generics, self); + if (arm.guard() != null) { + requireAssignable(typeOfCondition(arm.guard(), armEnv, generics, self), Primitive.BOOL, "match guard"); + } + checkBlock(arm.body(), armEnv, generics, expectedReturn, self); + + if (!matched.ordered() && !fallback) { + for (int j = 0; j < i; j++) { + Ast.MatchArm previous = arms.get(j); + if (isFallbackArm(previous)) { + throw new IllegalArgumentException("exclusive match fallback must be the final arm"); + } + if (!patternsProvablyDisjoint(previous.pattern(), arm.pattern(), subject, generics, self) + && !guardsProvablyDisjoint(previous.guard(), arm.guard())) { + throw new IllegalArgumentException( + "overlapping match arms " + (j + 1) + " and " + (i + 1) + + "; exclusive match requires a proof of disjointness" + + " (use 'match first' only when priority semantics are intentional)"); + } + } + } + } + + if (!matchProvablyExhaustive(arms, subject, generics, self)) { + throw new IllegalArgumentException( + "non-exhaustive match for " + subject + + "; add an unguarded '_'/'else' arm or cover every known constructor/value"); + } + } + + private void checkPattern( + Ast.Pattern pattern, Type subject, Env bindings, Set generics, Type self) { + if (pattern instanceof Ast.WildcardPattern) return; + if (pattern instanceof Ast.BindingPattern binding) { + bindings.define(binding.name(), subject, Ast.BindingKind.VAL); + return; + } + if (pattern instanceof Ast.LiteralPattern literal) { + Type literalType = typeOf(new Ast.LiteralExpr(literal.value()), bindings, generics, self); + if (!assignable(literalType, subject) && !assignable(subject, literalType)) { + throw new IllegalArgumentException("literal pattern type " + literalType + " is incompatible with " + subject); + } + return; + } + if (pattern instanceof Ast.TypePattern typed) { + requireRuntimeReifiableType(typed.type(), "is pattern"); + Type target = deref(resolve(typed.type(), generics, self)); + if (!typesMayOverlap(subject, target)) { + throw new IllegalArgumentException("unreachable type pattern: " + subject + " cannot be " + target); + } + if (typed.binding() != null) bindings.define(typed.binding(), target, Ast.BindingKind.VAL); + return; + } + if (pattern instanceof Ast.ConstructorPattern constructor) { + if (!(subject instanceof Named named)) { + throw new IllegalArgumentException("constructor pattern " + constructor.constructor() + " requires a sum/constructor type, got " + subject); + } + List args = switch (constructor.constructor()) { + case "Some" -> named.name().equals("Option") && named.arguments().size() == 1 + ? List.of(named.arguments().getFirst()) : null; + case "None" -> named.name().equals("Option") && named.arguments().size() == 1 + ? List.of() : null; + case "Ok" -> named.name().equals("Result") && named.arguments().size() == 2 + ? List.of(named.arguments().get(0)) : null; + case "Err" -> named.name().equals("Result") && named.arguments().size() == 2 + ? List.of(named.arguments().get(1)) : null; + default -> null; + }; + if (args == null) { + throw new IllegalArgumentException("constructor " + constructor.constructor() + " is not valid for " + subject); + } + if (args.size() != constructor.arguments().size()) { + throw new IllegalArgumentException("constructor pattern " + constructor.constructor() + + " expects " + args.size() + " argument(s), got " + constructor.arguments().size()); + } + for (int i = 0; i < args.size(); i++) { + checkPattern(constructor.arguments().get(i), args.get(i), bindings, generics, self); + } + return; + } + throw new IllegalArgumentException("unsupported pattern " + pattern); + } + + private boolean patternsProvablyDisjoint( + Ast.Pattern left, Ast.Pattern right, Type subject, Set generics, Type self) { + if (left instanceof Ast.WildcardPattern || right instanceof Ast.WildcardPattern + || left instanceof Ast.BindingPattern || right instanceof Ast.BindingPattern) return false; + if (left instanceof Ast.LiteralPattern a && right instanceof Ast.LiteralPattern b) { + return !java.util.Objects.equals(a.value(), b.value()); + } + if (left instanceof Ast.ConstructorPattern a && right instanceof Ast.ConstructorPattern b) { + if (!a.constructor().equals(b.constructor())) { + if ((a.constructor().equals("Some") && b.constructor().equals("None")) + || (a.constructor().equals("None") && b.constructor().equals("Some")) + || (a.constructor().equals("Ok") && b.constructor().equals("Err")) + || (a.constructor().equals("Err") && b.constructor().equals("Ok"))) return true; + } + return false; + } + if (left instanceof Ast.TypePattern a && right instanceof Ast.TypePattern b) { + Type at = deref(resolve(a.type(), generics, self)); + Type bt = deref(resolve(b.type(), generics, self)); + return !typesMayOverlap(at, bt); + } + return false; + } + + private boolean guardsProvablyDisjoint(Ast.Expr left, Ast.Expr right) { + GuardRange a = guardRange(left); + GuardRange b = guardRange(right); + if (a == null || b == null || !a.variable().equals(b.variable())) return false; + return a.max() < b.min() || b.max() < a.min() + || (a.max() == b.min() && (!a.maxInclusive() || !b.minInclusive())) + || (b.max() == a.min() && (!b.maxInclusive() || !a.minInclusive())); + } + + private GuardRange guardRange(Ast.Expr guard) { + if (!(guard instanceof Ast.BinaryExpr binary) + || !(binary.left() instanceof Ast.NameExpr name) + || !(binary.right() instanceof Ast.LiteralExpr literal) + || !(literal.value() instanceof Number number)) return null; + double v = number.doubleValue(); + return switch (binary.operator()) { + case "<" -> new GuardRange(name.name(), Double.NEGATIVE_INFINITY, false, v, false); + case "<=" -> new GuardRange(name.name(), Double.NEGATIVE_INFINITY, false, v, true); + case ">" -> new GuardRange(name.name(), v, false, Double.POSITIVE_INFINITY, false); + case ">=" -> new GuardRange(name.name(), v, true, Double.POSITIVE_INFINITY, false); + case "==" -> new GuardRange(name.name(), v, true, v, true); + default -> null; + }; + } + + private record GuardRange( + String variable, double min, boolean minInclusive, double max, boolean maxInclusive) { } + + private boolean isFallbackArm(Ast.MatchArm arm) { + return arm.guard() == null + && (arm.pattern() instanceof Ast.WildcardPattern + || arm.pattern() instanceof Ast.BindingPattern); + } + + private boolean matchProvablyExhaustive( + List arms, Type subject, Set generics, Type self) { + for (Ast.MatchArm arm : arms) { + if (arm.guard() != null) continue; + if (arm.pattern() instanceof Ast.WildcardPattern || arm.pattern() instanceof Ast.BindingPattern) return true; + if (arm.pattern() instanceof Ast.TypePattern typed) { + Type target = deref(resolve(typed.type(), generics, self)); + if (assignable(subject, target)) return true; + } + } + if (subject == Primitive.BOOL) { + boolean yes = false, no = false; + for (Ast.MatchArm arm : arms) if (arm.guard() == null && arm.pattern() instanceof Ast.LiteralPattern literal) { + if (Boolean.TRUE.equals(literal.value())) yes = true; + if (Boolean.FALSE.equals(literal.value())) no = true; + } + return yes && no; + } + if (subject instanceof Named named && named.name().equals("Option")) { + boolean some = false, none = false; + for (Ast.MatchArm arm : arms) if (arm.guard() == null && arm.pattern() instanceof Ast.ConstructorPattern c) { + some |= c.constructor().equals("Some"); + none |= c.constructor().equals("None"); + } + return some && none; + } + if (subject instanceof Named named && named.name().equals("Result")) { + boolean ok = false, err = false; + for (Ast.MatchArm arm : arms) if (arm.guard() == null && arm.pattern() instanceof Ast.ConstructorPattern c) { + ok |= c.constructor().equals("Ok"); + err |= c.constructor().equals("Err"); + } + return ok && err; + } + return false; + } + + /** + * Runtime-domain intersection, deliberately stricter than assignment + * compatibility. Numeric widening (int -> float) is not a runtime type + * identity relation, while open nominal types may share a future/common + * subtype and therefore cannot be declared disjoint without a proof. + */ + private boolean typesMayOverlap(Type a, Type b) { + if (a == Unknown.INSTANCE || b == Unknown.INSTANCE + || a instanceof Generic || b instanceof Generic) return true; + if (a instanceof Union union) return union.options().stream().anyMatch(option -> typesMayOverlap(option, b)); + if (b instanceof Union union) return union.options().stream().anyMatch(option -> typesMayOverlap(a, option)); + + if (a instanceof StringLiteral && (b instanceof StringLiteral || b == Primitive.STRING)) return true; + if (b instanceof StringLiteral && (a instanceof StringLiteral || a == Primitive.STRING)) return true; + + if (a instanceof Primitive ap && b instanceof Primitive bp) { + if (ap == bp) return true; + // FLOAT and DECIMAL currently share the same runtime scalar domain. + return (ap == Primitive.FLOAT && bp == Primitive.DECIMAL) + || (ap == Primitive.DECIMAL && bp == Primitive.FLOAT); + } + + if (a instanceof Named an && b instanceof Named bn) { + boolean aNominal = findClass(an.name()) != null || findInterface(an.name()) != null; + boolean bNominal = findClass(bn.name()) != null || findInterface(bn.name()) != null; + if (aNominal && bNominal) { + // Oreslang permits interface composition and multiple class + // parents. Without sealed/final proof metadata, unrelated open + // nominal types are not safely disjoint. + return true; + } + if (aNominal != bNominal) return false; + return an.name().equals(bn.name()); + } + + if (a instanceof ListType && b instanceof ListType) return true; + if (a instanceof Tuple at && b instanceof Tuple bt) { + if (at.elements().size() != bt.elements().size()) return false; + for (int i = 0; i < at.elements().size(); i++) { + if (!typesMayOverlap(at.elements().get(i), bt.elements().get(i))) return false; + } + return true; + } + if (a instanceof Record && b instanceof Record) return true; + return a.equals(b); + } + + private void requireRuntimeReifiableType(Ast.TypeRef ref, String operator) { + if (ref == null) throw new IllegalArgumentException(operator + " requires a runtime-reifiable type"); + if (ref.isUnion()) { + for (Ast.TypeRef option : ref.arguments()) requireRuntimeReifiableType(option, operator); + return; + } + if (ref.isBorrow() || ref.isTupleType() || ref.isRecordType() || ref.isStringLiteral() + || ref.name().equals("$infer$") || ref.name().equals("self") + || ref.name().equals("Fnc")) { + throw new IllegalArgumentException( + operator + " requires a nominal/runtime-reifiable type; use 'matches' for structural patterns"); + } + if (ref.inferArguments() || !ref.arguments().isEmpty()) { + throw new IllegalArgumentException( + operator + " cannot test parameterized type '" + ref + + "' until generic runtime type arguments are reified; match constructors/structure instead"); + } + } + + private Type resolveSharedGeneric(Type type, Map bindings) { + if (type instanceof Generic generic) { + Type bound = bindings.get(generic.name()); + return bound == null || bound == type ? type : resolveSharedGeneric(bound, bindings); + } + if (type instanceof Named named) { + return new Named(named.name(), named.arguments().stream() + .map(argument -> resolveSharedGeneric(argument, bindings)).toList()); + } + if (type instanceof ListType list) return new ListType(resolveSharedGeneric(list.element(), bindings)); + if (type instanceof Tuple tuple) { + return new Tuple(tuple.elements().stream().map(element -> resolveSharedGeneric(element, bindings)).toList()); + } + if (type instanceof Union union) { + return Types.unionOf(union.options().stream() + .map(option -> resolveSharedGeneric(option, bindings)) + .toList()); + } + if (type instanceof Record record) { + Map members = new LinkedHashMap<>(); + for (Map.Entry entry : record.members().entrySet()) { + members.put(entry.getKey(), resolveSharedGeneric(entry.getValue(), bindings)); + } + return new Record(members); + } + return type; + } + + private Type builtinOptionResultMember(Type receiver, String member) { + if (!(receiver instanceof Named named)) return null; + if (named.name().equals("Option") && named.arguments().size() == 1) { + Type element = named.arguments().getFirst(); + return switch (member) { + case "is_some", "is_none" -> new Function(List.of(), Primitive.BOOL); + case "unwrap" -> new Function(List.of(), element); + case "unwrap_safe" -> new Function( + List.of(), + new Named("Result", List.of(element, new Named("OptionUnwrapError", List.of())))); + case "expect" -> new Function(List.of(Primitive.STRING), element); + case "unwrap_or" -> new Function(List.of(element), element); + default -> null; + }; + } + if (named.name().equals("Result") && named.arguments().size() == 2) { + Type ok = named.arguments().get(0); + return switch (member) { + case "is_ok", "is_err" -> new Function(List.of(), Primitive.BOOL); + case "unwrap" -> new Function(List.of(), ok); + case "unwrap_safe" -> new Function(List.of(), named); + case "expect" -> new Function(List.of(Primitive.STRING), ok); + case "unwrap_or" -> new Function(List.of(ok), ok); + default -> null; + }; + } + return null; + } + + private Type builtinMutexMember(Type receiver, String member) { + if (!(receiver instanceof Named named) || named.arguments().size() != 1) return null; + Type element = named.arguments().getFirst(); + if (named.name().equals("Mutex") || named.name().equals("SharedMutex")) { + if (named.name().equals("SharedMutex") + && currentActorKind != Ast.ActorKind.NONE + && (member.equals("lock") || member.equals("with_lock"))) { + throw new IllegalArgumentException( + "actor code cannot use blocking SharedMutex." + member + + "(); use try_lock() or await lock_async()"); + } + Type guard = new Named("MutexGuard", List.of(element)); + return switch (member) { + case "lock" -> new Function(List.of(), guard); + case "try_lock" -> new Function(List.of(), new Named("Option", List.of(guard))); + case "lock_async" -> new Function(List.of(), new Named("Future", List.of(guard))); + case "with_lock" -> new Function(List.of(new Function(List.of(element), Primitive.VOID)), Primitive.VOID); + case "is_poisoned" -> new Function(List.of(), Primitive.BOOL); + case "recover" -> named.name().equals("SharedMutex") + ? new Function(List.of(new Function(List.of(element), Primitive.VOID)), Primitive.VOID) + : null; + default -> null; + }; + } + if (named.name().equals("MutexGuard")) { + return switch (member) { + case "release" -> new Function(List.of(), Primitive.VOID); + case "is_released" -> new Function(List.of(), Primitive.BOOL); + default -> null; + }; + } + return null; + } + + private Type unwrapMutexGuard(Type type) { + if (type instanceof Named named && named.name().equals("MutexGuard") && named.arguments().size() == 1) { + return named.arguments().getFirst(); + } + return type; + } + + private Type iterableElementType(Type iterable) { + if (iterable instanceof ListType list) return list.element(); + if (iterable instanceof Tuple tuple) { + Type result = Unknown.INSTANCE; + for (Type element : tuple.elements()) result = result == Unknown.INSTANCE ? element : commonType(result, element); + return result; + } + if (iterable instanceof Named named) { + Ast.ClassDecl klass = findClass(named.name()); + if (klass != null) { + ResolvedMethod iteratorTarget = findMethodTarget(klass, named, "Symbol.iterator", 0, new LinkedHashSet<>()); + if (iteratorTarget != null) { + Ast.MethodDecl iterator = iteratorTarget.method(); + requireClassMemberVisible( + iterator.visibility(), + iteratorTarget.owner(), + "method", + iterator.name()); + Set iteratorGenerics = new HashSet<>(iteratorTarget.owner().genericParameters()); + iteratorGenerics.addAll(iterator.genericParameters()); + Type result = resolve(iterator.returnType(), iteratorGenerics, iteratorTarget.ownerType()); + result = substituteGenerics(result, classGenericBindings(iteratorTarget.owner(), iteratorTarget.ownerType())); + if (result instanceof ListType list) return list.element(); + if (result instanceof Tuple tuple) return iterableElementType(tuple); + throw new IllegalArgumentException("[Symbol.iterator]() must return Array, List, or a tuple in v0"); + } + } + } + throw new IllegalArgumentException("for-of requires an array/list, tuple, or a class with [Symbol.iterator]()"); + } + + private Type widenCollectionElement(Type type) { + return type instanceof StringLiteral ? Primitive.STRING : type; + } + + private Type collectionElementJoin(Type a, Type b) { + if (assignable(b, a) && assignable(a, b)) return a; + if (Types.isNumeric(a) && Types.isNumeric(b)) return Types.numericJoin(a, b); + if (isStringLike(a) && isStringLike(b)) return Primitive.STRING; + return Types.unionOf(a, b); + } + + private Type commonType(Type a, Type b) { + if (assignable(b, a) && assignable(a, b)) return a; + if (Types.isNumeric(a) && Types.isNumeric(b)) return Types.numericJoin(a, b); + if (isStringLike(a) && isStringLike(b)) return Primitive.STRING; + return Unknown.INSTANCE; + } + + private void requireInteger(Type type, String where) { + if (type != Primitive.INT) throw new IllegalArgumentException(where + " must be an integer type"); + } + + private Type numericJoin(Type left, Type right, String op) { + Type result = Types.numericJoin(left, right); + if (result == Unknown.INSTANCE) throw new IllegalArgumentException("operator '" + op + "' needs numeric operands"); + return result; + } + + private boolean isStringLike(Type type) { + return type == Primitive.STRING || type instanceof StringLiteral; + } + + private Record classShape(Ast.ClassDecl klass, Set stack) { + Record cached = classShapeCache.get(klass); + if (cached != null) return cached; + if (!stack.add(klass)) throw new IllegalArgumentException("inheritance cycle involving class '" + klass.name() + "'"); + Map members = new LinkedHashMap<>(); + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, klass); + if (parent != null) { + Map parentBindings = parentBindings(parentRef, klass, parent); + for (Map.Entry inherited : classShape(parent, stack).members().entrySet()) { + mergeMember(members, inherited.getKey(), substituteGenerics(inherited.getValue(), parentBindings), + "multiple inheritance of " + klass.name()); + } + } + } + Set generics = Set.copyOf(klass.genericParameters()); + Type self = nominalClassType(klass); + for (Ast.FieldDecl field : klass.fields()) mergeMember(members, field.name(), classFieldType(klass, field), "class " + klass.name()); + for (Ast.MethodDecl method : klass.methods()) { + if (method.isStatic()) continue; + mergeMember(members, + methodContractKey(method.name(), method.arity(), method.genericParameters().size()), + callableContractType(method.genericParameters(), method.parameters(), method.returnType(), method.async(), generics, self), + "class " + klass.name()); + } + stack.remove(klass); + Record result = new Record(members); + classShapeCache.put(klass, result); + return result; + } + + private Record publicClassShape(Ast.ClassDecl klass, Set stack) { + if (!stack.add(klass)) throw new IllegalArgumentException("inheritance cycle involving class '" + klass.name() + "'"); + Map members = new LinkedHashMap<>(); + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, klass); + if (parent != null) { + Map parentBindings = parentBindings(parentRef, klass, parent); + for (Map.Entry inherited : publicClassShape(parent, stack).members().entrySet()) { + mergeMember(members, inherited.getKey(), substituteGenerics(inherited.getValue(), parentBindings), + "public inheritance of " + klass.name()); + } + } + } + Set generics = Set.copyOf(klass.genericParameters()); + Type self = nominalClassType(klass); + for (Ast.FieldDecl field : klass.fields()) { + if (field.visibility() == Ast.Visibility.PUBLIC) mergeMember(members, field.name(), classFieldType(klass, field), "class " + klass.name()); + } + for (Ast.MethodDecl method : klass.methods()) { + if (!method.isStatic() && method.visibility() == Ast.Visibility.PUBLIC) { + mergeMember(members, + methodContractKey(method.name(), method.arity(), method.genericParameters().size()), + callableContractType(method.genericParameters(), method.parameters(), method.returnType(), method.async(), generics, self), + "class " + klass.name()); + } + } + stack.remove(klass); + return new Record(members); + } + + private Record interfaceShape(Ast.InterfaceDecl iface, Set generics, Set stack) { + if (!stack.add(iface)) throw new IllegalArgumentException("interface inheritance cycle involving '" + iface.name() + "'"); + Map members = new LinkedHashMap<>(); + for (Ast.TypeRef parentRef : iface.parents()) { + Ast.InterfaceDecl parent = findInterface(parentRef.name()); + if (parent == null) throw new IllegalArgumentException("unknown parent interface '" + parentRef.name() + "' for " + iface.name()); + Type resolvedParent = resolve(parentRef, generics, null); + if (!(resolvedParent instanceof Named parentType)) { + throw new IllegalArgumentException("parent interface must resolve to a named type"); + } + Map parentBindings = genericBindings(parent.genericParameters(), parentType.arguments(), + "interface " + parent.name()); + for (Map.Entry inherited : interfaceShape(parent, Set.copyOf(parent.genericParameters()), stack).members().entrySet()) { + mergeMember(members, inherited.getKey(), substituteGenerics(inherited.getValue(), parentBindings), + "interface inheritance of " + iface.name()); + } + } + for (Ast.InterfaceMember member : iface.members()) { + if (member instanceof Ast.InterfaceFunctionDecl fn) { + mergeMember(members, + methodContractKey(fn.name(), fn.parameters().size(), fn.genericParameters().size()), + callableContractType(fn.genericParameters(), fn.parameters(), fn.returnType(), false, generics, null), + "interface " + iface.name()); + } else if (member instanceof Ast.InterfaceFieldDecl field) { + mergeMember(members, field.name(), resolve(field.type(), generics, null), "interface " + iface.name()); + } + } + stack.remove(iface); + return new Record(members); + } + + private Named inferConstructedClassType( + Ast.ClassDecl klass, + List arguments, + Env env, + Set callerGenerics, + Type callerSelf) { + if (klass.genericParameters().isEmpty()) { + throw new IllegalArgumentException("class " + klass.name() + " is not generic; remove <>"); + } + + Named patternType = nominalClassType(klass); + List fields = effectiveFieldTargets(klass, patternType, new LinkedHashSet<>()); + if (arguments.size() > fields.size()) { + throw new IllegalArgumentException("constructor for " + klass.name() + " received too many positional fields"); + } + + Map bindings = new HashMap<>(); + Set classGenericNames = Set.copyOf(klass.genericParameters()); + for (int i = 0; i < arguments.size(); i++) { + ResolvedField resolvedField = fields.get(i); + Type fieldPattern = resolve( + resolvedField.field().type(), + Set.copyOf(resolvedField.owner().genericParameters()), + resolvedField.ownerType()); + fieldPattern = substituteGenerics( + fieldPattern, + classGenericBindings(resolvedField.owner(), resolvedField.ownerType())); + Type actual = typeOf(arguments.get(i), env, callerGenerics, callerSelf); + inferGenericBindings( + fieldPattern, + actual, + bindings, + Set.of(), + "constructor " + klass.name()); + } + + List inferred = new ArrayList<>(klass.genericParameters().size()); + for (String generic : klass.genericParameters()) { + Type bound = bindings.get(generic); + if (bound == null || containsGenericNamed(bound, classGenericNames)) { + throw new IllegalArgumentException( + "cannot infer class generic '" + generic + "' for constructor " + + klass.name() + "<>; provide explicit type arguments"); + } + inferred.add(bound); + } + return new Named(qualifiedClassName(klass), inferred); + } + + private Map genericBindings(List names, List arguments, String owner) { + if (names.size() != arguments.size()) { + throw new IllegalArgumentException(owner + " expects " + names.size() + " type argument(s), got " + arguments.size()); + } + Map result = new HashMap<>(); + for (int i = 0; i < names.size(); i++) result.put(names.get(i), arguments.get(i)); + return result; + } + + private Map parentBindings(Ast.TypeRef parentRef, Ast.ClassDecl child, Ast.ClassDecl parent) { + Type resolved = resolve(parentRef, Set.copyOf(child.genericParameters()), nominalClassType(child)); + if (!(resolved instanceof Named named)) throw new IllegalArgumentException("parent class must resolve to a named type"); + return genericBindings(parent.genericParameters(), named.arguments(), "class " + parent.name()); + } + + private Named concreteParentType(Ast.TypeRef parentRef, Ast.ClassDecl child, Named childType) { + Type parentPattern = resolve(parentRef, Set.copyOf(child.genericParameters()), nominalClassType(child)); + Type concrete = substituteGenerics(parentPattern, classGenericBindings(child, childType)); + if (!(concrete instanceof Named named)) throw new IllegalArgumentException("parent class must resolve to a named type"); + return named; + } + + private Type classFieldType(Ast.ClassDecl klass, Ast.FieldDecl field) { + Set generics = Set.copyOf(klass.genericParameters()); + Type self = nominalClassType(klass); + if (field.type() != null) return resolve(field.type(), generics, self); + if (field.initializer() == null) { + throw new IllegalArgumentException("inferred field '" + klass.name() + "." + field.name() + + "' requires an initializer"); + } + return typeOf(field.initializer(), new Env(null), generics, self); + } + + private void validateFieldLayout(Ast.ClassDecl klass) { + collectFieldLayout( + klass, + nominalClassType(klass), + new LinkedHashMap<>(), + new LinkedHashSet<>()); + } + + private void collectFieldLayout( + Ast.ClassDecl klass, + Named concreteType, + Map fields, + Set stack) { + if (!stack.add(klass)) { + throw new IllegalArgumentException( + "inheritance cycle involving class '" + klass.name() + "'"); + } + + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, klass); + if (parent == null) continue; + Named parentType = concreteParentType(parentRef, klass, concreteType); + collectFieldLayout(parent, parentType, fields, stack); + } + + Set localNames = new LinkedHashSet<>(); + for (Ast.FieldDecl field : klass.fields()) { + if (!localNames.add(field.name())) { + throw new IllegalArgumentException( + "duplicate field '" + klass.name() + "." + field.name() + "'"); + } + + ResolvedField candidate = new ResolvedField(klass, concreteType, field); + ResolvedField previous = fields.putIfAbsent(field.name(), candidate); + if (previous != null + && (previous.owner() != candidate.owner() + || !previous.ownerType().equals(candidate.ownerType()))) { + throw new IllegalArgumentException( + "field '" + klass.name() + "." + field.name() + + "' collides with inherited field slot " + + previous.owner().name() + previous.ownerType().arguments() + + "; class storage field names must be unique across inheritance " + + "and generic diamond paths must use the same concrete instantiation"); + } + } + + stack.remove(klass); + } + + private List effectiveFieldTargets(Ast.ClassDecl klass, Named concreteType, Set stack) { + if (!stack.add(klass)) throw new IllegalArgumentException("inheritance cycle involving class '" + klass.name() + "'"); + LinkedHashMap fields = new LinkedHashMap<>(); + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, klass); + if (parent == null) continue; + Named parentType = concreteParentType(parentRef, klass, concreteType); + for (ResolvedField field : effectiveFieldTargets(parent, parentType, stack)) fields.putIfAbsent(field.field().name(), field); + } + for (Ast.FieldDecl field : klass.fields()) fields.put(field.name(), new ResolvedField(klass, concreteType, field)); + stack.remove(klass); + return List.copyOf(fields.values()); + } + + private ResolvedField findFieldTarget(Ast.ClassDecl klass, Named concreteType, String name, Set seen) { + if (!seen.add(klass)) return null; + for (Ast.FieldDecl field : klass.fields()) { + if (field.name().equals(name)) { + seen.remove(klass); + return new ResolvedField(klass, concreteType, field); + } + } + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, klass); + if (parent == null) continue; + Named parentType = concreteParentType(parentRef, klass, concreteType); + ResolvedField result = findFieldTarget(parent, parentType, name, seen); + if (result != null) { + seen.remove(klass); + return result; + } + } + seen.remove(klass); + return null; + } + + private ResolvedMethod findMethodTarget(Ast.ClassDecl klass, Named concreteType, String name, int arity, Set seen) { + if (!seen.add(klass)) return null; + for (Ast.MethodDecl method : klass.methods()) { + if (!method.isStatic() && method.name().equals(name) && method.arity() == arity) { + seen.remove(klass); + return new ResolvedMethod(klass, concreteType, method); + } + } + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, klass); + if (parent == null) continue; + Named parentType = concreteParentType(parentRef, klass, concreteType); + ResolvedMethod result = findMethodTarget(parent, parentType, name, arity, seen); + if (result != null) { + seen.remove(klass); + return result; + } + } + seen.remove(klass); + return null; + } + + private List findMethodsByName(Ast.ClassDecl klass, String name, Set seen) { + if (!seen.add(klass)) return List.of(); + LinkedHashMap methods = new LinkedHashMap<>(); + for (Ast.MethodDecl method : klass.methods()) if (!method.isStatic() && method.name().equals(name)) methods.put(method.arity(), method); + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, klass); + if (parent == null) continue; + for (Ast.MethodDecl method : findMethodsByName(parent, name, seen)) methods.putIfAbsent(method.arity(), method); + } + seen.remove(klass); + return List.copyOf(methods.values()); + } + + private Named concreteInterfaceParentType( + Ast.TypeRef parentRef, + Ast.InterfaceDecl child, + Named childType) { + Type parentPattern = resolve(parentRef, Set.copyOf(child.genericParameters()), null); + Type concrete = substituteGenerics( + parentPattern, + genericBindings(child.genericParameters(), childType.arguments(), "interface " + child.name())); + if (!(concrete instanceof Named named)) { + throw new IllegalArgumentException("parent interface must resolve to a named type"); + } + return named; + } + + private ResolvedInterfaceFunction findInterfaceFunctionTarget( + Ast.InterfaceDecl iface, + Named concreteType, + String name, + int arity, + Set seen) { + if (!seen.add(iface)) return null; + for (Ast.InterfaceMember member : iface.members()) { + if (member instanceof Ast.InterfaceFunctionDecl fn + && fn.name().equals(name) + && fn.parameters().size() == arity) { + seen.remove(iface); + return new ResolvedInterfaceFunction(iface, concreteType, fn); + } + } + for (Ast.TypeRef parentRef : iface.parents()) { + Ast.InterfaceDecl parent = findInterface(parentRef.name()); + if (parent == null) { + throw new IllegalArgumentException( + "unknown parent interface '" + parentRef.name() + "' for " + iface.name()); + } + Named parentType = concreteInterfaceParentType(parentRef, iface, concreteType); + ResolvedInterfaceFunction result = findInterfaceFunctionTarget( + parent, parentType, name, arity, seen); + if (result != null) { + seen.remove(iface); + return result; + } + } + seen.remove(iface); + return null; + } + + private boolean hasInterfaceFunctionNamed( + Ast.InterfaceDecl iface, + String name, + Set seen) { + if (!seen.add(iface)) return false; + for (Ast.InterfaceMember member : iface.members()) { + if (member instanceof Ast.InterfaceFunctionDecl fn && fn.name().equals(name)) { + seen.remove(iface); + return true; + } + } + for (Ast.TypeRef parentRef : iface.parents()) { + Ast.InterfaceDecl parent = findInterface(parentRef.name()); + if (parent != null && hasInterfaceFunctionNamed(parent, name, seen)) { + seen.remove(iface); + return true; + } + } + seen.remove(iface); + return false; + } + + private void collectInstanceMethodNames( + Ast.ClassDecl klass, + Set names, + Set seen) { + if (!seen.add(klass)) return; + for (Ast.MethodDecl method : klass.methods()) { + if (!method.isStatic()) names.add(method.name()); + } + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, klass); + if (parent != null) collectInstanceMethodNames(parent, names, seen); + } + seen.remove(klass); + } + + private void collectInterfaceFieldNames( + Ast.InterfaceDecl iface, + Set names, + Set seen) { + if (!seen.add(iface)) return; + for (Ast.InterfaceMember member : iface.members()) { + if (member instanceof Ast.InterfaceFieldDecl field) names.add(field.name()); + } + for (Ast.TypeRef parentRef : iface.parents()) { + Ast.InterfaceDecl parent = findInterface(parentRef.name()); + if (parent != null) collectInterfaceFieldNames(parent, names, seen); + } + seen.remove(iface); + } + + private void collectInterfaceMethodNames( + Ast.InterfaceDecl iface, + Set names, + Set seen) { + if (!seen.add(iface)) return; + for (Ast.InterfaceMember member : iface.members()) { + if (member instanceof Ast.InterfaceFunctionDecl fn) names.add(fn.name()); + } + for (Ast.TypeRef parentRef : iface.parents()) { + Ast.InterfaceDecl parent = findInterface(parentRef.name()); + if (parent != null) collectInterfaceMethodNames(parent, names, seen); + } + seen.remove(iface); + } + + private void requireClassMemberVisible( + Ast.Visibility visibility, + Ast.ClassDecl owner, + String kind, + String name) { + if (visibility == Ast.Visibility.PRIVATE && currentClassOwner != owner) { + throw new IllegalArgumentException( + "private " + kind + " '" + owner.name() + "." + name + + "' is accessible only from code declared in class " + owner.name()); + } + } + + private Ast.ClassDecl findDeclaringClass( + Ast.ClassDecl klass, + Ast.MethodDecl target, + Set seen) { + if (!seen.add(klass)) return null; + for (Ast.MethodDecl method : klass.methods()) { + if (method == target) { + seen.remove(klass); + return klass; + } + } + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, klass); + if (parent == null) continue; + Ast.ClassDecl owner = findDeclaringClass(parent, target, seen); + if (owner != null) { + seen.remove(klass); + return owner; + } + } + seen.remove(klass); + throw new IllegalStateException( + "cannot find declaring class for method '" + target.name() + "'"); + } + + private Ast.MethodDecl findStaticFunction(Ast.ClassDecl klass, String name, int arity, Set seen) { + if (!seen.add(klass)) return null; + for (Ast.MethodDecl method : klass.methods()) { + if (method.isStatic() && method.name().equals(name) && method.arity() == arity) { + seen.remove(klass); + return method; + } + } + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, klass); + if (parent == null) continue; + Ast.MethodDecl candidate = findStaticFunction(parent, name, arity, seen); + if (candidate != null) { + seen.remove(klass); + return candidate; + } + } + seen.remove(klass); + return null; + } + + private List findStaticFunctionsByName(Ast.ClassDecl klass, String name, Set seen) { + if (!seen.add(klass)) return List.of(); + LinkedHashMap functions = new LinkedHashMap<>(); + for (Ast.MethodDecl method : klass.methods()) if (method.isStatic() && method.name().equals(name)) functions.put(method.arity(), method); + for (Ast.TypeRef parentRef : klass.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, klass); + if (parent == null) continue; + for (Ast.MethodDecl fn : findStaticFunctionsByName(parent, name, seen)) functions.putIfAbsent(fn.arity(), fn); + } + seen.remove(klass); + return List.copyOf(functions.values()); + } + + private Ast.ClassDecl resolveClassParent(Ast.TypeRef parentRef, Ast.ClassDecl child) { + if (parentRef.name().equals("Object") || parentRef.name().equals("List")) return null; + if (parentRef.name().equals("obj") || parentRef.name().equals("arr")) throw new IllegalArgumentException("inline obj/arr values cannot be subclassed; extend Object or List instead"); + Ast.ClassDecl parent = findClass(parentRef.name()); + if (parent == null) throw new IllegalArgumentException("unknown parent class '" + parentRef.name() + "' for " + child.name()); + if (parent == child) throw new IllegalArgumentException("class '" + child.name() + "' cannot extend itself"); + return parent; + } + + private void mergeMember(Map members, String name, Type type, String owner) { + Type existing = members.get(name); + if (existing != null && (!assignable(existing, type) || !assignable(type, existing))) { + throw new IllegalArgumentException("conflicting member '" + name + "' in " + owner + ": " + existing + " vs " + type); + } + members.put(name, type); + } + + private Type asyncResult(boolean async, Type result) { + return async ? new Named("Future", List.of(result)) : result; + } + + private Function functionType( + List params, + Ast.TypeRef returns, + boolean async, + Set generics, + Type self) { + return new Function( + params.stream().map(p -> resolveParam(p, generics, self)).toList(), + asyncResult(async, resolve(returns, generics, self))); + } + + private Function callableContractType( + List callableGenerics, + List params, + Ast.TypeRef returns, + boolean async, + Set ownerGenerics, + Type self) { + Set all = new HashSet<>(ownerGenerics); + all.addAll(callableGenerics); + Function raw = functionType(params, returns, async, all, self); + if (callableGenerics.isEmpty()) return raw; + + Map canonical = new HashMap<>(); + for (int i = 0; i < callableGenerics.size(); i++) { + canonical.put(callableGenerics.get(i), new Generic("$callable" + i)); + } + return (Function) substituteGenerics(raw, canonical); + } + + private Type resolveParam(Ast.Param param, Set generics, Type self) { + if (!param.structural()) return resolve(param.type(), generics, self); + Type concrete = resolve(param.type(), generics, self); + Ast.InterfaceDecl iface = findInterface(param.type().name()); + if (iface != null) { + if (!(concrete instanceof Named named)) throw new IllegalArgumentException("@Structural interface must resolve to a named type"); + Type shape = interfaceShape(iface, Set.copyOf(iface.genericParameters()), new LinkedHashSet<>()); + return substituteGenerics(shape, genericBindings(iface.genericParameters(), named.arguments(), "interface " + iface.name())); + } + Ast.ClassDecl klass = findClass(param.type().name()); + if (klass != null) { + if (!(concrete instanceof Named named)) throw new IllegalArgumentException("@Structural class must resolve to a named type"); + Type shape = publicClassShape(klass, new LinkedHashSet<>()); + return substituteGenerics(shape, classGenericBindings(klass, named)); + } + throw new IllegalArgumentException("@Structural requires a known class or interface type, got '" + param.type().name() + "'"); + } + + private void rejectStaticClassGenericReferences( + List statements, + Set classGenerics, + Ast.ClassDecl klass, + Ast.MethodDecl method) { + for (Ast.Stmt statement : statements) { + if (statement instanceof Ast.BindingStmt binding) { + rejectStaticClassGenericReference(binding.declaredType(), classGenerics, klass, method); + rejectStaticClassGenericReferences(binding.initializer(), classGenerics, klass, method); + } else if (statement instanceof Ast.DestructureStmt destructure) { + rejectStaticClassGenericReferences(destructure.initializer(), classGenerics, klass, method); + } else if (statement instanceof Ast.ReturnStmt returned) { + rejectStaticClassGenericReferences(returned.value(), classGenerics, klass, method); + } else if (statement instanceof Ast.ExprStmt expression) { + rejectStaticClassGenericReferences(expression.expression(), classGenerics, klass, method); + } else if (statement instanceof Ast.DeferStmt defer) { + rejectStaticClassGenericReferences(defer.expression(), classGenerics, klass, method); + } else if (statement instanceof Ast.BlockStmt block) { + rejectStaticClassGenericReferences(block.body(), classGenerics, klass, method); + } else if (statement instanceof Ast.LoopStmt loop) { + rejectStaticClassGenericReferences(loop.body(), classGenerics, klass, method); + } else if (statement instanceof Ast.IfStmt conditional) { + for (Ast.IfBranch branch : conditional.branches()) { + rejectStaticClassGenericReferences(branch.condition(), classGenerics, klass, method); + rejectStaticClassGenericReferences(branch.body(), classGenerics, klass, method); + } + rejectStaticClassGenericReferences(conditional.elseBody(), classGenerics, klass, method); + } else if (statement instanceof Ast.MatchStmt matched) { + rejectStaticClassGenericReferences(matched.subject(), classGenerics, klass, method); + for (Ast.MatchArm arm : matched.arms()) { + rejectStaticClassGenericReferences(arm.pattern(), classGenerics, klass, method); + rejectStaticClassGenericReferences(arm.guard(), classGenerics, klass, method); + rejectStaticClassGenericReferences(arm.body(), classGenerics, klass, method); + } + } else if (statement instanceof Ast.SwitchStmt switched) { + rejectStaticClassGenericReferences(switched.subject(), classGenerics, klass, method); + for (Ast.SwitchCase arm : switched.cases()) { + for (Ast.Expr constant : arm.constants()) { + rejectStaticClassGenericReferences(constant, classGenerics, klass, method); + } + rejectStaticClassGenericReferences(arm.body(), classGenerics, klass, method); + } + rejectStaticClassGenericReferences(switched.defaultBody(), classGenerics, klass, method); + } else if (statement instanceof Ast.TryStmt attempted) { + rejectStaticClassGenericReferences(attempted.body(), classGenerics, klass, method); + rejectStaticClassGenericReferences(attempted.catchBody(), classGenerics, klass, method); + rejectStaticClassGenericReferences(attempted.finallyBody(), classGenerics, klass, method); + } else if (statement instanceof Ast.SelectStmt selected) { + for (Ast.SelectArm arm : selected.arms()) { + rejectStaticClassGenericReferences( + arm.channel(), classGenerics, klass, method); + rejectStaticClassGenericReferences( + arm.value(), classGenerics, klass, method); + rejectStaticClassGenericReferences( + arm.body(), classGenerics, klass, method); + } + } else if (statement instanceof Ast.ForOfDestructureStmt loop) { + rejectStaticClassGenericReferences(loop.iterable(), classGenerics, klass, method); + rejectStaticClassGenericReferences(loop.body(), classGenerics, klass, method); + } else if (statement instanceof Ast.ForOfStmt loop) { + rejectStaticClassGenericReferences(loop.iterable(), classGenerics, klass, method); + rejectStaticClassGenericReferences(loop.body(), classGenerics, klass, method); + } else if (statement instanceof Ast.ForStmt loop) { + if (loop.initializer() != null) { + rejectStaticClassGenericReferences(List.of(loop.initializer()), classGenerics, klass, method); + } + rejectStaticClassGenericReferences(loop.condition(), classGenerics, klass, method); + rejectStaticClassGenericReferences(loop.update(), classGenerics, klass, method); + rejectStaticClassGenericReferences(loop.body(), classGenerics, klass, method); + } + } + } + + private void rejectStaticClassGenericReferences( + Ast.Expr expression, + Set classGenerics, + Ast.ClassDecl klass, + Ast.MethodDecl method) { + if (expression == null) return; + if (expression instanceof Ast.AssignExpr assignment) { + rejectStaticClassGenericReferences(assignment.target(), classGenerics, klass, method); + rejectStaticClassGenericReferences(assignment.value(), classGenerics, klass, method); + } else if (expression instanceof Ast.TypeTestExpr test) { + rejectStaticClassGenericReferences(test.value(), classGenerics, klass, method); + rejectStaticClassGenericReference(test.targetType(), classGenerics, klass, method); + } else if (expression instanceof Ast.PatternTestExpr test) { + rejectStaticClassGenericReferences(test.value(), classGenerics, klass, method); + rejectStaticClassGenericReferences(test.pattern(), classGenerics, klass, method); + } else if (expression instanceof Ast.CastExpr cast) { + rejectStaticClassGenericReferences(cast.value(), classGenerics, klass, method); + rejectStaticClassGenericReference(cast.targetType(), classGenerics, klass, method); + } else if (expression instanceof Ast.BinaryExpr binary) { + rejectStaticClassGenericReferences(binary.left(), classGenerics, klass, method); + rejectStaticClassGenericReferences(binary.right(), classGenerics, klass, method); + } else if (expression instanceof Ast.UnaryExpr unary) { + rejectStaticClassGenericReferences(unary.operand(), classGenerics, klass, method); + } else if (expression instanceof Ast.ConditionalExpr conditional) { + rejectStaticClassGenericReferences(conditional.condition(), classGenerics, klass, method); + rejectStaticClassGenericReferences(conditional.whenTrue(), classGenerics, klass, method); + rejectStaticClassGenericReferences(conditional.whenFalse(), classGenerics, klass, method); + } else if (expression instanceof Ast.CallExpr call) { + for (Ast.TypeRef argument : call.typeArguments()) { + rejectStaticClassGenericReference(argument, classGenerics, klass, method); + } + rejectStaticClassGenericReferences(call.callee(), classGenerics, klass, method); + for (Ast.Expr argument : call.arguments()) { + rejectStaticClassGenericReferences(argument, classGenerics, klass, method); + } + } else if (expression instanceof Ast.MemberExpr member) { + rejectStaticClassGenericReferences(member.receiver(), classGenerics, klass, method); + } else if (expression instanceof Ast.IndexExpr indexed) { + rejectStaticClassGenericReferences(indexed.receiver(), classGenerics, klass, method); + rejectStaticClassGenericReferences(indexed.index(), classGenerics, klass, method); + } else if (expression instanceof Ast.NewExpr created) { + rejectStaticClassGenericReference(created.type(), classGenerics, klass, method); + for (Ast.Expr argument : created.arguments()) { + rejectStaticClassGenericReferences(argument, classGenerics, klass, method); + } + } else if (expression instanceof Ast.AwaitExpr awaited) { + rejectStaticClassGenericReferences(awaited.expression(), classGenerics, klass, method); + } else if (expression instanceof Ast.ChannelOpExpr operation) { + rejectStaticClassGenericReferences( + operation.channel(), classGenerics, klass, method); + rejectStaticClassGenericReferences( + operation.value(), classGenerics, klass, method); + } else if (expression instanceof Ast.DynamicSelectExpr selected) { + rejectStaticClassGenericReferences( + selected.cases(), classGenerics, klass, method); + } else if (expression instanceof Ast.ListExpr list) { + for (Ast.Expr item : list.elements()) { + rejectStaticClassGenericReferences(item, classGenerics, klass, method); + } + } else if (expression instanceof Ast.TupleExpr tuple) { + for (Ast.Expr item : tuple.elements()) { + rejectStaticClassGenericReferences(item, classGenerics, klass, method); + } + } else if (expression instanceof Ast.ObjectExpr object) { + for (Ast.ObjectField field : object.fields()) { + if (field.isDynamic()) { + rejectStaticClassGenericReferences(field.dynamicName(), classGenerics, klass, method); + } + rejectStaticClassGenericReferences(field.value(), classGenerics, klass, method); + } + } else if (expression instanceof Ast.LambdaExpr lambda) { + for (Ast.Param param : lambda.parameters()) { + rejectStaticClassGenericReference(param.type(), classGenerics, klass, method); + } + rejectStaticClassGenericReferences(lambda.expressionBody(), classGenerics, klass, method); + if (lambda.blockBody() != null) { + rejectStaticClassGenericReferences(lambda.blockBody(), classGenerics, klass, method); + } + } + } + + private void rejectStaticClassGenericReferences( + Ast.Pattern pattern, + Set classGenerics, + Ast.ClassDecl klass, + Ast.MethodDecl method) { + if (pattern instanceof Ast.TypePattern typed) { + rejectStaticClassGenericReference(typed.type(), classGenerics, klass, method); + } else if (pattern instanceof Ast.ConstructorPattern constructor) { + for (Ast.Pattern nested : constructor.arguments()) { + rejectStaticClassGenericReferences(nested, classGenerics, klass, method); + } + } + } + + private void rejectStaticClassGenericReference( + Ast.TypeRef ref, + Set classGenerics, + Ast.ClassDecl klass, + Ast.MethodDecl method) { + if (ref == null || classGenerics.isEmpty()) return; + if (ref.isBorrow()) { + rejectStaticClassGenericReference(ref.borrowedTarget(), classGenerics, klass, method); + return; + } + if (classGenerics.contains(ref.name())) { + throw new IllegalArgumentException( + "static function " + klass.name() + "." + method.name() + + " cannot reference enclosing class generic '" + ref.name() + + "'; declare a static-function generic parameter instead"); + } + for (Ast.TypeRef argument : ref.arguments()) { + rejectStaticClassGenericReference(argument, classGenerics, klass, method); + } + } + + private void validateGenericArity(Ast.TypeRef ref, List parameters, String owner) { + if (ref.inferArguments()) { + throw new IllegalArgumentException(owner + " does not permit unresolved <> here; provide explicit type arguments"); + } + if (ref.arguments().size() != parameters.size()) { + throw new IllegalArgumentException(owner + " expects " + parameters.size() + + " type argument(s), got " + ref.arguments().size()); + } + } + + private Map classGenericBindings(Ast.ClassDecl klass, Named actual) { + if (actual.arguments().size() != klass.genericParameters().size()) { + throw new IllegalArgumentException("class " + klass.name() + " expects " + klass.genericParameters().size() + + " type argument(s), got " + actual.arguments().size()); + } + Map bindings = new HashMap<>(); + for (int i = 0; i < klass.genericParameters().size(); i++) { + bindings.put(klass.genericParameters().get(i), actual.arguments().get(i)); + } + return bindings; + } + + private void validateCallTypeArgumentMarker(Ast.CallExpr call, List genericNames, String label) { + if (call.typeArgumentsPresent() && genericNames.isEmpty()) { + throw new IllegalArgumentException(label + " is not generic and cannot be called with <...> or <>"); + } + } + + private Map explicitGenericBindings( + List genericNames, + List typeArguments, + Set callerGenerics, + Type callerSelf, + String label) { + if (typeArguments.isEmpty()) return Map.of(); + if (typeArguments.size() != genericNames.size()) { + throw new IllegalArgumentException(label + " expects " + genericNames.size() + + " explicit type argument(s), got " + typeArguments.size()); + } + Map bindings = new HashMap<>(); + for (int i = 0; i < genericNames.size(); i++) { + bindings.put(genericNames.get(i), resolve(typeArguments.get(i), callerGenerics, callerSelf)); + } + return bindings; + } + + private Type checkGenericCallable( + List genericNames, + List params, + Ast.TypeRef returnRef, + List arguments, + Env env, + Set callerGenerics, + Type callerSelf, + Type callableSelf, + Map initialBindings, + String label) { + if (params.size() != arguments.size()) throw new IllegalArgumentException(label + " arity mismatch"); + Set unique = uniqueGenerics(genericNames, label); + Map bindings = new HashMap<>(initialBindings); + Set fixedBindings = new HashSet<>(initialBindings.keySet()); + List patterns = params.stream().map(p -> resolveParam(p, unique, callableSelf)).toList(); + + for (int i = 0; i < arguments.size(); i++) { + Type actual = typeOf(arguments.get(i), env, callerGenerics, callerSelf); + inferGenericBindings(patterns.get(i), actual, bindings, fixedBindings, label); + } + Set unbound = new HashSet<>(unique); + unbound.removeAll(bindings.keySet()); + + for (int i = 0; i < arguments.size(); i++) { + Type expected = substituteGenerics(patterns.get(i), bindings); + if (containsGenericNamed(expected, unbound)) { + throw new IllegalArgumentException("cannot infer all generic parameters for " + label + " from argument " + (i + 1)); + } + validateLambdaArgument(arguments.get(i), expected, env, callerGenerics, callerSelf); + requireAssignable(typeOf(arguments.get(i), env, callerGenerics, callerSelf), expected, "argument " + (i + 1)); + } + + Type result = substituteGenerics(resolve(returnRef, unique, callableSelf), bindings); + if (containsGenericNamed(result, unbound)) { + throw new IllegalArgumentException("cannot infer generic return type for " + label + "; add an inferable value parameter"); + } + return result; + } + + private void inferGenericBindings(Type pattern, Type actual, Map bindings, Set fixedBindings, String label) { + if (containsUnknown(actual)) return; + if (pattern instanceof Generic generic) { + if (fixedBindings.contains(generic.name())) return; + Type previous = bindings.putIfAbsent(generic.name(), actual); + if (previous != null) { + Type joined = commonType(previous, actual); + if (joined == Unknown.INSTANCE) { + throw new IllegalArgumentException("conflicting inference for generic '" + generic.name() + "' in " + label + + ": " + previous + " vs " + actual); + } + bindings.put(generic.name(), joined); + } + return; + } + if (pattern instanceof Borrow p && actual instanceof Borrow a) { + inferGenericBindings(p.target(), a.target(), bindings, fixedBindings, label); + return; + } + if (pattern instanceof ListType p && actual instanceof ListType a) { + inferGenericBindings(p.element(), a.element(), bindings, fixedBindings, label); + return; + } + if (pattern instanceof Tuple p && actual instanceof Tuple a && p.elements().size() == a.elements().size()) { + for (int i = 0; i < p.elements().size(); i++) inferGenericBindings(p.elements().get(i), a.elements().get(i), bindings, fixedBindings, label); + return; + } + if (pattern instanceof Named p && actual instanceof Named a + && p.name().equals(a.name()) && p.arguments().size() == a.arguments().size()) { + for (int i = 0; i < p.arguments().size(); i++) inferGenericBindings(p.arguments().get(i), a.arguments().get(i), bindings, fixedBindings, label); + return; + } + if (pattern instanceof Function p && actual instanceof Function a + && p.parameters().size() == a.parameters().size()) { + for (int i = 0; i < p.parameters().size(); i++) inferGenericBindings(p.parameters().get(i), a.parameters().get(i), bindings, fixedBindings, label); + inferGenericBindings(p.result(), a.result(), bindings, fixedBindings, label); + } + } + + private Type substituteGenerics(Type type, Map bindings) { + if (type instanceof Generic generic) return bindings.getOrDefault(generic.name(), type); + if (type instanceof Borrow borrow) return new Borrow(substituteGenerics(borrow.target(), bindings), borrow.mutable()); + if (type instanceof ListType list) return new ListType(substituteGenerics(list.element(), bindings)); + if (type instanceof Tuple tuple) return new Tuple(tuple.elements().stream().map(t -> substituteGenerics(t, bindings)).toList()); + if (type instanceof Union union) return Types.unionOf(union.options().stream().map(t -> substituteGenerics(t, bindings)).toList()); + if (type instanceof Named named) return new Named(named.name(), named.arguments().stream().map(t -> substituteGenerics(t, bindings)).toList()); + if (type instanceof Function fn) return new Function( + fn.parameters().stream().map(t -> substituteGenerics(t, bindings)).toList(), + substituteGenerics(fn.result(), bindings)); + if (type instanceof Record record) { + Map members = new LinkedHashMap<>(); + record.members().forEach((name, member) -> members.put(name, substituteGenerics(member, bindings))); + return new Record(members); + } + return type; + } + + private boolean containsUnknown(Type type) { + if (type == Unknown.INSTANCE) return true; + if (type instanceof Borrow borrow) return containsUnknown(borrow.target()); + if (type instanceof ListType list) return containsUnknown(list.element()); + if (type instanceof Tuple tuple) return tuple.elements().stream().anyMatch(this::containsUnknown); + if (type instanceof Union union) return union.options().stream().anyMatch(this::containsUnknown); + if (type instanceof Named named) return named.arguments().stream().anyMatch(this::containsUnknown); + if (type instanceof Function fn) return fn.parameters().stream().anyMatch(this::containsUnknown) + || containsUnknown(fn.result()); + if (type instanceof Record record) return record.members().values().stream().anyMatch(this::containsUnknown); + return false; + } + + private boolean containsGenericNamed(Type type, Set names) { + if (names.isEmpty()) return false; + if (type instanceof Generic generic) return names.contains(generic.name()); + if (type instanceof Borrow borrow) return containsGenericNamed(borrow.target(), names); + if (type instanceof ListType list) return containsGenericNamed(list.element(), names); + if (type instanceof Tuple tuple) return tuple.elements().stream().anyMatch(t -> containsGenericNamed(t, names)); + if (type instanceof Union union) return union.options().stream().anyMatch(t -> containsGenericNamed(t, names)); + if (type instanceof Named named) return named.arguments().stream().anyMatch(t -> containsGenericNamed(t, names)); + if (type instanceof Function fn) return fn.parameters().stream().anyMatch(t -> containsGenericNamed(t, names)) + || containsGenericNamed(fn.result(), names); + if (type instanceof Record record) return record.members().values().stream().anyMatch(t -> containsGenericNamed(t, names)); + return false; + } + + private Type resolve(Ast.TypeRef ref, Set generics, Type self) { + return resolve(ref, generics, self, false); + } + + private Type resolve(Ast.TypeRef ref, Set generics, Type self, boolean allowNullMarker) { + if (ref == null) return Unknown.INSTANCE; + if (ref.isBorrow()) return new Borrow(resolve(ref.borrowedTarget(), generics, self, allowNullMarker), ref.mutableBorrow()); + if (ref.isStringLiteral()) return new StringLiteral(ref.stringLiteralValue()); + if (ref.name().equals("$infer$")) return Unknown.INSTANCE; + if (ref.name().equals("self")) return self == null ? Unknown.INSTANCE : self; + if (ref.name().equals("null")) { + if (!allowNullMarker) throw new IllegalArgumentException("null is not a standalone type; it is only legal as Option"); + return Primitive.NULL; + } + if (ref.isUnion()) { + return Types.unionOf(ref.arguments().stream().map(option -> resolve(option, generics, self, allowNullMarker)).toList()); + } + if (ref.isTupleType()) { + return new Tuple(ref.arguments().stream().map(element -> resolve(element, generics, self, allowNullMarker)).toList()); + } + if (ref.isRecordType()) { + Map members = new LinkedHashMap<>(); + for (Map.Entry member : ref.recordMembers().entrySet()) { + members.put(member.getKey(), resolve(member.getValue(), generics, self, allowNullMarker)); + } + return new Record(members); + } + if (generics.contains(ref.name())) return new Generic(ref.name()); + + Ast.TypeAliasDecl alias = findTypeAlias(ref.name()); + if (alias != null) { + if (alias.genericParameters().size() != ref.arguments().size()) { + throw new IllegalArgumentException("type alias '" + alias.name() + "' expects " + alias.genericParameters().size() + + " type argument(s), got " + ref.arguments().size()); + } + if (!resolvingAliases.add(alias)) throw new IllegalArgumentException("type alias cycle involving '" + alias.name() + "'"); + try { + Map substitutions = new HashMap<>(); + for (int i = 0; i < alias.genericParameters().size(); i++) { + substitutions.put(alias.genericParameters().get(i), ref.arguments().get(i)); + } + return resolve(substituteAliasType(alias.target(), substitutions), generics, self, allowNullMarker); + } finally { + resolvingAliases.remove(alias); + } + } + + return switch (ref.name()) { + case "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64", "int", "uint", "bigint" -> Primitive.INT; + case "f32", "f64", "float" -> Primitive.FLOAT; + case "decimal" -> Primitive.DECIMAL; + case "complex64", "complex128", "complex" -> Primitive.COMPLEX; + case "bool", "Bool" -> Primitive.BOOL; + case "string", "String" -> Primitive.STRING; + case "void" -> Primitive.VOID; + case "Array", "List" -> { + if (!ref.inferArguments() && ref.arguments().size() != 1) throw new IllegalArgumentException(ref.name() + " requires exactly one type argument"); + yield new ListType(ref.arguments().isEmpty() ? Unknown.INSTANCE : resolve(ref.arguments().getFirst(), generics, self)); + } + case "DynamicStruct" -> { + if (ref.inferArguments() || ref.arguments().size() != 1) { + throw new IllegalArgumentException("DynamicStruct requires exactly one explicit value type"); + } + Type value = resolve(ref.arguments().getFirst(), generics, self); + if (value == Primitive.VOID) throw new IllegalArgumentException("DynamicStruct is invalid"); + yield new Named("DynamicStruct", List.of(value)); + } + case "Option" -> { + if (ref.inferArguments() || ref.arguments().size() != 1) throw new IllegalArgumentException("Option requires exactly one explicit type argument"); + Type element = resolve(ref.arguments().getFirst(), generics, self, true); + if (element == Primitive.VOID) throw new IllegalArgumentException("Option is invalid; use void for no return value"); + yield new Named("Option", List.of(element)); + } + case "Result" -> { + if (ref.inferArguments() || ref.arguments().size() != 2) { + throw new IllegalArgumentException("Result requires exactly two explicit type arguments"); + } + yield new Named("Result", List.of( + resolve(ref.arguments().get(0), generics, self), + resolve(ref.arguments().get(1), generics, self))); + } + case "MutexGuard" -> throw new IllegalArgumentException( + "MutexGuard is compiler-managed and cannot be named in source declarations; acquire it from lock()/try_lock()/lock_async()"); + case "Mutex" -> { + if (ref.inferArguments() || ref.arguments().size() != 1) throw new IllegalArgumentException("Mutex requires exactly one explicit type argument"); + Type element = resolve(ref.arguments().getFirst(), generics, self); + if (element == Primitive.VOID) throw new IllegalArgumentException("Mutex is invalid"); + if (element instanceof Borrow) { + throw new IllegalArgumentException("Mutex requires an owned value type; borrowed payload types are invalid"); + } + yield new Named("Mutex", List.of(element)); + } + case "Future" -> { + if (ref.inferArguments() || ref.arguments().size() != 1) throw new IllegalArgumentException("Future requires exactly one explicit type argument"); + Type element = resolve(ref.arguments().getFirst(), generics, self); + if (element == Primitive.VOID) throw new IllegalArgumentException("Future is invalid"); + yield new Named("Future", List.of(element)); + } + case "SharedMutex" -> { + if (ref.inferArguments() || ref.arguments().size() != 1) throw new IllegalArgumentException("SharedMutex requires exactly one explicit type argument"); + Type element = resolve(ref.arguments().getFirst(), generics, self); + if (element == Primitive.VOID) throw new IllegalArgumentException("SharedMutex is invalid"); + if (!isSharedSafe(element, new LinkedHashSet<>(), Map.of())) { + throw new IllegalArgumentException( + "SharedMutex requires a concrete shared-safe type; borrows, Mutex, MutexGuard, Future, closures, and unresolved generic/dynamic values are not shareable"); + } + yield new Named("SharedMutex", List.of(element)); + } + case "Fnc" -> { + List args = ref.arguments().stream().map(arg -> resolve(arg, generics, self)).toList(); + if (args.isEmpty()) throw new IllegalArgumentException("Fnc requires at least a result type"); + yield new Function(args.subList(0, args.size() - 1), args.getLast()); + } + default -> { + Ast.ClassDecl knownClass = findClass(ref.name()); + if (knownClass != null) { + validateGenericArity(ref, knownClass.genericParameters(), "class " + knownClass.name()); + yield new Named(qualifiedClassName(knownClass), + ref.arguments().stream().map(arg -> resolve(arg, generics, self)).toList()); + } + Ast.InterfaceDecl knownInterface = findInterface(ref.name()); + if (knownInterface != null) { + validateGenericArity(ref, knownInterface.genericParameters(), "interface " + knownInterface.name()); + yield new Named(qualifiedInterfaceName(knownInterface), + ref.arguments().stream().map(arg -> resolve(arg, generics, self)).toList()); + } + if (ref.inferArguments()) { + throw new IllegalArgumentException("cannot infer type arguments for unknown type '" + ref.name() + "<>'"); + } + yield new Named(ref.name(), ref.arguments().stream().map(arg -> resolve(arg, generics, self)).toList()); + } + }; + } + + private Ast.TypeRef substituteAliasType(Ast.TypeRef ref, Map substitutions) { + Ast.TypeRef replacement = substitutions.get(ref.name()); + if (replacement != null && ref.arguments().isEmpty() && !ref.inferArguments()) return replacement; + return new Ast.TypeRef( + ref.name(), + ref.arguments().stream().map(arg -> substituteAliasType(arg, substitutions)).toList(), + ref.inferArguments()); + } + + private Named nominalClassType(Ast.ClassDecl klass) { + return new Named(qualifiedClassName(klass), klass.genericParameters().stream().map(Generic::new).map(Type.class::cast).toList()); + } + + private String qualifiedClassName(Ast.ClassDecl klass) { + String owner = classOwners.get(klass); + return owner == null || owner.equals("__root__") ? klass.name() : owner + "." + klass.name(); + } + + private String qualifiedInterfaceName(Ast.InterfaceDecl iface) { + String owner = interfaceOwners.get(iface); + return owner == null || owner.equals("__root__") ? iface.name() : owner + "." + iface.name(); + } + + private boolean assignable(Type actual, Type expected) { + if (expected instanceof Generic expectedGeneric) { + return actual instanceof Generic actualGeneric + && actualGeneric.name().equals(expectedGeneric.name()); + } + if (actual instanceof Generic actualGeneric) { + return expected instanceof Generic expectedGeneric + && actualGeneric.name().equals(expectedGeneric.name()); + } + if (actual instanceof Union source) { + return source.options().stream().allMatch(option -> assignable(option, expected)); + } + if (expected instanceof Union target) { + return target.options().stream().anyMatch(option -> assignable(actual, option)); + } + if (Types.isAssignable(actual, expected)) return true; + + if (actual instanceof Named actualNamed && expected instanceof Record targetShape) { + Ast.ClassDecl klass = findClass(actualNamed.name()); + if (klass == null) return false; + Type specializedShape = substituteGenerics( + publicClassShape(klass, new LinkedHashSet<>()), + classGenericBindings(klass, actualNamed)); + return Types.isAssignable(specializedShape, targetShape); + } + + if (actual instanceof Named actualNamed && expected instanceof Named expectedNamed) { + if (findClass(actualNamed.name()) != null) { + if (findClass(expectedNamed.name()) != null + && classTypeExtends(actualNamed, expectedNamed, new LinkedHashSet<>())) { + return true; + } + if (findInterface(expectedNamed.name()) != null + && classTypeImplements(actualNamed, expectedNamed, new LinkedHashSet<>())) { + return true; + } + } + if (findInterface(actualNamed.name()) != null + && findInterface(expectedNamed.name()) != null + && interfaceTypeExtends(actualNamed, expectedNamed, new LinkedHashSet<>())) { + return true; + } + } + return false; + } + + private Named concreteClassReference(Ast.TypeRef ref, Ast.ClassDecl owner, Named ownerType) { + Type pattern = resolve(ref, Set.copyOf(owner.genericParameters()), nominalClassType(owner)); + Type concrete = substituteGenerics(pattern, classGenericBindings(owner, ownerType)); + if (!(concrete instanceof Named named)) { + throw new IllegalArgumentException("class relationship must resolve to a named type"); + } + return named; + } + + private boolean classTypeExtends(Named actualType, Named expectedType, Set seen) { + if (Types.isAssignable(actualType, expectedType)) return true; + if (!seen.add(actualType)) return false; + + Ast.ClassDecl actual = findClass(actualType.name()); + if (actual == null) return false; + for (Ast.TypeRef parentRef : actual.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, actual); + if (parent == null) continue; + Named parentType = concreteClassReference(parentRef, actual, actualType); + if (Types.isAssignable(parentType, expectedType) + || classTypeExtends(parentType, expectedType, seen)) { + return true; + } + } + return false; + } + + private boolean classTypeImplements(Named actualType, Named expectedInterfaceType, Set seen) { + if (!seen.add(actualType)) return false; + Ast.ClassDecl actual = findClass(actualType.name()); + if (actual == null) return false; + + for (Ast.TypeRef ifaceRef : actual.interfaces()) { + Named ifaceType = concreteClassReference(ifaceRef, actual, actualType); + if (interfaceTypeExtends(ifaceType, expectedInterfaceType, new LinkedHashSet<>())) { + return true; + } + } + + for (Ast.TypeRef parentRef : actual.parents()) { + Ast.ClassDecl parent = resolveClassParent(parentRef, actual); + if (parent == null) continue; + Named parentType = concreteClassReference(parentRef, actual, actualType); + if (classTypeImplements(parentType, expectedInterfaceType, seen)) return true; + } + return false; + } + + private boolean interfaceTypeExtends(Named actualType, Named expectedType, Set seen) { + if (Types.isAssignable(actualType, expectedType)) return true; + if (!seen.add(actualType)) return false; + + Ast.InterfaceDecl actual = findInterface(actualType.name()); + if (actual == null) return false; + Map bindings = genericBindings( + actual.genericParameters(), + actualType.arguments(), + "interface " + actual.name()); + Set generics = Set.copyOf(actual.genericParameters()); + + for (Ast.TypeRef parentRef : actual.parents()) { + Type pattern = resolve(parentRef, generics, null); + Type concrete = substituteGenerics(pattern, bindings); + if (!(concrete instanceof Named parentType)) { + throw new IllegalArgumentException("interface relationship must resolve to a named type"); + } + if (Types.isAssignable(parentType, expectedType) + || interfaceTypeExtends(parentType, expectedType, seen)) { + return true; + } + } + return false; + } + + private Ast.FunctionDecl findFunction(String name) { + if (ambiguousFunctions.contains(name)) throw new IllegalArgumentException("ambiguous function/routine name '" + name + "'; qualify it with its module"); + return functions.get(name); + } + + private Ast.ClassDecl findClass(String name) { + if (ambiguousClasses.contains(name)) throw new IllegalArgumentException("ambiguous class name '" + name + "'; qualify it with its module"); + return classes.get(name); + } + + private Ast.InterfaceDecl findInterface(String name) { + if (ambiguousInterfaces.contains(name)) throw new IllegalArgumentException("ambiguous interface name '" + name + "'; qualify it with its module"); + return interfaces.get(name); + } + + private Ast.TypeAliasDecl findTypeAlias(String name) { + if (ambiguousTypeAliases.contains(name)) throw new IllegalArgumentException("ambiguous type alias '" + name + "'; qualify it with its module"); + return typeAliases.get(name); + } + + private Set uniqueGenerics(List names, String owner) { + Set result = new HashSet<>(); + for (String name : names) if (!result.add(name)) throw new IllegalArgumentException("duplicate generic '" + name + "' in " + owner); + return result; + } + + private boolean constant(Ast.Expr expr) { + if (expr instanceof Ast.LiteralExpr) return true; + if (expr instanceof Ast.UnaryExpr unary) return constant(unary.operand()); + if (expr instanceof Ast.BinaryExpr binary) return constant(binary.left()) && constant(binary.right()); + if (expr instanceof Ast.ConditionalExpr conditional) return constant(conditional.condition()) && constant(conditional.whenTrue()) && constant(conditional.whenFalse()); + if (expr instanceof Ast.ListExpr list) return list.elements().stream().allMatch(this::constant); + if (expr instanceof Ast.TupleExpr tuple) return tuple.elements().stream().allMatch(this::constant); + if (expr instanceof Ast.ObjectExpr object) return object.fields().stream().allMatch(field -> constant(field.value())); + return false; + } + + private boolean definitelyReturns(List body) { + for (Ast.Stmt stmt : body) { + if (stmt instanceof Ast.ReturnStmt) return true; + if (stmt instanceof Ast.BlockStmt block && definitelyReturns(block.body())) return true; + if (stmt instanceof Ast.LoopStmt loop && !containsBreakForCurrentLoop(loop.body())) return true; + if (stmt instanceof Ast.IfStmt conditional) { + boolean allBranches = !conditional.branches().isEmpty() + && conditional.branches().stream().allMatch(branch -> definitelyReturns(branch.body())) + && !conditional.elseBody().isEmpty() + && definitelyReturns(conditional.elseBody()); + if (allBranches) return true; + } + if (stmt instanceof Ast.MatchStmt matched + && !matched.arms().isEmpty() + && matched.arms().stream().allMatch(arm -> definitelyReturns(arm.body()))) return true; + if (stmt instanceof Ast.SwitchStmt switched + && !switched.defaultBody().isEmpty() + && switched.cases().stream().allMatch(arm -> definitelyReturns(arm.body())) + && definitelyReturns(switched.defaultBody())) return true; + if (stmt instanceof Ast.TryStmt attempted) { + if (definitelyReturns(attempted.finallyBody())) return true; + if (definitelyReturns(attempted.body()) && definitelyReturns(attempted.catchBody())) return true; + } + } + return false; + } + + private boolean containsBreakForCurrentLoop(List body) { + for (Ast.Stmt stmt : body) { + if (stmt instanceof Ast.BreakStmt) return true; + if (stmt instanceof Ast.BlockStmt block && containsBreakForCurrentLoop(block.body())) return true; + if (stmt instanceof Ast.IfStmt conditional) { + for (Ast.IfBranch branch : conditional.branches()) { + if (containsBreakForCurrentLoop(branch.body())) return true; + } + if (containsBreakForCurrentLoop(conditional.elseBody())) return true; + } else if (stmt instanceof Ast.MatchStmt matched) { + for (Ast.MatchArm arm : matched.arms()) { + if (containsBreakForCurrentLoop(arm.body())) return true; + } + } else if (stmt instanceof Ast.SwitchStmt switched) { + for (Ast.SwitchCase arm : switched.cases()) { + if (containsBreakForCurrentLoop(arm.body())) return true; + } + if (containsBreakForCurrentLoop(switched.defaultBody())) return true; + } else if (stmt instanceof Ast.TryStmt attempted) { + if (containsBreakForCurrentLoop(attempted.body()) + || containsBreakForCurrentLoop(attempted.catchBody()) + || containsBreakForCurrentLoop(attempted.finallyBody())) return true; + } + } + return false; + } + + private boolean hasInferredArgs(Ast.TypeRef ref) { + return ref.inferArguments() || ref.arguments().stream().anyMatch(this::hasInferredArgs); + } + + private void requireAssignable(Type actual, Type expected, String where) { + if (!assignable(actual, expected)) throw new IllegalArgumentException(where + " has type " + actual + " but expected " + expected); + } + + private String methodKey(String name, int arity) { + return name + "$arity" + arity; + } + + private String methodContractKey(String name, int arity, int genericArity) { + return methodKey(name, arity) + "$generics" + genericArity; + } + + + + private static final class Env { + private final Env parent; + private final boolean descendantsNonLexical; + private final Map bindings = new HashMap<>(); + private Env(Env parent) { this(parent, parent != null && parent.descendantsNonLexical); } + private Env(Env parent, boolean descendantsNonLexical) { + this.parent = parent; + this.descendantsNonLexical = descendantsNonLexical; + } + private boolean descendantsNonLexical() { return descendantsNonLexical; } + private void define(String name, Type type, Ast.BindingKind kind) { + if (bindings.putIfAbsent(name, new Binding(type, kind)) != null) throw new IllegalArgumentException("duplicate binding '" + name + "'"); + } + private Binding lookup(String name) { + Binding binding = bindings.get(name); + return binding != null ? binding : parent == null ? null : parent.lookup(name); + } + private void replace(String name, Type type, Ast.BindingKind kind) { + if (!bindings.containsKey(name)) throw new IllegalArgumentException("unknown binding '" + name + "'"); + bindings.put(name, new Binding(type, kind)); + } + private record Binding(Type type, Ast.BindingKind kind) { } + } +} diff --git a/src/main/java/dev/oreslang/types/Types.java b/src/main/java/dev/oreslang/types/Types.java new file mode 100644 index 00000000..5f791be8 --- /dev/null +++ b/src/main/java/dev/oreslang/types/Types.java @@ -0,0 +1,148 @@ +package dev.oreslang.types; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class Types { + private Types() { } + + public sealed interface Type permits Primitive, Named, Borrow, ClassNamespace, Record, Function, ListType, Tuple, Union, Generic, StringLiteral, Unknown { } + + public enum Primitive implements Type { + INT, FLOAT, DECIMAL, COMPLEX, BOOL, STRING, VOID, NULL + } + + public record Named(String name, List arguments) implements Type { + public Named { arguments = List.copyOf(arguments); } + } + + /** Rust-style compile-time borrow; erased by the interpreter runtime. */ + public record Borrow(Type target, boolean mutable) implements Type { } + + /** Compile-time meta-value for access to static class functions. */ + public record ClassNamespace(String className) implements Type { } + + public record Record(Map members) implements Type { + public Record { members = Map.copyOf(members); } + } + + public record Function(List parameters, Type result) implements Type { + public Function { parameters = List.copyOf(parameters); } + } + + public record ListType(Type element) implements Type { } + + public record Tuple(List elements) implements Type { + public Tuple { elements = List.copyOf(elements); } + } + + public record Union(List options) implements Type { + public Union { + options = List.copyOf(options); + if (options.size() < 2) throw new IllegalArgumentException("union needs at least two distinct types"); + } + } + + public record Generic(String name) implements Type { } + + public record StringLiteral(String value) implements Type { } + + public enum Unknown implements Type { INSTANCE } + + public static boolean isAssignable(Type from, Type to) { + Objects.requireNonNull(from); + Objects.requireNonNull(to); + if (from == Unknown.INSTANCE || to == Unknown.INSTANCE) return true; + if (to instanceof Generic || from instanceof Generic) return true; + if (from.equals(to)) return true; + if (from instanceof StringLiteral && to == Primitive.STRING) return true; + + if (from instanceof Union source) { + return source.options().stream().allMatch(option -> isAssignable(option, to)); + } + if (to instanceof Union target) { + return target.options().stream().anyMatch(option -> isAssignable(from, option)); + } + + if (from instanceof Borrow source && to instanceof Borrow target) { + if (target.mutable() && !source.mutable()) return false; + return isAssignable(source.target(), target.target()) && isAssignable(target.target(), source.target()); + } + + if (from instanceof Named source && to instanceof Named target && source.name().equals(target.name())) { + if (source.arguments().size() != target.arguments().size()) return false; + for (int i = 0; i < source.arguments().size(); i++) { + Type a = source.arguments().get(i), b = target.arguments().get(i); + if (!isAssignable(a, b) || !isAssignable(b, a)) return false; + } + return true; + } + + if (from instanceof Record source && to instanceof Record target) { + for (Map.Entry required : target.members().entrySet()) { + Type actual = source.members().get(required.getKey()); + if (actual == null || !isAssignable(actual, required.getValue())) return false; + } + return true; + } + + if (from instanceof ListType source && to instanceof ListType target) { + return isAssignable(source.element(), target.element()) && isAssignable(target.element(), source.element()); + } + + if (from instanceof Tuple source && to instanceof Tuple target) { + if (source.elements().size() != target.elements().size()) return false; + for (int i = 0; i < source.elements().size(); i++) { + if (!isAssignable(source.elements().get(i), target.elements().get(i))) return false; + } + return true; + } + + if (from instanceof Function source && to instanceof Function target) { + if (source.parameters().size() != target.parameters().size()) return false; + for (int i = 0; i < source.parameters().size(); i++) { + if (!isAssignable(target.parameters().get(i), source.parameters().get(i))) return false; + } + return isAssignable(source.result(), target.result()); + } + + return numericWidening(from, to); + } + + private static boolean numericWidening(Type from, Type to) { + if (from == Primitive.INT && (to == Primitive.FLOAT || to == Primitive.DECIMAL || to == Primitive.COMPLEX)) return true; + if ((from == Primitive.FLOAT || from == Primitive.DECIMAL) && to == Primitive.COMPLEX) return true; + return false; + } + + public static Type numericJoin(Type left, Type right) { + if (!isNumeric(left) || !isNumeric(right)) return Unknown.INSTANCE; + if (left == Primitive.COMPLEX || right == Primitive.COMPLEX) return Primitive.COMPLEX; + if (left == Primitive.DECIMAL || right == Primitive.DECIMAL) return Primitive.DECIMAL; + if (left == Primitive.FLOAT || right == Primitive.FLOAT) return Primitive.FLOAT; + return Primitive.INT; + } + + public static Type unionOf(Type left, Type right) { + return unionOf(List.of(left, right)); + } + + public static Type unionOf(List types) { + java.util.ArrayList flattened = new java.util.ArrayList<>(); + for (Type type : types) { + if (type == Unknown.INSTANCE) return Unknown.INSTANCE; + if (type instanceof Union union) { + for (Type option : union.options()) if (!flattened.contains(option)) flattened.add(option); + } else if (!flattened.contains(type)) flattened.add(type); + } + if (flattened.isEmpty()) return Unknown.INSTANCE; + if (flattened.size() == 1) return flattened.getFirst(); + flattened.sort(java.util.Comparator.comparing(Object::toString)); + return new Union(flattened); + } + + public static boolean isNumeric(Type type) { + return type == Primitive.INT || type == Primitive.FLOAT || type == Primitive.DECIMAL || type == Primitive.COMPLEX; + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 00000000..eb48b572 --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,14 @@ +module dev.oreslang { + requires java.base; + requires java.compiler; + requires java.logging; + requires org.graalvm.polyglot; + requires org.graalvm.truffle; + requires org.tomlj; + + exports dev.oreslang.launcher; + exports dev.oreslang.compiler; + + provides com.oracle.truffle.api.provider.TruffleLanguageProvider + with dev.oreslang.OresLanguageProvider; +} diff --git a/src/main/resources/META-INF/native-image/dev.oreslang/oreslang/native-image.properties b/src/main/resources/META-INF/native-image/dev.oreslang/oreslang/native-image.properties new file mode 100644 index 00000000..b7d162e2 --- /dev/null +++ b/src/main/resources/META-INF/native-image/dev.oreslang/oreslang/native-image.properties @@ -0,0 +1 @@ +Args = --initialize-at-build-time=dev.oreslang --initialize-at-run-time=dev.oreslang.runtime.NativeCarrierExecutor diff --git a/src/main/resources/META-INF/native-image/dev.oreslang/oreslang/reachability-metadata.json b/src/main/resources/META-INF/native-image/dev.oreslang/oreslang/reachability-metadata.json new file mode 100644 index 00000000..0031c451 --- /dev/null +++ b/src/main/resources/META-INF/native-image/dev.oreslang/oreslang/reachability-metadata.json @@ -0,0 +1,24 @@ +{ + "jni": [ + { + "type": "java.lang.IllegalStateException", + "jniAccessible": true, + "methods": [ + { + "name": "", + "parameterTypes": ["java.lang.String"] + } + ] + }, + { + "type": "dev.oreslang.runtime.NativeCarrierExecutor", + "jniAccessible": true, + "methods": [ + { + "name": "nativeCarrierLoop", + "parameterTypes": ["int"] + } + ] + } + ] +} diff --git a/src/test/java/dev/oreslang/ActorCallableKeywordTest.java b/src/test/java/dev/oreslang/ActorCallableKeywordTest.java new file mode 100644 index 00000000..6f6734c6 --- /dev/null +++ b/src/test/java/dev/oreslang/ActorCallableKeywordTest.java @@ -0,0 +1,185 @@ +package dev.oreslang; + +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Lexer; +import dev.oreslang.parser.Parser; +import dev.oreslang.parser.Token; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +final class ActorCallableKeywordTest { + + @Test + void actorIsSharedAndIsoactorIsPrivateForFunctionsRoutinesAndClasses() { + List tokens = new Lexer("actor isoactor").scan(); + assertEquals(Token.Type.ACTOR, tokens.get(0).type()); + assertEquals(Token.Type.ISOACTOR, tokens.get(1).type()); + + Ast.Program program = Parser.parse(""" + actor fnc shared_fnc(): void { return; } + actor routine shared_routine(): void { return; } + isoactor fnc private_fnc(): void { return; } + isoactor routine private_routine(): void { return; } + + actor SharedBox { + let int value = 1; + } + + isoactor PrivateBox { + let int value = 1; + } + """); + + List declarations = program.modules().getFirst().declarations(); + + assertEquals(Ast.ActorKind.SHARED, ((Ast.FunctionDecl) declarations.get(0)).actorKind()); + assertEquals(Ast.CallableKind.FNC, ((Ast.FunctionDecl) declarations.get(0)).kind()); + + assertEquals(Ast.ActorKind.SHARED, ((Ast.FunctionDecl) declarations.get(1)).actorKind()); + assertEquals(Ast.CallableKind.ROUTINE, ((Ast.FunctionDecl) declarations.get(1)).kind()); + + assertEquals(Ast.ActorKind.PRIVATE, ((Ast.FunctionDecl) declarations.get(2)).actorKind()); + assertEquals(Ast.CallableKind.FNC, ((Ast.FunctionDecl) declarations.get(2)).kind()); + + assertEquals(Ast.ActorKind.PRIVATE, ((Ast.FunctionDecl) declarations.get(3)).actorKind()); + assertEquals(Ast.CallableKind.ROUTINE, ((Ast.FunctionDecl) declarations.get(3)).kind()); + + assertEquals(Ast.ActorKind.SHARED, ((Ast.ClassDecl) declarations.get(4)).actorKind()); + assertEquals(Ast.ActorKind.PRIVATE, ((Ast.ClassDecl) declarations.get(5)).actorKind()); + } + + @Test + void actorCallablesSpawnFreshActorsAndPreserveDeclaredResults() throws Exception { + String output = run(""" + actor fnc add_one(int value): int { + return value + 1; + } + + actor routine shared_emit(String value): void { + stdio.stdout.write(value); + return; + } + + isoactor fnc double_it(int value): int { + return value * 2; + } + + isoactor routine private_emit(String value): void { + stdio.stdout.write(value); + return; + } + + pub routine main(): void { + stdio.stdout.write(add_one(41)); + stdio.stdout.write(":"); + shared_emit("shared"); + stdio.stdout.write(":"); + stdio.stdout.write(double_it(21)); + stdio.stdout.write(":"); + private_emit("private"); + return; + } + """); + + assertEquals("42:shared:42:private", output); + } + + @Test + void actorKeywordsRemainReservedButActorBuiltinNamespaceStillParses() { + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + pub routine main(): void { + val int isoactor = 1; + return; + } + """)); + + assertDoesNotThrow(() -> Parser.parse(""" + pub routine main(): void { + actor.gc(); + return; + } + """)); + } + + @Test + void actorMainRemainsForbidden() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub actor fnc main(): void { return; } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub isoactor routine main(): void { return; } + """))); + } + + @Test + void actorBoundaryTypesAreCheckedStatically() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + actor fnc invalid(Mutex value): void { return; } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + actor fnc invalid(MutexGuard value): void { return; } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + isoactor fnc invalid(SharedMutex value): void { return; } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + actor fnc invalid(Future value): void { return; } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + actor fnc invalid(&int value): void { return; } + """))); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + actor fnc valid(SharedMutex value): void { return; } + """))); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + isoactor fnc valid(int value): int { return value; } + """))); + } + + @Test + void actorTurnsCannotSynchronouslyInvokeAnotherActorCallable() { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + actor fnc child(int value): int { + return value + 1; + } + + actor fnc parent(int value): int { + return child(value); + } + """))); + + assertTrue(failure.getMessage().contains("mailbox-oriented actor composition")); + } + + private static String run(String program) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "actor-callables.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + return output.toString(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/dev/oreslang/ActorFncKeywordTest.java b/src/test/java/dev/oreslang/ActorFncKeywordTest.java new file mode 100644 index 00000000..b7475fe8 --- /dev/null +++ b/src/test/java/dev/oreslang/ActorFncKeywordTest.java @@ -0,0 +1,18 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +final class ActorFncKeywordTest { + @Test + void actorFunctionsRequireFnc() { + String legacy = "f" + "n"; + var error = assertThrows(IllegalArgumentException.class, () -> + Parser.parse(("actor %s work(): void { return; }").formatted(legacy))); + assertTrue(error.getMessage().contains("actor fnc")); + assertDoesNotThrow(() -> + Parser.parse("actor fnc work(): void { return; }")); + } +} diff --git a/src/test/java/dev/oreslang/ActorInvocationRuntimeTest.java b/src/test/java/dev/oreslang/ActorInvocationRuntimeTest.java new file mode 100644 index 00000000..601ef833 --- /dev/null +++ b/src/test/java/dev/oreslang/ActorInvocationRuntimeTest.java @@ -0,0 +1,83 @@ +package dev.oreslang; + +import dev.oreslang.runtime.ActorRuntime; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.runtime.OresMutex; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +final class ActorInvocationRuntimeTest { + + @Test + void invokeUsesRequestedSharedOrPrivateActorKind() { + try (ActorRuntime runtime = new ActorRuntime()) { + ActorRuntime.ActorKind shared = runtime.invoke( + ActorRuntime.ActorKind.SHARED, + "ping", + (message, context) -> { + assertTrue(context.privateMemory().isEmpty()); + return context.kind(); + }); + + ActorRuntime.ActorKind isolated = runtime.invoke( + ActorRuntime.ActorKind.PRIVATE, + "ping", + (message, context) -> { + assertTrue(context.privateMemory().isPresent()); + assertFalse(context.policy().allows(IsolatePolicy.Capability.SHARED_MEMORY)); + assertFalse(context.policy().allows(IsolatePolicy.Capability.ACTOR_SHARE_READONLY)); + return context.kind(); + }); + + assertEquals(ActorRuntime.ActorKind.SHARED, shared); + assertEquals(ActorRuntime.ActorKind.PRIVATE, isolated); + } + } + + @Test + void privateInvocationRejectsSharedMutexTransport() { + try (ActorRuntime runtime = new ActorRuntime()) { + OresMutex.Shared> shared = OresMutex.shared(List.of(1, 2, 3)); + + assertThrows( + SecurityException.class, + () -> runtime.invoke( + ActorRuntime.ActorKind.PRIVATE, + shared, + (message, context) -> 1)); + } + } + + @Test + void invocationRejectsMutableHostReturnValues() { + try (ActorRuntime runtime = new ActorRuntime()) { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> runtime.invoke( + ActorRuntime.ActorKind.SHARED, + "ok", + (message, context) -> new StringBuilder(message))); + + assertTrue(failure.getMessage().contains("not Sendable")); + } + } + + @Test + void invocationPropagatesActorFailure() { + try (ActorRuntime runtime = new ActorRuntime()) { + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> runtime.invoke( + ActorRuntime.ActorKind.SHARED, + "boom", + (message, context) -> { + throw new IllegalStateException(message); + })); + + assertEquals("boom", failure.getMessage()); + } + } +} diff --git a/src/test/java/dev/oreslang/ActorRuntimeTest.java b/src/test/java/dev/oreslang/ActorRuntimeTest.java new file mode 100644 index 00000000..0405222c --- /dev/null +++ b/src/test/java/dev/oreslang/ActorRuntimeTest.java @@ -0,0 +1,1506 @@ +package dev.oreslang; + +import dev.oreslang.runtime.ActorRuntime; +import dev.oreslang.runtime.IsolatePolicy; +import org.junit.jupiter.api.Test; + +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +final class ActorRuntimeTest { + @Test + void freezesMessagesBeforeDelivery() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch received = new CountDownLatch(1); + AtomicReference> observed = new AtomicReference<>(); + var ref = runtime.>spawn(() -> (message, context) -> { + observed.set(message); + received.countDown(); + }); + + ArrayList mutable = new ArrayList<>(List.of(1, 2)); + ref.send(mutable); + mutable.add(3); + + assertTrue(received.await(2, TimeUnit.SECONDS)); + assertEquals(List.of(1, 2), observed.get()); + assertThrows(UnsupportedOperationException.class, () -> ((List) observed.get()).add(9)); + } + } + + @Test + void rejectsUnknownMutableHostObjects() { + assertThrows(IllegalArgumentException.class, () -> ActorRuntime.freeze(new StringBuilder("mutable"))); + } + + @Test + void actorCarriesItsOwnStricterPolicy() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + IsolatePolicy strict = IsolatePolicy.strictFaas(); + + var ref = runtime.spawnPrivate(strict, factoryContext -> { + if (factoryContext.policy().allows(IsolatePolicy.Capability.SHARED_MEMORY)) { + throw new AssertionError("private actor policy retained SHARED_MEMORY"); + } + if (factoryContext.policy().maxMailboxMessages() != IsolatePolicy.strictFaas().maxMailboxMessages()) { + throw new AssertionError("private actor policy did not preserve mailbox limit"); + } + return (message, context) -> context.self().stop(); + }); + ref.send("ping"); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (ref.isAlive() && System.nanoTime() < deadline) Thread.sleep(5); + assertFalse(ref.isAlive()); + assertTrue(ref.failure().isEmpty()); + } + } + + @Test + void childActorCannotEscalatePastRuntimePolicyCeiling() { + IsolatePolicy ceiling = IsolatePolicy.strictFaas(); + try (ActorRuntime runtime = new ActorRuntime(ceiling)) { + IsolatePolicy escalated = ceiling.withCapabilities(IsolatePolicy.Capability.PROCESS_INFO); + assertThrows(SecurityException.class, () -> + runtime.spawnPrivate(escalated, factoryContext -> (message, context) -> { })); + } + } + + @Test + void readonlySharingDeepFreezesContainers() { + try (ActorRuntime runtime = new ActorRuntime()) { + var shared = runtime.shareReadonly(Map.of("items", List.of(1, 2, 3))); + assertNotNull(shared.value()); + } + } + @Test + void sharedActorProcessesOnlyOneMessageAtATime() throws Exception { + var config = new ActorRuntime.DispatcherConfig(2, 4, 8); + try (ActorRuntime runtime = new ActorRuntime(IsolatePolicy.developer(), config)) { + AtomicInteger inFlight = new AtomicInteger(); + AtomicInteger maxInFlight = new AtomicInteger(); + CountDownLatch received = new CountDownLatch(64); + + var ref = runtime.spawnShared(() -> (message, context) -> { + int current = inFlight.incrementAndGet(); + maxInFlight.accumulateAndGet(current, Math::max); + try { + Thread.sleep(2); + } finally { + inFlight.decrementAndGet(); + received.countDown(); + } + }); + + for (int i = 0; i < 64; i++) ref.send(i); + + assertTrue(received.await(5, TimeUnit.SECONDS)); + assertEquals(1, maxInFlight.get(), "one actor mailbox must never run concurrently"); + } + } + + @Test + void carrierHandoffAndTerminationWaitForTurnExecutorExit() throws Exception { + CountDownLatch firstTurnReturnedToExecutor = new CountDownLatch(1); + CountDownLatch releaseFirstExecutor = new CountDownLatch(1); + CountDownLatch secondDelivered = new CountDownLatch(1); + AtomicInteger executorRuns = new AtomicInteger(); + AtomicInteger activeExecutors = new AtomicInteger(); + AtomicInteger maxActiveExecutors = new AtomicInteger(); + AtomicInteger delivered = new AtomicInteger(); + + ActorRuntime.TurnExecutor executor = turn -> { + int active = activeExecutors.incrementAndGet(); + maxActiveExecutors.accumulateAndGet(active, Math::max); + int runNumber = executorRuns.incrementAndGet(); + try { + turn.run(); + if (runNumber == 1) { + firstTurnReturnedToExecutor.countDown(); + try { + if (!releaseFirstExecutor.await(2, TimeUnit.SECONDS)) { + throw new IllegalStateException("test executor release timed out"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new RuntimeException(interrupted); + } + } + } finally { + activeExecutors.decrementAndGet(); + } + }; + + var config = new ActorRuntime.DispatcherConfig(1, 2, 1, 16); + try (ActorRuntime runtime = new ActorRuntime(IsolatePolicy.developer(), config, executor)) { + var ref = runtime.spawnShared(() -> (message, context) -> { + int seen = delivered.incrementAndGet(); + if (seen == 2) { + secondDelivered.countDown(); + context.self().stop(); + } + }); + + ref.send(1); + ref.send(2); + + assertTrue(firstTurnReturnedToExecutor.await(2, TimeUnit.SECONDS)); + try { + Thread.sleep(50); + assertEquals( + 1, + delivered.get(), + "next mailbox batch must not start until the prior TurnExecutor boundary exits"); + assertEquals( + 1, + maxActiveExecutors.get(), + "one actor must not occupy overlapping TurnExecutor/carrier boundaries"); + assertFalse( + ref.awaitTermination(25, TimeUnit.MILLISECONDS), + "termination cannot become visible while a scheduled carrier still owns the executor boundary"); + } finally { + releaseFirstExecutor.countDown(); + } + + assertTrue(secondDelivered.await(2, TimeUnit.SECONDS)); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertEquals(1, maxActiveExecutors.get()); + assertEquals(2, delivered.get()); + } finally { + releaseFirstExecutor.countDown(); + } + } + + @Test + void closeFinalizesQueuedActorBatchCancelledBeforeCarrierEntry() throws Exception { + CountDownLatch executorEntered = new CountDownLatch(1); + CountDownLatch releaseExecutor = new CountDownLatch(1); + + ActorRuntime.TurnExecutor executor = turn -> { + executorEntered.countDown(); + try { + releaseExecutor.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + turn.run(); + }; + + var config = new ActorRuntime.DispatcherConfig(1, 1, 1, 16); + ActorRuntime runtime = new ActorRuntime(IsolatePolicy.developer(), config, executor); + try { + var first = runtime.spawnShared(() -> (message, context) -> { }); + var queued = runtime.spawnShared(() -> (message, context) -> { }); + + first.send(1); + assertTrue(executorEntered.await(2, TimeUnit.SECONDS)); + queued.send(2); + + assertDoesNotThrow(runtime::close); + assertFalse(first.isAlive()); + assertFalse(queued.isAlive()); + } finally { + releaseExecutor.countDown(); + try { + runtime.close(); + } catch (IllegalStateException ignored) { + // Preserve the primary assertion if a broken implementation + // already reported a close failure above. + } + } + } + + @Test + void privateAndSharedActorsUseDifferentDispatchers() throws Exception { + var config = new ActorRuntime.DispatcherConfig(1, 1, 16); + try (ActorRuntime runtime = new ActorRuntime(IsolatePolicy.developer(), config)) { + CountDownLatch received = new CountDownLatch(2); + AtomicReference privateThread = new AtomicReference<>(); + AtomicReference sharedThread = new AtomicReference<>(); + + var privateRef = runtime.spawnPrivate(() -> (message, context) -> { + privateThread.set(Thread.currentThread().getName()); + assertEquals(ActorRuntime.ActorKind.PRIVATE, context.kind()); + received.countDown(); + }); + var sharedRef = runtime.spawnShared(() -> (message, context) -> { + sharedThread.set(Thread.currentThread().getName()); + assertEquals(ActorRuntime.ActorKind.SHARED, context.kind()); + received.countDown(); + }); + + privateRef.send("private"); + sharedRef.send("shared"); + + assertTrue(received.await(2, TimeUnit.SECONDS)); + assertTrue(privateThread.get().startsWith("ores-private-actor-dispatcher-")); + assertTrue(sharedThread.get().startsWith("ores-shared-actor-dispatcher-")); + } + } + + @Test + void sharedActorsCanCoordinateThroughExplicitSyncCell() throws Exception { + var config = new ActorRuntime.DispatcherConfig(1, 4, 32); + try (ActorRuntime runtime = new ActorRuntime(IsolatePolicy.developer(), config)) { + ActorRuntime.SyncCell cell = runtime.syncCell(0); + CountDownLatch received = new CountDownLatch(200); + + var a = runtime.spawnShared(() -> (message, context) -> { + cell.update(value -> value + 1); + received.countDown(); + }); + var b = runtime.spawnShared(() -> (message, context) -> { + cell.update(value -> value + 1); + received.countDown(); + }); + + for (int i = 0; i < 100; i++) { + a.send(i); + b.send(i); + } + + assertTrue(received.await(5, TimeUnit.SECONDS)); + assertEquals(200, cell.snapshot()); + } + } + + @Test + void privateActorsRejectSharedMutableCells() { + try (ActorRuntime runtime = new ActorRuntime()) { + ActorRuntime.SyncCell cell = runtime.syncCell(0); + var ref = runtime.spawnPrivate(() -> (message, context) -> { }); + assertThrows(IllegalArgumentException.class, () -> ref.send(cell)); + } + } + + @Test + void privateActorsIsolationCopyReadonlySharedValues() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch received = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + var ref = runtime.spawnPrivate(() -> (message, context) -> { + observed.set(message); + received.countDown(); + }); + + var shared = runtime.shareReadonly(List.of("a", "b")); + ref.send(shared); + + assertTrue(received.await(2, TimeUnit.SECONDS)); + assertEquals(List.of("a", "b"), observed.get()); + assertFalse(observed.get() instanceof ActorRuntime.Shared); + } + } + + @Test + void privateActorGetsConfinedMemorySliceOwnedByItsActorId() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch received = new CountDownLatch(1); + AtomicReference owner = new AtomicReference<>(); + AtomicReference usedDuringTurn = new AtomicReference<>(); + + var ref = runtime.spawnPrivate(() -> (message, context) -> { + var memory = context.privateMemory().orElseThrow(); + owner.set(memory.owner()); + usedDuringTurn.set(memory.usedBytes()); + received.countDown(); + }); + + ref.send("private-payload"); + + assertTrue(received.await(2, TimeUnit.SECONDS)); + assertEquals(ref.id(), owner.get()); + assertTrue(usedDuringTurn.get() > 0L, "mailbox payload must be charged to the private slice during delivery"); + } + } + + @Test + void sharedActorHasNoPrivateMemorySlice() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch received = new CountDownLatch(1); + AtomicReference hasPrivateMemory = new AtomicReference<>(); + + var ref = runtime.spawnShared(() -> (message, context) -> { + hasPrivateMemory.set(context.privateMemory().isPresent()); + received.countDown(); + }); + + ref.send("shared-payload"); + + assertTrue(received.await(2, TimeUnit.SECONDS)); + assertFalse(hasPrivateMemory.get()); + } + } + + @Test + void privateActorRejectsMessageThatExceedsItsMemorySlice() { + IsolatePolicy ceiling = IsolatePolicy.developer(); + IsolatePolicy small = new IsolatePolicy( + ceiling.capabilities(), + 16L * 1024 * 1024, + ceiling.maxMailboxMessages(), + ceiling.maxWallTime(), + false); + + try (ActorRuntime runtime = new ActorRuntime(ceiling)) { + var ref = runtime.spawnPrivate(small, () -> (message, context) -> { }); + String tooLarge = "x".repeat(9 * 1024 * 1024); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> ref.send(tooLarge)); + assertTrue(failure.getMessage().contains("private actor mailbox limit exceeded")); + assertEquals(0L, runtime.privateMemoryBytes(), "failed admission must roll back aggregate accounting"); + } + } + + @Test + void persistentPrivateHeapReservationsShareTheSameSliceBudget() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch reserved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference stateReservation = new AtomicReference<>(); + + var ref = runtime.spawnPrivate(() -> (message, context) -> { + var memory = context.privateMemory().orElseThrow(); + stateReservation.set(memory.reserveHeap(1024)); + assertTrue(memory.usedBytes() >= 1024); + reserved.countDown(); + assertTrue(release.await(2, TimeUnit.SECONDS)); + stateReservation.get().close(); + }); + + ref.send("reserve"); + assertTrue(reserved.await(2, TimeUnit.SECONDS)); + assertTrue(runtime.privateMemoryBytes() >= 1024); + release.countDown(); + } + } + + @Test + void privateActorFactoryInitializesInsideOwnedMemorySlice() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch constructed = new CountDownLatch(1); + CountDownLatch handled = new CountDownLatch(1); + AtomicReference constructionOwner = new AtomicReference<>(); + AtomicReference state = new AtomicReference<>(); + + var ref = runtime.spawnPrivateTrusted(context -> { + var memory = context.privateMemory().orElseThrow(); + constructionOwner.set(memory.owner()); + state.set(memory.reserveHeap(4096)); + constructed.countDown(); + + return (message, turn) -> { + assertEquals(memory.owner(), turn.self().id()); + assertTrue(memory.usedBytes() >= 4096); + state.get().close(); + handled.countDown(); + }; + }); + + ref.send("initialize"); + + assertTrue(constructed.await(2, TimeUnit.SECONDS)); + assertEquals(ref.id(), constructionOwner.get()); + assertTrue(handled.await(2, TimeUnit.SECONDS)); + } + } + + @Test + void leakedPrivateMemorySliceCannotBeReservedOutsideOwningActorTurn() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch captured = new CountDownLatch(1); + AtomicReference leaked = new AtomicReference<>(); + + var ref = runtime.spawnPrivate(() -> (message, context) -> { + leaked.set(context.privateMemory().orElseThrow()); + captured.countDown(); + }); + + ref.send("capture"); + assertTrue(captured.await(2, TimeUnit.SECONDS)); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> leaked.get().reserveHeap(1)); + assertTrue(failure.getMessage().contains("owning actor")); + } + } + + @Test + void privateActorsShareParentAggregateMemoryCeiling() throws Exception { + IsolatePolicy parent = new IsolatePolicy( + IsolatePolicy.developer().capabilities(), + 16L * 1024 * 1024, + 32, + IsolatePolicy.developer().maxWallTime(), + false); + + try (ActorRuntime runtime = new ActorRuntime(parent, new ActorRuntime.DispatcherConfig(2, 1, 8))) { + CountDownLatch firstReserved = new CountDownLatch(1); + CountDownLatch holdFirst = new CountDownLatch(1); + + var first = runtime.spawnPrivateTrusted(parent, context -> { + var reservation = context.privateMemory().orElseThrow().reserveHeap(10L * 1024 * 1024); + firstReserved.countDown(); + return (message, turn) -> { + try { + assertTrue(holdFirst.await(2, TimeUnit.SECONDS)); + } finally { + reservation.close(); + } + }; + }); + + var second = runtime.spawnPrivate(parent, () -> (message, context) -> { }); + + first.send("start"); + assertTrue(firstReserved.await(2, TimeUnit.SECONDS)); + assertTrue(runtime.privateMemoryBytes() >= 10L * 1024 * 1024); + + // ~8 MiB logical footprint: below the second actor's 16 MiB slice, + // but above the remaining aggregate parent budget. + String payload = "x".repeat(4 * 1024 * 1024); + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> second.send(payload)); + assertTrue(failure.getMessage().contains("aggregate runtime limit exceeded")); + + holdFirst.countDown(); + } + } + + @Test + void readonlySharingRejectsNestedSyncCells() { + try (ActorRuntime runtime = new ActorRuntime()) { + var cell = runtime.syncCell(1); + assertThrows(IllegalArgumentException.class, + () -> runtime.shareReadonly(Map.of("cell", cell))); + } + } + + @Test + void syncCellMutationRequiresSharedActorTurn() { + try (ActorRuntime runtime = new ActorRuntime()) { + var cell = runtime.syncCell(1); + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> cell.update(value -> value + 1)); + assertTrue(failure.getMessage().contains("shared actor mailbox turn")); + } + } + + @Test + void privateActorRejectsNestedSharedMutableCells() { + try (ActorRuntime runtime = new ActorRuntime()) { + var cell = runtime.syncCell(0); + var ref = runtime.spawnPrivate(() -> (message, context) -> { }); + assertThrows(IllegalArgumentException.class, + () -> ref.send(Map.of("nested", List.of(cell)))); + } + } + + @Test + void capturedPrivateMemorySliceCannotBeUsedOutsideOwnerTurn() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch received = new CountDownLatch(1); + AtomicReference captured = new AtomicReference<>(); + + var ref = runtime.spawnPrivate(() -> (message, context) -> { + captured.set(context.privateMemory().orElseThrow()); + received.countDown(); + }); + + ref.send("capture"); + assertTrue(received.await(2, TimeUnit.SECONDS)); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> captured.get().reserveHeap(64)); + assertTrue(failure.getMessage().contains("owning actor")); + } + } + + + @Test + void adversarialActorsRejectSupplierFactoriesThatCanCaptureHostState() { + IsolatePolicy strict = IsolatePolicy.strictFaas(); + try (ActorRuntime runtime = new ActorRuntime(strict)) { + assertThrows(SecurityException.class, () -> + runtime.spawnPrivate(strict, () -> (message, context) -> { })); + + assertDoesNotThrow(() -> + runtime.spawnPrivate(strict, factoryContext -> (message, context) -> { })); + } + } + + @Test + void actorFailureIsRetainedOnRefAndSubsequentSendReportsTermination() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch entered = new CountDownLatch(1); + var ref = runtime.spawnPrivate(() -> (message, context) -> { + entered.countDown(); + throw new IllegalStateException("boom"); + }); + + ref.send("fail"); + assertTrue(entered.await(2, TimeUnit.SECONDS)); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (ref.isAlive() && System.nanoTime() < deadline) Thread.sleep(5); + + assertFalse(ref.isAlive()); + assertTrue(ref.failure().isPresent()); + assertEquals("boom", ref.failure().orElseThrow().getMessage()); + + ActorRuntime.ActorTerminatedException terminated = assertThrows( + ActorRuntime.ActorTerminatedException.class, + () -> ref.send("after-failure")); + assertSame(ref.failure().orElseThrow(), terminated.getCause()); + } + } + + @Test + void explicitStopClosesPrivateSliceAndRejectsFurtherMessages() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch reserved = new CountDownLatch(1); + AtomicReference reservation = new AtomicReference<>(); + + var ref = runtime.spawnPrivateTrusted(factoryContext -> { + reservation.set(factoryContext.privateMemory().orElseThrow().reserveHeap(4096)); + reserved.countDown(); + return (message, context) -> { }; + }); + + ref.send("initialize"); + assertTrue(reserved.await(2, TimeUnit.SECONDS)); + assertTrue(runtime.privateMemoryBytes() >= 4096); + + ref.stop(); + + assertFalse(ref.isAlive()); + assertEquals(0L, runtime.privateMemoryBytes()); + assertThrows(ActorRuntime.ActorTerminatedException.class, () -> ref.send("after-stop")); + reservation.get().close(); // idempotent after slice teardown + assertEquals(0L, runtime.privateMemoryBytes()); + } + } + + + @Test + void actorMessageGraphDepthIsBounded() { + Object nested = "leaf"; + for (int i = 0; i < 300; i++) nested = List.of(nested); + Object tooDeep = nested; + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> ActorRuntime.freeze(tooDeep)); + assertTrue(failure.getMessage().contains("maximum nesting depth")); + } + + @Test + void sharedCellsConsumeAndReleaseRuntimeActorMemoryBudget() { + try (ActorRuntime runtime = new ActorRuntime()) { + assertEquals(0L, runtime.sharedMemoryBytes()); + var cell = runtime.syncCell("shared-state"); + assertTrue(runtime.sharedMemoryBytes() > 0L); + assertEquals(runtime.sharedMemoryBytes(), runtime.actorMemoryBytes()); + + cell.close(); + + assertEquals(0L, runtime.sharedMemoryBytes()); + assertEquals(0L, runtime.actorMemoryBytes()); + assertTrue(cell.closed()); + assertThrows(IllegalStateException.class, cell::snapshot); + } + } + + @Test + void privateActorCannotAccessLeakedSyncCellHandle() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var cell = runtime.syncCell(1); + CountDownLatch checked = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + + var ref = runtime.spawnPrivate(() -> (message, context) -> { + try { + cell.snapshot(); + } catch (Throwable failure) { + observed.set(failure); + } finally { + checked.countDown(); + } + }); + + ref.send("read"); + assertTrue(checked.await(2, TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, observed.get()); + assertTrue(observed.get().getMessage().contains("private actors cannot access synchronized shared memory")); + } + } + + @Test + void privateAndSharedMemoryCompeteForOneParentCeiling() throws Exception { + IsolatePolicy parent = new IsolatePolicy( + IsolatePolicy.developer().capabilities(), + 16L * 1024 * 1024, + 128, + IsolatePolicy.developer().maxWallTime(), + false); + + try (ActorRuntime runtime = new ActorRuntime(parent)) { + CountDownLatch reserved = new CountDownLatch(1); + var ref = runtime.spawnPrivateTrusted(parent, factoryContext -> { + factoryContext.privateMemory().orElseThrow().reserveHeap(10L * 1024 * 1024); + reserved.countDown(); + return (message, context) -> { }; + }); + + ref.send("initialize"); + assertTrue(reserved.await(2, TimeUnit.SECONDS)); + assertTrue(runtime.privateMemoryBytes() >= 10L * 1024 * 1024); + + String sharedTooLarge = "x".repeat(4 * 1024 * 1024); + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> runtime.syncCell(sharedTooLarge)); + assertTrue(failure.getMessage().contains("aggregate runtime limit exceeded")); + assertEquals(0L, runtime.sharedMemoryBytes()); + } + } + + + @Test + void strictPolicyRejectsSharedActorMemoryAtRuntime() { + IsolatePolicy strict = IsolatePolicy.strictFaas(); + try (ActorRuntime runtime = new ActorRuntime(strict)) { + assertThrows(SecurityException.class, () -> + runtime.spawnShared(strict, factoryContext -> (message, context) -> { })); + assertThrows(SecurityException.class, () -> runtime.syncCell(1)); + } + } + + + @Test + void readonlySharedValuesAreQuotaAccountedAndInvalidAfterRuntimeClose() { + ActorRuntime runtime = new ActorRuntime(); + ActorRuntime.Shared>> shared = + runtime.shareReadonly(Map.of("items", List.of(1, 2, 3))); + + assertTrue(runtime.sharedMemoryBytes() > 0L); + assertEquals(List.of(1, 2, 3), shared.value().get("items")); + + runtime.close(); + + assertEquals(0L, runtime.sharedMemoryBytes()); + assertThrows(IllegalStateException.class, shared::value); + } + + @Test + void strictPolicyRejectsReadonlySharingWithoutCapability() { + IsolatePolicy strict = IsolatePolicy.strictFaas(); + try (ActorRuntime runtime = new ActorRuntime(strict)) { + assertThrows(SecurityException.class, () -> runtime.shareReadonly(List.of(1, 2, 3))); + } + } + + + @Test + void nestedDifferentSyncCellsAreRejectedInsteadOfRiskingLockOrderDeadlock() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var first = runtime.syncCell(1); + var second = runtime.syncCell(2); + CountDownLatch checked = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + + var ref = runtime.spawnShared(() -> (message, context) -> { + first.update(value -> { + try { + second.snapshot(); + } catch (Throwable failure) { + observed.set(failure); + } + return value; + }); + checked.countDown(); + }); + + ref.send("check"); + assertTrue(checked.await(2, TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, observed.get()); + assertTrue(observed.get().getMessage().contains("nested synchronization")); + } + } + + + @Test + void runtimeCloseDoesNotBlockOnActorHeldSyncCellLock() throws Exception { + ActorRuntime runtime = new ActorRuntime( + IsolatePolicy.developer(), + new ActorRuntime.DispatcherConfig(1, 1, 8)); + var cell = runtime.syncCell(1); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + var ref = runtime.spawnShared(() -> (message, context) -> + cell.update(value -> { + entered.countDown(); + boolean done = false; + while (!done) { + try { + done = release.await(25, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + // Deliberately ignore shutdown interruption to prove + // runtime.close() does not wait on this user lock. + } + } + return value; + })); + + ref.send("hold"); + assertTrue(entered.await(2, TimeUnit.SECONDS)); + + AtomicReference closeFailure = new AtomicReference<>(); + Thread closer = new Thread(() -> { + try { + runtime.close(); + } catch (Throwable failure) { + closeFailure.set(failure); + } + }); + closer.start(); + closer.join(500); + + try { + assertFalse(closer.isAlive(), "runtime close must not wait for user code holding a SyncCell lock"); + assertInstanceOf(IllegalStateException.class, closeFailure.get()); + assertTrue(closeFailure.get().getMessage().contains("full actor termination")); + assertTrue(cell.closed()); + assertEquals(0L, runtime.sharedMemoryBytes()); + } finally { + release.countDown(); + closer.join(2000); + } + + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertDoesNotThrow(runtime::close); + } + + + @Test + void sharedActorsRejectForeignRuntimeSharedHandles() { + try (ActorRuntime left = new ActorRuntime(); + ActorRuntime right = new ActorRuntime()) { + var foreignShared = left.shareReadonly(List.of(1, 2, 3)); + var foreignCell = left.syncCell(1); + var ref = right.spawnShared(() -> (message, context) -> { }); + + IllegalArgumentException sharedFailure = assertThrows( + IllegalArgumentException.class, + () -> ref.send(foreignShared)); + assertTrue(sharedFailure.getMessage().contains("different ActorRuntime")); + + IllegalArgumentException cellFailure = assertThrows( + IllegalArgumentException.class, + () -> ref.send(foreignCell)); + assertTrue(cellFailure.getMessage().contains("different ActorRuntime")); + } + } + + @Test + void privateActorsCopyForeignReadonlyValuesInsteadOfRetainingRuntimeHandle() throws Exception { + try (ActorRuntime left = new ActorRuntime(); + ActorRuntime right = new ActorRuntime()) { + var foreignShared = left.shareReadonly(List.of("a", "b")); + CountDownLatch received = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + + var ref = right.spawnPrivate(() -> (message, context) -> { + observed.set(message); + received.countDown(); + }); + + ref.send(foreignShared); + + assertTrue(received.await(2, TimeUnit.SECONDS)); + assertEquals(List.of("a", "b"), observed.get()); + assertFalse(observed.get() instanceof ActorRuntime.Shared); + } + } + + + @Test + void actorRefsCannotBecomeImplicitCrossRuntimeChannels() { + try (ActorRuntime left = new ActorRuntime(); + ActorRuntime right = new ActorRuntime()) { + var foreign = left.spawnPrivate(() -> (message, context) -> { }); + var localPrivate = right.spawnPrivate(() -> (message, context) -> { }); + var localShared = right.spawnShared(() -> (message, context) -> { }); + + IllegalArgumentException privateFailure = assertThrows( + IllegalArgumentException.class, + () -> localPrivate.send(foreign)); + assertTrue(privateFailure.getMessage().contains("different ActorRuntime")); + + IllegalArgumentException sharedFailure = assertThrows( + IllegalArgumentException.class, + () -> localShared.send(Map.of("ref", foreign))); + assertTrue(sharedFailure.getMessage().contains("different ActorRuntime")); + + assertThrows(IllegalArgumentException.class, + () -> right.shareReadonly(List.of(foreign))); + } + } + + @Test + void actorRefsRemainSendableInsideOneRuntime() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch received = new CountDownLatch(1); + var target = runtime.spawnPrivate(() -> (message, context) -> { }); + var receiver = runtime.spawnPrivate(() -> (message, context) -> { + assertSame(target, message); + received.countDown(); + }); + + receiver.send(target); + assertTrue(received.await(2, TimeUnit.SECONDS)); + } + } + + + @Test + void actorPopulationIsBoundedAndSlotsReturnOnStop() { + var config = new ActorRuntime.DispatcherConfig(1, 1, 8, 2); + try (ActorRuntime runtime = new ActorRuntime(IsolatePolicy.developer(), config)) { + var first = runtime.spawnPrivate(() -> (message, context) -> { }); + var second = runtime.spawnShared(() -> (message, context) -> { }); + + assertEquals(2, runtime.actorCount()); + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> runtime.spawnPrivate(() -> (message, context) -> { })); + assertTrue(failure.getMessage().contains("actor runtime limit exceeded")); + + first.stop(); + assertEquals(1, runtime.actorCount()); + + assertDoesNotThrow(() -> + runtime.spawnPrivate(() -> (message, context) -> { })); + assertEquals(2, runtime.actorCount()); + + second.stop(); + } + } + + + @Test + void safePrivateFactoryRejectsCapturedHostStateEvenInDeveloperMode() { + try (ActorRuntime runtime = new ActorRuntime()) { + Object mutableHostState = new StringBuilder("host"); + SecurityException failure = assertThrows( + SecurityException.class, + () -> runtime.spawnPrivate(factoryContext -> { + mutableHostState.toString(); + return (message, context) -> { }; + })); + assertTrue(failure.getMessage().contains("stateless")); + } + } + + @Test + void isolatedPrivateActorCanUseOwnerCheckedDirectMemory() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var ref = runtime.spawnPrivate(factoryContext -> { + ActorRuntime.PrivateMemoryBlock block = + factoryContext.privateMemory().orElseThrow().allocatePrivateBytes(64); + block.writeByte(0, (byte) 7); + + return (message, context) -> { + if (block.readByte(0) != 7) throw new AssertionError("private block lost actor state"); + block.writeByte(1, message); + if (block.readByte(1) != message) throw new AssertionError("private block write mismatch"); + context.self().stop(); + }; + }); + + ref.send((byte) 42); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (ref.isAlive() && System.nanoTime() < deadline) Thread.sleep(5); + assertFalse(ref.isAlive()); + assertTrue(ref.failure().isEmpty()); + assertEquals(0L, runtime.privateMemoryBytes(), "actor teardown must release its private direct-memory quota"); + } + } + + @Test + void leakedPrivateDirectMemoryCannotBeReadOutsideOwnerActor() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + AtomicReference leaked = new AtomicReference<>(); + CountDownLatch created = new CountDownLatch(1); + + var ref = runtime.spawnPrivateTrusted(factoryContext -> { + ActorRuntime.PrivateMemoryBlock block = + factoryContext.privateMemory().orElseThrow().allocatePrivateBytes(8); + block.writeByte(0, (byte) 99); + leaked.set(block); + created.countDown(); + return (message, context) -> { }; + }); + + ref.send("initialize"); + assertTrue(created.await(2, TimeUnit.SECONDS)); + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> leaked.get().readByte(0)); + assertTrue(failure.getMessage().contains("owning actor")); + ref.stop(); + assertTrue(leaked.get().closed()); + } + } + + @Test + void privateDirectMemoryHandleCannotCrossMailboxBoundary() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + AtomicReference block = new AtomicReference<>(); + CountDownLatch created = new CountDownLatch(1); + + var owner = runtime.spawnPrivateTrusted(factoryContext -> { + block.set(factoryContext.privateMemory().orElseThrow().allocatePrivateBytes(8)); + created.countDown(); + return (message, context) -> { }; + }); + owner.send("initialize"); + assertTrue(created.await(2, TimeUnit.SECONDS)); + + var other = runtime.spawnPrivate(() -> (message, context) -> { }); + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> other.send(block.get())); + assertTrue(failure.getMessage().contains("not Sendable") + || failure.getMessage().contains("actor boundaries")); + + owner.stop(); + other.stop(); + } + } + + + + @Test + void actorCannotUseClosureCapturedForeignRuntimeSendOrSpawn() throws Exception { + try (ActorRuntime runtimeA = new ActorRuntime(); + ActorRuntime runtimeB = new ActorRuntime()) { + CountDownLatch checked = new CountDownLatch(1); + AtomicReference sendFailure = new AtomicReference<>(); + AtomicReference spawnFailure = new AtomicReference<>(); + + var target = runtimeA.spawn(() -> (message, context) -> { }); + var caller = runtimeB.spawn(() -> (message, context) -> { + try { + runtimeA.send(target, "cross-runtime"); + } catch (Throwable problem) { + sendFailure.set(problem); + } + try { + runtimeA.spawn(() -> (childMessage, childContext) -> { }); + } catch (Throwable problem) { + spawnFailure.set(problem); + } finally { + checked.countDown(); + } + }); + + caller.send("go"); + assertTrue(checked.await(2, TimeUnit.SECONDS)); + assertInstanceOf(SecurityException.class, sendFailure.get()); + assertInstanceOf(SecurityException.class, spawnFailure.get()); + } + } + + @Test + void actorCannotInvokeClosureCapturedForeignActorRef() throws Exception { + try (ActorRuntime runtimeA = new ActorRuntime(); + ActorRuntime runtimeB = new ActorRuntime()) { + CountDownLatch targetReceived = new CountDownLatch(1); + CountDownLatch checked = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + + var target = runtimeA.spawn(() -> (message, context) -> targetReceived.countDown()); + var foreignCaller = runtimeB.spawn(() -> (message, context) -> { + try { + target.send("cross-runtime"); + } catch (Throwable problem) { + failure.set(problem); + } finally { + checked.countDown(); + } + }); + + foreignCaller.send("go"); + assertTrue(checked.await(2, TimeUnit.SECONDS)); + assertInstanceOf(SecurityException.class, failure.get()); + assertEquals(1L, targetReceived.getCount()); + } + } + + @Test + void actorRuntimeEnforcesConfiguredActorCeiling() { + try (ActorRuntime runtime = new ActorRuntime(IsolatePolicy.developer(), 1)) { + runtime.spawn(() -> (message, context) -> { }); + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> runtime.spawn(() -> (message, context) -> { })); + assertTrue(error.getMessage().contains("actor runtime limit exceeded")); + assertEquals(1, runtime.maxActors()); + } + } + + @Test + void actorCodeCannotCloseItsOwnRuntime() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch checked = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + + var ref = runtime.spawn(() -> (message, context) -> { + try { + assertThrows(SecurityException.class, context.runtime()::close); + } catch (Throwable problem) { + failure.set(problem); + } finally { + checked.countDown(); + } + }); + + ref.send("check"); + assertTrue(checked.await(2, TimeUnit.SECONDS)); + assertNull(failure.get()); + } + } + + @Test + void strictActorCannotBypassReadonlyCapabilityThroughRuntimeHandle() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + IsolatePolicy strict = IsolatePolicy.strictFaas(); + var ref = runtime.spawnPrivate( + strict, + factoryContext -> (message, context) -> + context.runtime().shareReadonly(List.of("secret"))); + + ref.send("check"); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + + assertTrue(ref.failure().isPresent()); + assertInstanceOf(SecurityException.class, ref.failure().orElseThrow()); + } + } + + @Test + void synchronousInvokeWaitsForCarrierToLeaveTurnExecutor() throws Exception { + CountDownLatch guestTurnReturned = new CountDownLatch(1); + CountDownLatch releaseCarrier = new CountDownLatch(1); + + ActorRuntime.TurnExecutor turnExecutor = turn -> { + turn.run(); + guestTurnReturned.countDown(); + try { + if (!releaseCarrier.await(2, TimeUnit.SECONDS)) { + throw new IllegalStateException("test carrier release timed out"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("test carrier interrupted", interrupted); + } + }; + + try (ActorRuntime runtime = new ActorRuntime( + IsolatePolicy.developer(), + new ActorRuntime.DispatcherConfig(1, 1, 8), + turnExecutor)) { + AtomicReference result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + + Thread caller = new Thread(() -> { + try { + result.set(runtime.invoke( + ActorRuntime.ActorKind.PRIVATE, + 41, + (value, context) -> value + 1)); + } catch (Throwable thrown) { + failure.set(thrown); + } + }); + caller.start(); + + assertTrue(guestTurnReturned.await(2, TimeUnit.SECONDS)); + Thread.sleep(50); + assertTrue( + caller.isAlive(), + "synchronous invoke must not return while its actor carrier is still inside the host turn executor"); + + releaseCarrier.countDown(); + caller.join(1000); + + assertFalse(caller.isAlive()); + assertNull(failure.get()); + assertEquals(42, result.get()); + } + } + + @Test + void closeWaitsForCarrierToLeaveTurnExecutorAfterActorFinalizes() throws Exception { + CountDownLatch turnExecutorAfterGuestTurn = new CountDownLatch(1); + CountDownLatch releaseCarrier = new CountDownLatch(1); + + ActorRuntime.TurnExecutor turnExecutor = turn -> { + turn.run(); + turnExecutorAfterGuestTurn.countDown(); + + boolean released = false; + while (!released) { + try { + released = releaseCarrier.await(25, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + // shutdownNow() interrupts the carrier. Keep the wrapper + // entered until the test explicitly releases it. + } + } + }; + + ActorRuntime runtime = new ActorRuntime( + IsolatePolicy.developer(), + new ActorRuntime.DispatcherConfig(1, 1, 8), + turnExecutor); + CountDownLatch handled = new CountDownLatch(1); + var ref = runtime.spawnShared(() -> (message, context) -> handled.countDown()); + + ref.send("ping"); + assertTrue(handled.await(2, TimeUnit.SECONDS)); + assertTrue(turnExecutorAfterGuestTurn.await(2, TimeUnit.SECONDS)); + + AtomicReference closeFailure = new AtomicReference<>(); + Thread closer = new Thread(() -> { + try { + runtime.close(); + } catch (Throwable failure) { + closeFailure.set(failure); + } + }); + closer.start(); + + Thread.sleep(50); + assertTrue( + closer.isAlive(), + "close must wait until the carrier exits the host turn executor/Truffle boundary"); + + releaseCarrier.countDown(); + closer.join(1000); + + assertFalse(closer.isAlive()); + assertNull(closeFailure.get()); + assertThrows(IllegalStateException.class, () -> ref.send("after-close")); + } + + @Test + void closeStopsActorEvenWhenBehaviorClearsInterruptBeforeReturning() throws Exception { + ActorRuntime runtime = new ActorRuntime(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch cleared = new CountDownLatch(1); + + var ref = runtime.spawn(() -> (message, context) -> { + started.countDown(); + try { + Thread.sleep(30_000); + } catch (InterruptedException interrupted) { + Thread.interrupted(); + cleared.countDown(); + } + }); + + ref.send("block"); + assertTrue(started.await(2, TimeUnit.SECONDS)); + + assertDoesNotThrow(runtime::close); + assertTrue(cleared.await(1, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, () -> ref.send("after-close")); + } + + @Test + void closeCanBeRetriedAfterInitialTerminationTimeout() throws Exception { + ActorRuntime runtime = new ActorRuntime(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + var ref = runtime.spawn(() -> (message, context) -> { + started.countDown(); + while (true) { + try { + release.await(); + return; + } catch (InterruptedException ignored) { + // Deliberately ignore the first shutdown interrupt. + } + } + }); + + ref.send("block"); + assertTrue(started.await(2, TimeUnit.SECONDS)); + + IllegalStateException timedOut = assertThrows(IllegalStateException.class, runtime::close); + assertTrue(timedOut.getMessage().contains("did not observe full actor termination")); + + release.countDown(); + assertDoesNotThrow(runtime::close); + assertThrows(IllegalStateException.class, () -> ref.send("after-close")); + } + + @Test + void privateActorCannotCreateReadonlySharedMemory() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch checked = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + + var ref = runtime.spawnPrivate(() -> (message, context) -> { + try { + context.runtime().shareReadonly(List.of("private")); + } catch (Throwable failure) { + observed.set(failure); + } finally { + checked.countDown(); + } + }); + + ref.send("check"); + assertTrue(checked.await(2, TimeUnit.SECONDS)); + assertNotNull(observed.get()); + assertTrue(observed.get().getMessage().contains("private actors cannot access synchronized shared memory") + || observed.get().getMessage().contains("shareReadonly") + || observed.get() instanceof SecurityException); + } + } + + @Test + void privateActorCannotDereferenceCapturedReadonlySharedHandle() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + ActorRuntime.Shared> shared = runtime.shareReadonly(List.of("shared")); + CountDownLatch checked = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + + var ref = runtime.spawnPrivate(() -> (message, context) -> { + try { + shared.value(); + } catch (Throwable failure) { + observed.set(failure); + } finally { + checked.countDown(); + } + }); + + ref.send("check"); + assertTrue(checked.await(2, TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, observed.get()); + assertTrue(observed.get().getMessage().contains("private actors cannot access")); + } + } + + + + @Test + void fullMailboxRejectsBeforeTraversingAnotherMessage() throws Exception { + IsolatePolicy oneQueuedMessage = new IsolatePolicy( + IsolatePolicy.developer().capabilities(), + IsolatePolicy.developer().maxHeapBytes(), + 1, + IsolatePolicy.developer().maxWallTime(), + false); + + try (ActorRuntime runtime = new ActorRuntime(oneQueuedMessage)) { + CountDownLatch processing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + var ref = runtime.spawn(oneQueuedMessage, () -> (message, context) -> { + if ("first".equals(message)) { + processing.countDown(); + release.await(); + } + }); + + ref.send("first"); + assertTrue(processing.await(2, TimeUnit.SECONDS)); + ref.send("queued"); + + AtomicBoolean traversed = new AtomicBoolean(); + List shouldNotTraverse = new AbstractList<>() { + @Override + public Object get(int index) { + traversed.set(true); + throw new AssertionError("message graph must not be traversed after mailbox admission fails"); + } + + @Override + public int size() { + return 1; + } + }; + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> ref.send(shouldNotTraverse)); + assertTrue(error.getMessage().contains("mailbox limit exceeded")); + assertFalse(traversed.get()); + + release.countDown(); + } + } + + @Test + void concurrentSendersReserveMailboxBeforeTransportTraversal() throws Exception { + IsolatePolicy oneQueuedMessage = new IsolatePolicy( + IsolatePolicy.developer().capabilities(), + IsolatePolicy.developer().maxHeapBytes(), + 1, + IsolatePolicy.developer().maxWallTime(), + false); + + try (ActorRuntime runtime = new ActorRuntime(oneQueuedMessage)) { + CountDownLatch processing = new CountDownLatch(1); + CountDownLatch releaseActor = new CountDownLatch(1); + CountDownLatch firstTraversalEntered = new CountDownLatch(1); + CountDownLatch releaseFirstTraversal = new CountDownLatch(1); + AtomicReference firstFailure = new AtomicReference<>(); + AtomicBoolean secondTraversalRan = new AtomicBoolean(); + + var ref = runtime.spawn(oneQueuedMessage, () -> (message, context) -> { + if ("processing".equals(message)) { + processing.countDown(); + releaseActor.await(); + } + }); + + ref.send("processing"); + assertTrue(processing.await(2, TimeUnit.SECONDS)); + + List first = new AbstractList<>() { + private final AtomicBoolean blocked = new AtomicBoolean(); + + @Override + public Object get(int index) { + if (index != 0) throw new IndexOutOfBoundsException(index); + if (blocked.compareAndSet(false, true)) { + firstTraversalEntered.countDown(); + try { + if (!releaseFirstTraversal.await(2, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting to finish first traversal"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new java.util.concurrent.CancellationException(); + } + } + return "first-queued"; + } + + @Override + public int size() { + return 1; + } + }; + + Thread sender = Thread.ofPlatform().start(() -> { + try { + ref.send(first); + } catch (Throwable failure) { + firstFailure.set(failure); + } + }); + + assertTrue(firstTraversalEntered.await(2, TimeUnit.SECONDS)); + + List second = new AbstractList<>() { + @Override + public Object get(int index) { + secondTraversalRan.set(true); + return "second-queued"; + } + + @Override + public int size() { + return 1; + } + }; + + IllegalStateException rejected = assertThrows( + IllegalStateException.class, + () -> ref.send(second)); + assertTrue(rejected.getMessage().contains("mailbox limit exceeded")); + assertFalse(secondTraversalRan.get()); + + releaseFirstTraversal.countDown(); + sender.join(); + assertNull(firstFailure.get()); + + releaseActor.countDown(); + } + } + + + + @Test + void sharedActorBehaviorFactoryMustBeCaptureFree() { + try (ActorRuntime runtime = new ActorRuntime()) { + List captured = new ArrayList<>(); + + SecurityException failure = assertThrows( + SecurityException.class, + () -> runtime.spawnShared(factoryContext -> { + captured.add("captured"); + return (message, context) -> { }; + })); + + assertTrue(failure.getMessage().contains("stateless") + || failure.getMessage().contains("captured host state")); + assertTrue(captured.isEmpty(), "rejected factory must never execute"); + } + } + + @Test + void trustedSharedFactoryIsSupervisorOnlyAndNonAdversarial() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + List captured = new ArrayList<>(); + CountDownLatch delivered = new CountDownLatch(1); + + var ref = runtime.spawnSharedTrusted(factoryContext -> { + captured.add("factory"); + return (message, context) -> { + captured.add(message); + delivered.countDown(); + }; + }); + + ref.send("message"); + assertTrue(delivered.await(2, TimeUnit.SECONDS)); + assertEquals(List.of("factory", "message"), captured); + } + + IsolatePolicy adversarialShared = IsolatePolicy.strictFaas() + .withCapabilities(IsolatePolicy.Capability.SHARED_MEMORY); + try (ActorRuntime runtime = new ActorRuntime(adversarialShared)) { + SecurityException failure = assertThrows( + SecurityException.class, + () -> runtime.spawnSharedTrusted( + adversarialShared, + factoryContext -> (message, context) -> { })); + assertTrue(failure.getMessage().contains("adversarial")); + } + } + + +} diff --git a/src/test/java/dev/oreslang/ActorTransportHardeningTest.java b/src/test/java/dev/oreslang/ActorTransportHardeningTest.java new file mode 100644 index 00000000..9c83d94c --- /dev/null +++ b/src/test/java/dev/oreslang/ActorTransportHardeningTest.java @@ -0,0 +1,142 @@ +package dev.oreslang; + +import dev.oreslang.runtime.ActorRuntime; +import dev.oreslang.runtime.OresMutex; +import org.junit.jupiter.api.Test; + +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +final class ActorTransportHardeningTest { + @Test + void readonlySharedWrapperFreezesBeforePublication() { + try (ActorRuntime runtime = new ActorRuntime()) { + ArrayList source = new ArrayList<>(List.of("a")); + ActorRuntime.Shared shared = runtime.shareReadonly(source); + + source.add("b"); + + assertInstanceOf(List.class, shared.value()); + @SuppressWarnings("unchecked") + List frozen = (List) shared.value(); + assertEquals(List.of("a"), frozen); + assertThrows(UnsupportedOperationException.class, () -> frozen.add("c")); + } + } + + @Test + void actorRefsCannotCrossRuntimeCapabilityBoundaries() { + try (ActorRuntime runtimeA = new ActorRuntime(); + ActorRuntime runtimeB = new ActorRuntime()) { + var refA = runtimeA.spawn(() -> (message, context) -> { }); + var targetB = runtimeB.spawn(() -> (message, context) -> { }); + + IllegalArgumentException wrongDestinationRuntime = assertThrows( + IllegalArgumentException.class, + () -> runtimeB.send(refA, "wrong-runtime")); + assertTrue(wrongDestinationRuntime.getMessage().contains("different ActorRuntime")); + + IllegalArgumentException direct = assertThrows( + IllegalArgumentException.class, + () -> targetB.send(refA)); + assertTrue(direct.getMessage().contains("different ActorRuntime")); + + assertThrows(IllegalArgumentException.class, () -> ActorRuntime.freeze(refA)); + assertThrows(IllegalArgumentException.class, () -> runtimeA.shareReadonly(refA)); + } + } + + @Test + void cyclicMessageGraphsAreRejectedBeforeStackOverflow() { + ArrayList cycle = new ArrayList<>(); + cycle.add(cycle); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> ActorRuntime.freeze(cycle)); + + assertTrue(error.getMessage().contains("cyclic")); + } + + @Test + void excessivelyDeepMessageGraphsAreRejected() { + Object nested = "leaf"; + for (int i = 0; i < 300; i++) nested = List.of(nested); + Object tooDeep = nested; + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> ActorRuntime.freeze(tooDeep)); + + assertTrue(error.getMessage().contains("maximum nesting depth")); + } + + @Test + void hostileContainerSizeCannotForceEagerAllocation() { + List hostile = new AbstractList<>() { + @Override + public Object get(int index) { + throw new AssertionError("oversized container must be rejected before iteration"); + } + + @Override + public int size() { + return Integer.MAX_VALUE; + } + }; + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> ActorRuntime.freeze(hostile)); + + assertTrue(error.getMessage().contains("maximum node count")); + } + + @Test + void oversizedMessageGraphsAreRejected() { + List tooManyNodes = Collections.nCopies(100_001, 1); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> ActorRuntime.freeze(tooManyNodes)); + + assertTrue(error.getMessage().contains("maximum node count")); + } + + @Test + void sharedMutexCannotUseGenericFreezeTransport() { + var shared = OresMutex.shared(new int[]{0}); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> ActorRuntime.freeze(shared)); + + assertTrue(error.getMessage().contains("live actor/shared capabilities")); + } + @Test + void actorStartupFailureDoesNotLeaveUsableRefOrActorQuota() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + java.util.concurrent.CountDownLatch attempted = new java.util.concurrent.CountDownLatch(1); + + var ref = runtime.spawnPrivateTrusted(factoryContext -> { + attempted.countDown(); + throw new IllegalStateException("startup failed"); + }); + + ref.send("trigger"); + assertTrue(attempted.await(2, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue(ref.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS)); + assertFalse(ref.isAlive()); + assertEquals(0, runtime.actorCount()); + assertTrue(ref.failure().isPresent()); + assertEquals("startup failed", ref.failure().orElseThrow().getMessage()); + assertThrows(ActorRuntime.ActorTerminatedException.class, () -> ref.send("after-failure")); + } + } + + +} diff --git a/src/test/java/dev/oreslang/AsyncAwaitLanguageTest.java b/src/test/java/dev/oreslang/AsyncAwaitLanguageTest.java new file mode 100644 index 00000000..2ea62437 --- /dev/null +++ b/src/test/java/dev/oreslang/AsyncAwaitLanguageTest.java @@ -0,0 +1,182 @@ +package dev.oreslang; + +import dev.oreslang.compiler.OresCompiler; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class AsyncAwaitLanguageTest { + @Test + void asyncMainAndNestedAsyncFunctionComposeThroughAwait() throws Exception { + String program = """ + define module app + async fnc answer(): int { + return 42; + } + + pub async fnc main(): void { + val result = await answer(); + stdio.println(result); + return; + } + end + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "async-await.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + assertTrue(output.toString(StandardCharsets.UTF_8).contains("42")); + } + + @Test + void asyncCompositionRequiresAwaitRatherThanDirectTailTransfer() throws Exception { + assertThrows(IllegalArgumentException.class, + () -> OresCompiler.parseAndTypeCheck(""" + async fnc inner(): int { + return 7; + } + + async fnc bad_outer(): int { + return inner(); + } + """)); + + String program = """ + async fnc inner(): int { + return 7; + } + + async fnc outer(): int { + return await inner(); + } + + pub routine main(): void { + stdio.stdout.write(await outer()); + return; + } + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "async-tail-boundary.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + assertTrue(output.toString(StandardCharsets.UTF_8).contains("7")); + } + + @Test + void asyncTaskMovesOwnedArgumentsAndReturnsDetachedResult() throws Exception { + String program = """ + define module app + define class Box as + pub let int value = 1; + end + + async fnc change(Box mut box): Box { + box.value = 99; + return box; + } + + pub fnc main(): void { + let original = new Box(); + val changed = await change(original); + stdio.println(changed.value); + return; + } + end + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "async-owned-boundary.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("99")); + } + @Test + void awaitRejectsNonFutureExpressions() { + String program = """ + define module app + pub fnc main(): void { + val value = await 42; + return; + } + end + """; + + assertThrows(IllegalArgumentException.class, + () -> OresCompiler.parseAndTypeCheck(program)); + } + + @Test + void asyncActorCallableIsRejectedUntilMailboxContinuationLoweringExists() { + String program = """ + define module app + pub async actor fnc burn(int n): int { + return n; + } + end + """; + + assertThrows(IllegalArgumentException.class, + () -> OresCompiler.parseAndTypeCheck(program)); + } + + @Test + void asyncMoveBoundaryRejectsCallerUseAfterTransfer() { + String program = """ + define module app + define class Box as + pub let int value = 1; + end + + async fnc change(Box mut box): int { + box.value = 99; + return box.value; + } + + pub fnc main(): void { + let original = new Box(); + val changed = await change(original); + stdio.println(original.value); + stdio.println(changed); + return; + } + end + """; + + assertThrows(IllegalArgumentException.class, + () -> OresCompiler.parseAndTypeCheck(program)); + } + +} \ No newline at end of file diff --git a/src/test/java/dev/oreslang/BlockLoopControlFlowTest.java b/src/test/java/dev/oreslang/BlockLoopControlFlowTest.java new file mode 100644 index 00000000..f7f9aa13 --- /dev/null +++ b/src/test/java/dev/oreslang/BlockLoopControlFlowTest.java @@ -0,0 +1,344 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +final class BlockLoopControlFlowTest { + @Test + void blockCreatesAStandaloneLexicalScope() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + pub routine main(): void { + block { + val hidden = 1; + stdio.stdout.write(hidden); + } + return; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub routine main(): void { + block { + val hidden = 1; + } + stdio.stdout.write(hidden); + } + """))); + } + + @Test + void blockHasIndependentShadowingAndDeferLifetime() throws Exception { + String output = run(""" + pub routine main(): void { + val value = "outer"; + block { + val value = "inner"; + stdio.stdout.write(value); + defer stdio.stdout.write("-defer"); + } + stdio.stdout.write("-"); + stdio.stdout.write(value); + } + """); + + assertEquals("inner-defer-outer", output); + } + + @Test + void forOfBreakAndContinueTargetTheIteratorLoop() throws Exception { + String output = run(""" + pub routine main(): void { + for (val item of arr[1, 2, 3, 4]) { + if item == 2 { + continue; + } fi + stdio.stdout.write(item); + if item == 3 { + break; + } fi + } + } + """); + + assertEquals("13", output); + } + + @Test + void blockAndLoopPreserveProperTailCallPosition() throws Exception { + String output = run(""" + fnc countdown(int remaining): int { + block { + if remaining == 0 { + return 7; + } fi + loop { + return countdown(remaining - 1); + } + } + } + + pub routine main(): void { + stdio.stdout.write(countdown(20000)); + } + """); + + assertEquals("7", output); + } + + @Test + void loopSupportsBreakContinueAndMandatorySafepoints() throws Exception { + String output = run(""" + pub routine main(): void { + let i = 0; + loop { + i = i + 1; + if i == 2 { + continue; + } fi + if i == 4 { + break; + } fi + stdio.stdout.write(i); + } + stdio.stdout.write(process.descriptor.scheduler_safepoints); + } + """); + + assertEquals("134", output); + } + + @Test + void returnEscapesLoopAndTheEnclosingCallable() throws Exception { + String output = run(""" + fnc answer(): int { + loop { + return 7; + } + } + + pub routine main(): void { + stdio.stdout.write(answer()); + } + """); + + assertEquals("7", output); + } + + @Test + void breakAndContinueAreRejectedOutsideLoops() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub routine main(): void { + break; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub routine main(): void { + continue; + } + """))); + } + + @Test + void loopControlCannotCrossLambdaBoundary() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub routine main(): void { + loop { + val callback = || -> { + break; + }; + break; + } + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub routine main(): void { + loop { + val callback = || -> { + continue; + }; + break; + } + } + """))); + } + + @Test + void nestedLoopControlTargetsTheNearestLoop() throws Exception { + String output = run(""" + pub routine main(): void { + let outer = 0; + loop { + outer = outer + 1; + let inner = 0; + loop { + inner = inner + 1; + if inner == 1 { + continue; + } fi + break; + } + stdio.stdout.write(outer); + if outer == 2 { + break; + } fi + } + } + """); + + assertEquals("12", output); + } + + @Test + void conventionalForContinueStillRunsTheUpdateExpression() throws Exception { + String output = run(""" + pub routine main(): void { + let seen = 0; + for (let i = 0; i < 3; i = i + 1) { + if i < 2 { + continue; + } fi + seen = i; + } + stdio.stdout.write(seen); + } + """); + + assertEquals("2", output); + } + + @Test + void loopControlRunsFinallyAndIsNotCaughtAsAGuestException() throws Exception { + String output = run(""" + pub routine main(): void { + loop { + try { + break; + } catch (err) { + stdio.stdout.write("caught"); + } finally { + stdio.stdout.write("finally"); + } + } + stdio.stdout.write("after"); + } + """); + + assertEquals("finallyafter", output); + } + + @Test + void deferRunsWhenBreakContinueAndReturnUnwindScopes() throws Exception { + String output = run(""" + fnc finish(): void { + defer stdio.stdout.write("r"); + loop { + return; + } + } + + pub routine main(): void { + let i = 0; + loop { + i = i + 1; + defer stdio.stdout.write(i); + if i == 1 { + continue; + } fi + break; + } + finish(); + } + """); + + assertEquals("12r", output); + } + + @Test + void loopAndBlockRemainUsableAsCallableNamesAndFirstClassReferences() throws Exception { + String output = run(""" + fnc loop(): int { + return 3; + } + + fnc block(): int { + return 4; + } + + pub routine main(): void { + val loop_ref = loop; + val block_ref = block; + stdio.stdout.write(loop_ref()); + stdio.stdout.write(block_ref()); + } + """); + + assertEquals("34", output); + } + + @Test + void ifSupportsBraceAndThenFiForms() throws Exception { + String braces = run(""" + pub routine main(): void { + val value = 2; + if value == 1 { + stdio.stdout.write("a"); + } elseif value == 2 { + stdio.stdout.write("b"); + } else { + stdio.stdout.write("c"); + } fi + } + """); + assertEquals("b", braces); + + String keywordDelimited = run(""" + pub routine main(): void { + val value = 2; + if value == 1 then + stdio.stdout.write("a"); + elseif value == 2 then + stdio.stdout.write("b"); + else + stdio.stdout.write("c"); + fi + } + """); + assertEquals("b", keywordDelimited); + } + + @Test + void legacyIfDoFiRemainsSourceCompatible() throws Exception { + String output = run(""" + pub routine main(): void { + if true do + stdio.stdout.write("ok"); + fi + } + """); + assertEquals("ok", output); + } + + private static String run(String program) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "block-loop.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + return output.toString(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/dev/oreslang/CallableKeywordHardeningTest.java b/src/test/java/dev/oreslang/CallableKeywordHardeningTest.java new file mode 100644 index 00000000..0044ddc1 --- /dev/null +++ b/src/test/java/dev/oreslang/CallableKeywordHardeningTest.java @@ -0,0 +1,18 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +final class CallableKeywordHardeningTest { + @Test + void canonicalFunctionKeywordIsFnc() { + assertDoesNotThrow(() -> Parser.parse("pub fnc run(): void { return; }")); + String legacy = "f" + "n"; + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> Parser.parse(("pub %s run(): void { return; }").formatted(legacy))); + assertTrue(error.getMessage().contains("fnc")); + } +} diff --git a/src/test/java/dev/oreslang/CallableSemanticsTest.java b/src/test/java/dev/oreslang/CallableSemanticsTest.java new file mode 100644 index 00000000..21b10f00 --- /dev/null +++ b/src/test/java/dev/oreslang/CallableSemanticsTest.java @@ -0,0 +1,342 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +final class CallableSemanticsTest { + + @Test + void namespaceModulesMainFncRoutineAndLambdaCompose() throws Exception { + String output = run(""" + namespace demo; + + define module math + pub fnc factorial(int n): int { + return n == 0 ? 1 : n * factorial(n - 1); + } + + pub fnc offset(int x): int { + return x + 10; + } + end + + pub routine main(): void { + val int base = 7; + val Fnc lexical = |int x| -> { + return x + base; + }; + val Fnc isolated = nlex |int x| -> { + val int base = 1; + return math.offset(x) + base; + }; + stdio.stdout.write(math.factorial(5)); + stdio.stdout.write(":"); + stdio.stdout.write(lexical(2)); + stdio.stdout.write(":"); + stdio.stdout.write(isolated(2)); + return; + } + """); + + assertEquals("120:9:13", output); + } + + @Test + void explicitNlexLambdaCannotCaptureOuterLocal() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> + TypeChecker.check(Parser.parse(""" + fnc make(): (() => int) { + val int local = 42; + return nlex || -> { + return local; + }; + } + """))); + assertTrue(error.getMessage().contains("local") || error.getMessage().contains("unknown name")); + } + + @Test + void nlexNamedCallableMakesNestedLambdasNonCapturing() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> + TypeChecker.check(Parser.parse(""" + nlex fnc make(): (() => int) { + val int local = 42; + return || -> { + return local; + }; + } + """))); + assertTrue(error.getMessage().contains("local") || error.getMessage().contains("unknown name")); + } + + @Test + void nlexStillResolvesModulesGlobalsAndOwnShadowingLocals() throws Exception { + String output = run(""" + define module math + pub fnc one(): int { return 1; } + end + + pub routine main(): void { + val int value = 99; + val Fnc callback = nlex || -> { + val int value = 2; + return math.one() + value; + }; + stdio.stdout.write(callback()); + stdio.stdout.write(value); + return; + } + """); + assertEquals("399", output); + } + + @Test + void contextualLambdaTypesSupportTheDemoProgram() throws Exception { + String output = run(""" + namespace nlex_demo; + + type IntFn = typeof fnc(int value) => int; + + define module math + pub fnc factorial(int n): int { + return n == 0 ? 1 : n * factorial(n - 1); + } + + pub fnc offset(int value): int { + return value + 10; + } + end + + nlex fnc makeOffsetter(): IntFn { + return |value| -> { + return math.offset(value); + }; + } + + pub routine main(): void { + val int outer_bias = 100; + + val IntFn lexical = |value| -> { + return value + outer_bias; + }; + + val IntFn explicit_nlex = nlex |value| -> { + val int outer_bias = 1; + return math.offset(value) + outer_bias; + }; + + val IntFn inherited_nlex = makeOffsetter(); + + stdio.stdout.write(math.factorial(5)); + stdio.stdout.write(":"); + stdio.stdout.write(lexical(2)); + stdio.stdout.write(":"); + stdio.stdout.write(explicit_nlex(2)); + stdio.stdout.write(":"); + stdio.stdout.write(inherited_nlex(5)); + return; + } + """); + + assertEquals("120:102:13:15", output); + } + + @Test + void routineAndFncDifferByReifiabilityNotRecursion() throws Exception { + assertDoesNotThrow(() -> + TypeChecker.check(Parser.parse(""" + routine loop(bool finished): void { + if finished; do + return; + else + loop(true); + return; + fi + } + """))); + + IllegalArgumentException routineValue = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + routine work(int value): int { + return value + 1; + } + + fnc bad(): void { + val Fnc callback = work; + } + """))); + assertTrue(routineValue.getMessage().contains("direct-call-only")); + + String output = run(""" + fnc apply(Fnc callback, int value): int { + return callback(value); + } + + fnc increment(int value): int { + return value + 1; + } + + routine countdown(int value): int { + if value == 0; do + return 0; + else + return countdown(value - 1); + fi + } + + pub routine main(): void { + val Fnc callback = increment; + stdio.stdout.write(apply(callback, 4)); + stdio.stdout.write(":"); + stdio.stdout.write(countdown(4)); + return; + } + """); + assertEquals("5:0", output); + } + + @Test + void instanceMethodsAreDirectOnlyButStaticFncsAndExplicitLambdasAreFirstClass() throws Exception { + IllegalArgumentException methodValue = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Box as + pub addOne(int value): int { + return value + 1; + } + end + + fnc bad(): void { + val box = new Box(); + val Fnc callback = box.addOne; + } + """))); + assertTrue(methodValue.getMessage().contains("direct-call-only")); + + String output = run(""" + fnc apply(Fnc callback, int value): int { + return callback(value); + } + + define class Box as + pub addOne(int value): int { + return value + 1; + } + + pub static fnc twice(int value): int { + return value * 2; + } + end + + pub routine main(): void { + val Fnc wrapped = |int value| -> { + val box = new Box(); + return box.addOne(value); + }; + val Fnc static_callback = Box.twice; + + stdio.stdout.write(apply(wrapped, 4)); + stdio.stdout.write(":"); + stdio.stdout.write(apply(static_callback, 4)); + return; + } + """); + assertEquals("5:8", output); + } + + @Test + void moduleAliasesKeepFncsFirstClassAndRoutinesDirectOnlyAtRuntime() throws Exception { + String output = run(""" + define module service + pub fnc transform(int value): int { + return value + 1; + } + + pub routine direct_only(int value): int { + return value + 2; + } + end + + pub routine main(): void { + val alias = service; + val Fnc callback = alias.transform; + stdio.stdout.write(callback(4)); + stdio.stdout.write(":"); + stdio.stdout.write(alias.direct_only(4)); + return; + } + """); + + assertEquals("5:6", output); + } + + @Test + void functionValuedFieldsRemainFirstClassWithoutBecomingMethods() throws Exception { + String output = run(""" + fnc increment(int value): int { + return value + 1; + } + + define class Box as + pub val Fnc callback; + end + + pub routine main(): void { + val box = new Box(increment); + stdio.stdout.write(box.callback(4)); + stdio.stdout.write(":"); + val Fnc callback = box.callback; + stdio.stdout.write(callback(9)); + return; + } + """); + + assertEquals("5:10", output); + } + + @Test + void localCallableBindingShadowsTopLevelDeclarationForDirectCalls() throws Exception { + String output = run(""" + fnc value(): int { + return 1; + } + + fnc invoke(Fnc value): int { + return value(); + } + + pub routine main(): void { + val Fnc local = || -> { + return 7; + }; + stdio.stdout.write(invoke(local)); + return; + } + """); + + assertEquals("7", output); + } + + private static String run(String program) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "callables.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + return output.toString(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/dev/oreslang/ChannelSelectSyntaxTest.java b/src/test/java/dev/oreslang/ChannelSelectSyntaxTest.java new file mode 100644 index 00000000..c7a849b5 --- /dev/null +++ b/src/test/java/dev/oreslang/ChannelSelectSyntaxTest.java @@ -0,0 +1,373 @@ +package dev.oreslang; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Parser; +import dev.oreslang.types.OwnershipChecker; +import dev.oreslang.types.TypeChecker; +import org.junit.jupiter.api.Test; + +final class ChannelSelectSyntaxTest { + @Test + void parsesBlockingAndNonBlockingStaticSelect() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + fnc blocking( + Channel incoming, + Channel payload, + Channel done + ): void { + select { + case readch incoming: let msg + stdio.println(msg); + case readch payload: const body + stdio.println(body); + case readch done: + return; + } + return; + } + + actor fnc nonblocking(): void { + val Channel incoming = Channel.new(1); + val Channel payload = Channel.new(1); + val Channel done = Channel.new(1); + + nb select { + case readch incoming: let msg + stdio.println(msg); + case readch payload: const body + stdio.println(body); + case readch done: const signal + return; + } + stdio.println("continued immediately"); + return; + } + """)); + + Ast.FunctionDecl blocking = + (Ast.FunctionDecl) program.modules().getFirst().declarations().get(0); + Ast.SelectStmt blockingSelect = + assertInstanceOf(Ast.SelectStmt.class, blocking.body().getFirst()); + assertEquals(Ast.WaitMode.BLOCKING, blockingSelect.mode()); + assertEquals(Ast.SelectPolicy.FAIR, blockingSelect.policy()); + assertEquals(Ast.BindingKind.LET, blockingSelect.arms().getFirst().bindingKind()); + assertEquals(Ast.BindingKind.CONST, blockingSelect.arms().get(1).bindingKind()); + + Ast.FunctionDecl nonblocking = + (Ast.FunctionDecl) program.modules().getFirst().declarations().get(1); + Ast.SelectStmt nb = + assertInstanceOf(Ast.SelectStmt.class, nonblocking.body().getFirst()); + assertEquals(Ast.WaitMode.NONBLOCKING, nb.mode()); + + assertDoesNotThrow(() -> OwnershipChecker.check(program)); + } + + @Test + void staticNbSelectRequiresActorExecutionDomain() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc wrong(Channel input): void { + nb select { + case readch input: val value + stdio.println(value); + } + return; + } + """))); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + actor fnc right(): void { + val Channel input = Channel.new(1); + nb select { + case readch input: val value + stdio.println(value); + } + return; + } + """))); + } + + @Test + void selectPolicyIsFairByDefaultAndPriorityOrRandomOnlyWhenExplicit() { + Ast.Program program = Parser.parse(""" + fnc policies(Channel a, Channel b): void { + select { + case readch a: val x + stdio.println(x); + case readch b: val y + stdio.println(y); + } + + select first { + case readch a: val x + stdio.println(x); + case readch b: val y + stdio.println(y); + } + + select random { + case readch a: val x + stdio.println(x); + case readch b: val y + stdio.println(y); + } + return; + } + """); + + Ast.FunctionDecl fn = + (Ast.FunctionDecl) program.modules().getFirst().declarations().getFirst(); + assertEquals( + Ast.SelectPolicy.FAIR, + ((Ast.SelectStmt) fn.body().get(0)).policy()); + assertEquals( + Ast.SelectPolicy.PRIORITY, + ((Ast.SelectStmt) fn.body().get(1)).policy()); + assertEquals( + Ast.SelectPolicy.RANDOM, + ((Ast.SelectStmt) fn.body().get(2)).policy()); + } + + @Test + void parsesImmediateChannelProbesWithoutConfusingTryCatch() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + fnc probe(Channel input, Channel output): void { + val Option read = try readch input; + val bool wrote = try writech output, 42; + + try { + stdio.println("ordinary try still works"); + } catch err { + stdio.println(err); + } + return; + } + """)); + + Ast.FunctionDecl fn = + (Ast.FunctionDecl) program.modules().getFirst().declarations().getFirst(); + Ast.BindingStmt read = (Ast.BindingStmt) fn.body().get(0); + Ast.ChannelOpExpr readOp = + assertInstanceOf(Ast.ChannelOpExpr.class, read.initializer()); + assertEquals(Ast.WaitMode.IMMEDIATE, readOp.mode()); + assertEquals(Ast.ChannelOperation.READ, readOp.operation()); + + Ast.BindingStmt wrote = (Ast.BindingStmt) fn.body().get(1); + Ast.ChannelOpExpr writeOp = + assertInstanceOf(Ast.ChannelOpExpr.class, wrote.initializer()); + assertEquals(Ast.WaitMode.IMMEDIATE, writeOp.mode()); + assertEquals(Ast.ChannelOperation.WRITE, writeOp.operation()); + + assertInstanceOf(Ast.TryStmt.class, fn.body().get(2)); + } + + @Test + void parsesDynamicSelectFromRuntimeCollections() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + fnc choose(Array cases): SelectResult { + return select from cases; + } + + fnc arm(Array cases): Future { + return nb select first from cases; + } + + fnc probe(Array cases): Option { + return try select from cases; + } + """)); + + Ast.FunctionDecl choose = + (Ast.FunctionDecl) program.modules().getFirst().declarations().get(0); + Ast.DynamicSelectExpr blocking = + assertInstanceOf( + Ast.DynamicSelectExpr.class, + ((Ast.ReturnStmt) choose.body().getFirst()).value()); + assertEquals(Ast.WaitMode.BLOCKING, blocking.mode()); + assertEquals(Ast.SelectPolicy.FAIR, blocking.policy()); + + Ast.FunctionDecl arm = + (Ast.FunctionDecl) program.modules().getFirst().declarations().get(1); + Ast.DynamicSelectExpr nb = + assertInstanceOf( + Ast.DynamicSelectExpr.class, + ((Ast.ReturnStmt) arm.body().getFirst()).value()); + assertEquals(Ast.WaitMode.NONBLOCKING, nb.mode()); + assertEquals(Ast.SelectPolicy.PRIORITY, nb.policy()); + } + + @Test + void staticSelectSupportsWriteAndDefaultArms() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc send(Channel output, string payload): void { + try select first { + case writech output, payload: + stdio.println("sent"); + default: + stdio.println("busy"); + } + return; + } + """))); + } + + @Test + void channelAndDynamicCaseFactoriesTypeCheckEndToEnd() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc make(): Channel { + return Channel.new(16); + } + + fnc arm(): Future { + val Channel input = Channel.new(4); + val Channel output = Channel.new(4); + val Array cases = [ + SelectCase.read(input), + SelectCase.write(output, 42) + ]; + return nb select from cases; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc bad(): Channel { + return Channel.new(1); + } + """))); + } + + @Test + void channelAndSelectCapabilitiesCannotCrossActorBoundaries() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub actor fnc bad(Channel channel): int { + return 1; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub actor fnc bad_result(Array cases): SelectResult { + return select from cases; + } + """))); + } + + @Test + void nbSelectMovesMoveOnlyCapturesIntoDeferredContinuation() { + IllegalArgumentException moved = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + fnc bad(Channel input): void { + val Array owned = [1, 2, 3]; + + nb select { + case readch input: val value + stdio.println(owned[0]); + } + + stdio.println(owned[0]); + return; + } + """))); + + assertTrue( + moved.getMessage().contains("moved value") + || moved.getMessage().contains("cannot use moved"), + moved::getMessage); + } + + @Test + void nbSelectMayCaptureCopyValuesAndActorSelf() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc copy_capture(Channel input): void { + val int label = 7; + + nb select { + case readch input: val value + stdio.println(label + value); + } + + stdio.println(label); + return; + } + + shared actor Counter { + let int count = 0; + + pub tick(): void { + val Channel input = Channel.new(1); + + nb select { + case readch input: val value + self.count = self.count + value; + } + + return; + } + } + """))); + } + + @Test + void captureScannerTraversesNestedChannelAndSelectSyntax() { + IllegalArgumentException moved = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + fnc bad(Channel input): void { + val Array owned = [1, 2, 3]; + + val (() => void) callback = || -> { + nb select { + case readch input: val value + stdio.println(owned[0]); + } + return; + }; + + callback(); + stdio.println(owned[0]); + return; + } + """))); + + assertTrue( + moved.getMessage().contains("moved value") + || moved.getMessage().contains("cannot use moved"), + moved::getMessage); + } + + @Test + void rejectsMalformedChannelAndSelectForms() { + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + fnc bad(Channel ch): void { + nb select; + return; + } + """)); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + fnc bad(Channel ch): void { + select { + case readch ch + return; + } + return; + } + """)); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + fnc bad(Channel ch): void { + select { + default: + return; + default: + return; + } + return; + } + """)); + } +} diff --git a/src/test/java/dev/oreslang/CircularImportInitializationTest.java b/src/test/java/dev/oreslang/CircularImportInitializationTest.java new file mode 100644 index 00000000..5c64bd0d --- /dev/null +++ b/src/test/java/dev/oreslang/CircularImportInitializationTest.java @@ -0,0 +1,105 @@ +package dev.oreslang; + +import dev.oreslang.compiler.IncrementalCompiler; +import dev.oreslang.runtime.ExecutionProfile; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.runtime.LinkedProgramRunner; +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +final class CircularImportInitializationTest { + @TempDir + Path tempDir; + + @Test + void twoFilesMayImportEachOtherAndInitRunsOnlyAfterBothAreLinked() throws Exception { + Path a = tempDir.resolve("a.ores"); + Path b = tempDir.resolve("b.ores"); + + Files.writeString(a, """ + import fnc {b_value} from "./b.ores"; + + pub fnc a_value(): String { + return "A"; + } + + fnc init(): void { + stdio.stdout.write("init-a:"); + stdio.stdout.write(b_value()); + stdio.stdout.write("|"); + return; + } + + pub routine main(): void { + stdio.stdout.write("main:"); + stdio.stdout.write(a_value()); + stdio.stdout.write(b_value()); + return; + } + """); + + Files.writeString(b, """ + import fnc {a_value} from "./a.ores"; + + pub fnc b_value(): String { + return "B"; + } + + fnc init(): void { + stdio.stdout.write("init-b:"); + stdio.stdout.write(a_value()); + stdio.stdout.write("|"); + return; + } + """); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ByteArrayOutputStream error = new ByteArrayOutputStream(); + IncrementalCompiler.BuildResult build = LinkedProgramRunner.run( + a, + IsolatePolicy.developer(), + ExecutionProfile.serverJit(), + output, + error); + + assertEquals("init-a:B|init-b:A|main:AB", output.toString(StandardCharsets.UTF_8)); + + List> groups = build.initializationGroups(); + assertEquals(1, groups.size(), "the A<->B cycle should form one initialization barrier"); + assertEquals(2, groups.getFirst().size()); + assertTrue(groups.getFirst().contains(a.toAbsolutePath().normalize().toString().replace('\\', '/'))); + assertTrue(groups.getFirst().contains(b.toAbsolutePath().normalize().toString().replace('\\', '/'))); + } + + @Test + void initHookHasAClosedLifecycleSignature() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc init(): void { return; } + pub routine main(): void { return; } + """))); + + IllegalArgumentException withArgs = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + fnc init(int value): void { return; } + """))); + assertTrue(withArgs.getMessage().contains("init hook")); + + IllegalArgumentException publicInit = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + pub fnc init(): void { return; } + """))); + assertTrue(publicInit.getMessage().contains("init hook")); + } +} diff --git a/src/test/java/dev/oreslang/GarbageCollectionLanguageTest.java b/src/test/java/dev/oreslang/GarbageCollectionLanguageTest.java new file mode 100644 index 00000000..a5950d99 --- /dev/null +++ b/src/test/java/dev/oreslang/GarbageCollectionLanguageTest.java @@ -0,0 +1,64 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.runtime.CapabilityChecker; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +final class GarbageCollectionLanguageTest { + @Test + void processGcIsAFirstClassBuiltin() throws Exception { + Source source = Source.newBuilder(OresLanguage.ID, """ + pub fnc main(): void { + process.gc(); + return; + } + """, "process-gc.ores").mimeType(OresLanguage.MIME_TYPE).build(); + assertDoesNotThrow(() -> { + try (Context context = Context.newBuilder(OresLanguage.ID).allowAllAccess(false).build()) { + context.eval(source); + } + }); + } + + @Test + void actorGcRequiresAnActorMailboxTurn() throws Exception { + Source source = Source.newBuilder(OresLanguage.ID, """ + pub fnc main(): void { + actor.gc(); + return; + } + """, "actor-gc-outside-actor.ores").mimeType(OresLanguage.MIME_TYPE).build(); + try (Context context = Context.newBuilder(OresLanguage.ID).allowAllAccess(false).build()) { + PolyglotException error = assertThrows(PolyglotException.class, () -> context.eval(source)); + assertTrue(error.getMessage().contains("actor.gc() requires execution inside an actor")); + } + } + + @Test + void strictFaasRejectsProcessGcDuringCapabilityAdmission() { + var program = TypeChecker.check(Parser.parse(""" + pub fnc main(): void { + process.gc(); + return; + } + """)); + + SecurityException error = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.strictFaas())); + assertTrue(error.getMessage().contains("GC_CONTROL")); + } + + @Test + void strictFaasDoesNotGrantProcessWideGcControl() { + assertFalse(IsolatePolicy.strictFaas().allows(IsolatePolicy.Capability.GC_CONTROL)); + assertTrue(IsolatePolicy.developer().allows(IsolatePolicy.Capability.GC_CONTROL)); + } +} diff --git a/src/test/java/dev/oreslang/GenericsAndOperatorsHardeningTest.java b/src/test/java/dev/oreslang/GenericsAndOperatorsHardeningTest.java new file mode 100644 index 00000000..512e5872 --- /dev/null +++ b/src/test/java/dev/oreslang/GenericsAndOperatorsHardeningTest.java @@ -0,0 +1,562 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +final class GenericsAndOperatorsHardeningTest { + @Test + void infersGenericFunctionArgumentsWithoutTreatingTAsAny() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc identity(T value): T { + return value; + } + + fnc use(): void { + val int number = identity(42); + val String label = identity("ores"); + return; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc unsound(): T { + return 42; + } + end + """))); + } + + @Test + void enforcesKnownGenericArityAndSubstitutesClassMembers() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + define class Box as + pub val T value; + + pub get(): T { + return self.value; + } + end + + fnc read(Box box): int { + return box.get(); + } + + fnc make(): Box { + return new Box(7); + } + end + """))); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + define class Pair as + val A left; + val B right; + end + + fnc bad(Pair pair): void { + return; + } + end + """))); + assertTrue(error.getMessage().contains("expects 2 type argument")); + } + + @Test + void explicitGenericCallsAndInferenceMarkersAreChecked() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc identity(T value): T { + return value; + } + + fnc use(): void { + val explicit = identity(42); + val inferred = identity<>(42); + stdio.println(explicit); + stdio.println(explicit); + stdio.println(inferred); + stdio.println(inferred); + return; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc identity(T value): T { return value; } + fnc bad(): void { + val int value = identity("wrong"); + return; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc plain(int value): int { return value; } + fnc bad(): int { return plain<>(1); } + end + """))); + } + + @Test + void genericInheritanceSubstitutesFieldsMethodsConstructorsAndInterfaces() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + define interface HasValue + value: T; + end + + define class Parent as + pub val T value; + + pub get(): T { + return self.value; + } + end + + define class Child extends Parent implements HasValue as + end + + define class IntChild extends Parent as + end + + fnc read(Child child): int { + return child.get(); + } + + fnc reuseInheritedGenericResult(Child child): void { + val value = child.get(); + stdio.println(value); + stdio.println(value); + return; + } + + fnc readField(IntChild child): int { + return child.value; + } + + fnc reuseInheritedGenericField(IntChild child): void { + val value = child.value; + stdio.println(value); + stdio.println(value); + return; + } + + fnc make(): Child { + return new Child(7); + } + end + """))); + } + + @Test + void qualifiedGenericCallsInferButUnspecializedGenericValuesAreRejected() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module util + pub fnc identity(T value): T { + return value; + } + end + + define module app + fnc use(): void { + val value = util.identity<>(7); + stdio.println(value); + stdio.println(value); + return; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc identity(T value): T { return value; } + + fnc bad(): void { + val f = identity; + return; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module util + pub fnc identity(T value): T { return value; } + end + + define module app + fnc bad(): void { + val f = util.identity; + return; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + define class Box as + pub map(T value): T { return value; } + end + + fnc bad(Box box): void { + val f = box.map; + return; + } + end + """))); + } + + @Test + void genericInferenceRejectsUnknownArgumentShapes() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module app + fnc identity(T value): T { + return value; + } + + fnc bad(): void { + val value = identity(arr[]); + return; + } + end + """))); + assertTrue(error.getMessage().contains("cannot infer")); + } + + @Test + void genericObjectDestructuringPreservesConcreteFieldTypes() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + define class Box as + pub val T value; + end + + fnc use(Box box): void { + const {value} = box; + stdio.println(value); + stdio.println(value); + return; + } + end + """))); + } + + @Test + void genericInterfaceMethodsAreAlphaEquivalentButPreserveGenericArity() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + define interface Mapper + fnc map(T input, U fallback) => U; + end + + define class Good implements Mapper as + pub map(T input, V fallback): V { + return fallback; + } + end + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + define interface Mapper + fnc map(T input, U fallback) => U; + end + + define class Bad implements Mapper as + pub map(T input, A fallback): A { + return fallback; + } + end + end + """))); + } + + @Test + void genericNominalSubtypingPreservesConcreteArguments() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + define interface HasValue + value: T; + end + + define interface ExtendedValue extends HasValue + end + + define class Parent as + pub val T value; + end + + define class Child extends Parent implements ExtendedValue as + end + + fnc takeParentInt(Parent value): void { return; } + fnc takeValueInt(HasValue value): void { return; } + + fnc ok(Child parentValue, Child interfaceValue): void { + takeParentInt(parentValue); + takeValueInt(interfaceValue); + return; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + define class Parent as + pub val T value; + end + + define class Child extends Parent as + end + + fnc takeString(Parent value): void { return; } + + fnc bad(Child value): void { + takeString(value); + return; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + define interface HasValue + value: T; + end + + define interface ExtendedValue extends HasValue + end + + define class Box implements ExtendedValue as + pub val T value; + end + + fnc takeString(HasValue value): void { return; } + + fnc bad(Box value): void { + takeString(value); + return; + } + end + """))); + } + + @Test + void staticFunctionsOwnTheirGenericParametersAndCannotCaptureClassGenerics() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module model + define class Box as + pub static fnc identity(U value): U { + return value; + } + end + end + + define module app + fnc use(): void { + val value = model.Box.identity<>(7); + stdio.println(value); + stdio.println(value); + return; + } + end + """))); + + IllegalArgumentException captured = assertThrows(IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module app + define class Bad as + pub static fnc leak(T value): T { + return value; + } + end + end + """))); + assertTrue(captured.getMessage().contains("cannot reference enclosing class generic")); + + IllegalArgumentException bodyCapture = assertThrows(IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module app + define class Bad as + pub static fnc leakInside(): void { + val T value = process.dynamic; + return; + } + end + end + """))); + assertTrue(bodyCapture.getMessage().contains("cannot reference enclosing class generic")); + } + + @Test + void inferredConstructorsBindClassGenericsIncludingInheritedFields() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + define class Box as + pub val T value; + end + + define class Parent as + pub val T value; + end + + define class Child extends Parent as + end + + fnc use(): void { + val box = new Box<>(7); + val number = box.value; + stdio.println(number); + stdio.println(number); + + val child = new Child<>("ores"); + val label = child.value; + stdio.println(label); + stdio.println(label); + return; + } + end + """))); + } + + @Test + void inferredConstructorsRejectMissingAndConflictingBindings() { + IllegalArgumentException missing = assertThrows(IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module app + define class Phantom as + end + + fnc bad(): void { + val value = new Phantom<>(); + return; + } + end + """))); + assertTrue(missing.getMessage().contains("cannot infer class generic")); + + IllegalArgumentException conflict = assertThrows(IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module app + define class Same as + pub val T left; + pub val T right; + end + + fnc bad(): void { + val value = new Same<>(1, "mixed"); + return; + } + end + """))); + assertTrue(conflict.getMessage().contains("conflicting inference")); + } + + @Test + void spacedComparisonsAreNotMistakenForGenericCalls() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc between(int a, int b, int c): bool { + return a < b && b > (c); + } + end + """))); + } + + @Test + void nestedGenericClosersDoNotBecomeShiftOperators() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc keep(Option> value): Option> { + return value; + } + end + """))); + } + + @Test + void logicalAndBitwiseFamiliesHaveDistinctTypesAndPrecedence() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc bits(int a, int b): int { + return ((~a & b) | (a ^ b)) << 1 >> 1 >>> 1; + } + + fnc logic(bool a, bool b): bool { + return a && b || a ^^ b; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc bad(bool a, bool b): bool { + return a | b; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc bad(float a, int b): int { + return a & b; + } + end + """))); + } + + @Test + void zeroArgumentLambdaStillUsesDoublePipeAndRuntimeExecutesBitwiseOps() throws Exception { + String program = """ + define module app + fnc callback(): (() => int) { + return || -> { + return 7; + }; + } + + pub fnc main(): void { + stdio.println((5 & 3) | (8 >> 2)); + stdio.println(true ^^ false); + stdio.println(false && [true][99]); + stdio.println(true || [false][99]); + stdio.println(~0); + return; + } + end + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "operators.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("3")); + assertTrue(text.contains("true")); + assertTrue(text.contains("-1")); + } +} diff --git a/src/test/java/dev/oreslang/HostImportTest.java b/src/test/java/dev/oreslang/HostImportTest.java new file mode 100644 index 00000000..539f7cf4 --- /dev/null +++ b/src/test/java/dev/oreslang/HostImportTest.java @@ -0,0 +1,175 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.runtime.ActorRuntime; +import dev.oreslang.runtime.CapabilityChecker; +import dev.oreslang.runtime.ExecutionProfile; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Value; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +final class HostImportTest { + @Test + void validatesJavaImportShapesAliasesAndCollisions() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + import module Math from "java:java.lang.Math"; + pub fnc main(): void { return; } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + import class Nope from "java:java.lang.Math"; + pub fnc main(): void { return; } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + import class Math from "java:java/lang/Math"; + pub fnc main(): void { return; } + """))); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + import class ArrayList as JArrayList from "java:java.util.ArrayList"; + pub fnc main(): void { + val values = new JArrayList(); + return; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + import class {ArrayList, LinkedList} as JList from "java:java.util.ArrayList"; + pub fnc main(): void { return; } + """)); + } + + @Test + void hostClassAllowlistRequiresDedicatedCapabilityAndAdversarialRejectsIt() { + assertThrows(SecurityException.class, () -> + IsolatePolicy.developer().restrictedContextBuilder( + ExecutionProfile.serverJit(), Set.of("java.lang.Math"))); + + assertThrows(IllegalArgumentException.class, () -> + IsolatePolicy.strictFaas().withCapabilities(IsolatePolicy.Capability.JAVA_INTEROP)); + } + + @Test + void sensitiveJdkClassesRequireExtraCapabilitiesOrStayBlocked() { + IsolatePolicy javaOnly = IsolatePolicy.developer() + .withCapabilities(IsolatePolicy.Capability.JAVA_INTEROP); + + assertThrows(SecurityException.class, () -> javaOnly.restrictedContextBuilder( + ExecutionProfile.serverJit(), Set.of("java.lang.System"))); + assertThrows(SecurityException.class, () -> javaOnly.restrictedContextBuilder( + ExecutionProfile.serverJit(), Set.of("java.lang.Class"))); + assertThrows(SecurityException.class, () -> javaOnly.restrictedContextBuilder( + ExecutionProfile.serverJit(), Set.of("java.io.File"))); + + IsolatePolicy filePolicy = javaOnly.withCapabilities( + IsolatePolicy.Capability.FILESYSTEM_READ, + IsolatePolicy.Capability.FILESYSTEM_WRITE); + assertDoesNotThrow(() -> filePolicy.restrictedContextBuilder( + ExecutionProfile.serverJit(), Set.of("java.io.File"))); + } + + @Test + void aliasesConstructsAndInvokesAllowlistedJavaClasses() { + IsolatePolicy policy = IsolatePolicy.developer() + .withCapabilities(IsolatePolicy.Capability.JAVA_INTEROP); + + try (Context context = policy.restrictedContextBuilder( + ExecutionProfile.serverJit(), Set.of("java.util.ArrayList")).build()) { + Value value = context.eval(OresLanguage.ID, """ + import class ArrayList as JArrayList from "java:java.util.ArrayList"; + pub fnc main(): int { + val values = new JArrayList(); + values.add(19); + values.add(23); + return values.size(); + } + """); + assertEquals(2L, value.asLong()); + } + } + + @Test + void aliasesNamedStaticFunctionsWithoutRenamingTheHostMember() { + IsolatePolicy policy = IsolatePolicy.developer() + .withCapabilities(IsolatePolicy.Capability.JAVA_INTEROP); + + try (Context context = policy.restrictedContextBuilder( + ExecutionProfile.serverJit(), Set.of("java.lang.Math")).build()) { + Value value = context.eval(OresLanguage.ID, """ + import fnc abs as jabs from "java:java.lang.Math"; + pub fnc main(): int { return jabs(-42); } + """); + assertEquals(42L, value.asLong()); + } + } + + @Test + void exactAllowlistAndNoAccessInheritanceBlockEscalation() { + IsolatePolicy policy = IsolatePolicy.developer() + .withCapabilities(IsolatePolicy.Capability.JAVA_INTEROP); + + try (Context context = policy.restrictedContextBuilder( + ExecutionProfile.serverJit(), Set.of("java.lang.Math")).build()) { + assertThrows(PolyglotException.class, () -> context.eval(OresLanguage.ID, """ + import class System from "java:java.lang.System"; + pub fnc main(): int { return 1; } + """)); + } + + try (Context context = policy.restrictedContextBuilder( + ExecutionProfile.serverJit(), Set.of("java.lang.Math")).build()) { + assertThrows(PolyglotException.class, () -> context.eval(OresLanguage.ID, """ + import class Math as JMath from "java:java.lang.Math"; + pub fnc main(): any { return JMath.getClass(); } + """)); + } + } + + @Test + void runtimePrivateActorPolicyAlsoStripsJavaInterop() throws Exception { + IsolatePolicy policy = IsolatePolicy.developer() + .withCapabilities(IsolatePolicy.Capability.JAVA_INTEROP); + + try (ActorRuntime runtime = new ActorRuntime(policy)) { + var ref = runtime.spawnPrivate(factoryContext -> { + if (factoryContext.policy().allows(IsolatePolicy.Capability.JAVA_INTEROP)) { + throw new AssertionError("private actor retained JAVA_INTEROP"); + } + return (message, context) -> context.self().stop(); + }); + + ref.send("check"); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(ref.failure().isEmpty()); + } + } + + @Test + void privateActorsCannotUseJavaInteropEvenWhenParentCan() { + var program = TypeChecker.check(Parser.parse(""" + import class ArrayList as JArrayList from "java:java.util.ArrayList"; + + isoactor fnc worker(): void { + val values = new JArrayList(); + return; + } + """)); + + IsolatePolicy policy = IsolatePolicy.developer() + .withCapabilities(IsolatePolicy.Capability.JAVA_INTEROP); + + SecurityException failure = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, policy)); + assertTrue(failure.getMessage().contains("JAVA_INTEROP")); + } +} diff --git a/src/test/java/dev/oreslang/ImportSelectorSyntaxTest.java b/src/test/java/dev/oreslang/ImportSelectorSyntaxTest.java new file mode 100644 index 00000000..563062fa --- /dev/null +++ b/src/test/java/dev/oreslang/ImportSelectorSyntaxTest.java @@ -0,0 +1,165 @@ +package dev.oreslang; + +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Parser; +import dev.oreslang.runtime.LinkedProgramRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ImportSelectorSyntaxTest { + @TempDir + Path temp; + + @Test + void commaAndParenthesizedTypeSelectionsAreEquivalent() { + Ast.ImportDecl comma = Parser.parse(""" + import types X, Y, Z from '../foo'; + pub routine main(): void { return; } + """).imports().getFirst(); + + Ast.ImportDecl parenthesized = Parser.parse(""" + import types (X, Y, Z) from '../foo'; + pub routine main(): void { return; } + """).imports().getFirst(); + + assertEquals(Ast.ImportKind.TYPES, comma.kind()); + assertEquals(List.of("X", "Y", "Z"), comma.names()); + assertEquals(comma, parenthesized); + } + + @Test + void parsesTheCompleteExplicitImportVocabulary() { + Ast.Program program = Parser.parse(""" + import * as package from './dep'; + import module Service from './dep'; + import actor Worker from './dep'; + import class Box from './dep'; + import fnc make from './dep'; + import interface Api from './dep'; + import trait Retryable from './dep'; + import struct Point from './dep'; + import type Identifier from './dep'; + import types A, B, C from './dep'; + + pub routine main(): void { return; } + """); + + assertEquals(List.of( + Ast.ImportKind.ALL, + Ast.ImportKind.MODULE, + Ast.ImportKind.ACTOR, + Ast.ImportKind.CLASS, + Ast.ImportKind.FUNCTION, + Ast.ImportKind.INTERFACE, + Ast.ImportKind.TRAIT, + Ast.ImportKind.STRUCT, + Ast.ImportKind.TYPE, + Ast.ImportKind.TYPES), + program.imports().stream().map(Ast.ImportDecl::kind).toList()); + } + + @Test + void linkerDistinguishesActorClassInterfaceAndTypeSelections() throws Exception { + Path child = temp.resolve("models.ores"); + Path main = temp.resolve("main.ores"); + + Files.writeString(child, """ + define module Service + pub fnc value(): int { return 1; } + end + + shared actor Worker { + pub fnc value(): int { return 2; } + } + + define class Box as + end + + pub interface Api { + fnc value() => int; + } + + pub interface ExtraApi { + fnc value() => int; + } + + type Identifier = int; + type OtherIdentifier = int; + """); + + Files.writeString(main, """ + import module Service from './models'; + import actor Worker from './models'; + import class Box from './models'; + import interface Api from './models'; + import type Identifier from './models'; + import types ExtraApi, OtherIdentifier from './models'; + + pub routine main(): void { return; } + """); + + LinkedProgramRunner.validate(main); + } + + @Test + void classAndActorSelectorsDoNotAliasEachOther() throws Exception { + Path child = temp.resolve("actors.ores"); + Files.writeString(child, """ + shared actor Worker { + pub fnc value(): int { return 1; } + } + + define class Box as + end + """); + + Path actorAsClass = temp.resolve("actor-as-class.ores"); + Files.writeString(actorAsClass, """ + import class Worker from './actors'; + pub routine main(): void { return; } + """); + + Path classAsActor = temp.resolve("class-as-actor.ores"); + Files.writeString(classAsActor, """ + import actor Box from './actors'; + pub routine main(): void { return; } + """); + + assertThrows(IllegalArgumentException.class, () -> LinkedProgramRunner.validate(actorAsClass)); + assertThrows(IllegalArgumentException.class, () -> LinkedProgramRunner.validate(classAsActor)); + } + + @Test + void importedActorClassIsNotExposedAsAnUnspawnableRuntimeValue() throws Exception { + Path child = temp.resolve("worker.ores"); + Path main = temp.resolve("main.ores"); + + Files.writeString(child, """ + shared actor Worker { + pub fnc value(): int { return 1; } + } + """); + + Files.writeString(main, """ + import actor Worker from './worker'; + + pub routine main(): void { + val selected = Worker; + return; + } + """); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> LinkedProgramRunner.validate(main)); + assertTrue(failure.getMessage().contains("unknown name 'Worker'"), failure.getMessage()); + } +} diff --git a/src/test/java/dev/oreslang/IncrementalFunctorStaticTest.java b/src/test/java/dev/oreslang/IncrementalFunctorStaticTest.java new file mode 100644 index 00000000..91566134 --- /dev/null +++ b/src/test/java/dev/oreslang/IncrementalFunctorStaticTest.java @@ -0,0 +1,372 @@ +package dev.oreslang; + +import dev.oreslang.compiler.IncrementalCompiler; +import dev.oreslang.runtime.ExecutionProfile; +import dev.oreslang.runtime.HotReloadManager; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +final class IncrementalFunctorStaticTest { + @Test + void namespacesAndModulesAreFlat() { + var program = TypeChecker.check(Parser.parse(""" + namespace payments; + + define module api + pub fnc ping(): int { return 1; } + end + """)); + assertEquals("payments", program.namespace()); + assertEquals("api", program.modules().getFirst().name()); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + namespace company.payments; + fnc x(): void { return; } + """)); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define module company.payments + end + """)); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define module outer + define module inner + end + end + """)); + } + + @Test + void eachFileIsAnIncrementalCodeUnitAndDependentsInvalidateTransitively() { + IncrementalCompiler compiler = new IncrementalCompiler(); + Map firstSources = new LinkedHashMap<>(); + firstSources.put("math.ores", """ + namespace mathpkg; + pub fnc answer(): int { return 42; } + """); + firstSources.put("app.ores", """ + import fnc {answer} from './math.ores'; + pub fnc main(): void { return; } + """); + firstSources.put("cli.ores", """ + import fnc {main} from './app.ores'; + pub fnc launch(): void { return; } + """); + firstSources.put("unrelated.ores", """ + pub fnc untouched(): int { return 7; } + """); + + var first = compiler.compile(firstSources); + assertEquals(4, first.rebuiltUnits().size()); + assertEquals("mathpkg", first.units().get("math.ores").packageId()); + assertEquals("app", first.units().get("app.ores").packageId()); + + var second = compiler.compile(firstSources); + assertEquals(0, second.rebuiltUnits().size()); + assertEquals(4, second.reusedUnits().size()); + + Map changed = new LinkedHashMap<>(firstSources); + changed.put("math.ores", """ + namespace mathpkg; + pub fnc answer(): int { return 43; } + """); + + var third = compiler.compile(changed); + assertTrue(third.rebuilt("math.ores")); + assertTrue(third.reused("app.ores"), "implementation-only dependency edits must not rebuild importers"); + assertTrue(third.reused("cli.ores"), "implementation-only edits must stay local through the dependency graph"); + assertTrue(third.reused("unrelated.ores")); + + Map abiChanged = new LinkedHashMap<>(changed); + abiChanged.put("math.ores", """ + namespace mathpkg; + pub fnc answer(): String { return "43"; } + """); + + var fourth = compiler.compile(abiChanged); + assertTrue(fourth.rebuilt("math.ores")); + assertTrue(fourth.rebuilt("app.ores"), "public ABI changes must invalidate direct importers"); + assertTrue(fourth.rebuilt("cli.ores"), "ABI changes must conservatively invalidate the transitive reverse-import closure"); + assertTrue(fourth.reused("unrelated.ores")); + } + + @Test + void callableKindAndActorKindParticipateInAbiInvalidation() { + IncrementalCompiler compiler = new IncrementalCompiler(); + Map initial = Map.of( + "service.ores", """ + pub fnc work(int value): int { + return value + 1; + } + """, + "consumer.ores", """ + import * as service from "./service.ores"; + pub routine main(): void { + stdio.stdout.write(service.work(1)); + return; + } + """); + + compiler.compile(initial); + + Map routineChanged = Map.of( + "service.ores", """ + pub routine work(int value): int { + return value + 1; + } + """, + "consumer.ores", initial.get("consumer.ores")); + var routineResult = compiler.compile(routineChanged); + assertTrue(routineResult.rebuilt("service.ores")); + assertTrue(routineResult.rebuilt("consumer.ores"), + "fnc -> routine changes reifiability and must invalidate dependents"); + + Map actorChanged = Map.of( + "service.ores", """ + pub actor fnc work(int value): int { + return value + 1; + } + """, + "consumer.ores", initial.get("consumer.ores")); + var actorResult = compiler.compile(actorChanged); + assertTrue(actorResult.rebuilt("service.ores")); + assertTrue(actorResult.rebuilt("consumer.ores"), + "actor-kind changes dispatch semantics and must invalidate dependents"); + } + + @Test + void inferredPublicBindingsParticipateInAbiInvalidation() { + IncrementalCompiler compiler = new IncrementalCompiler(); + Map first = Map.of( + "config.ores", "pub val setting = 1;", + "consumer.ores", """ + import * as config from "./config.ores"; + pub routine main(): void { return; } + """); + compiler.compile(first); + + Map changed = Map.of( + "config.ores", "pub val setting = \"one\";", + "consumer.ores", first.get("consumer.ores")); + var result = compiler.compile(changed); + + assertTrue(result.rebuilt("config.ores")); + assertTrue(result.rebuilt("consumer.ores")); + } + + @Test + void compiledUnitsCanStageDirectlyAsIndependentHotReloadGenerations() { + IncrementalCompiler compiler = new IncrementalCompiler(); + var build = compiler.compile(Map.of( + "worker.ores", "pub routine main(): void { return; }", + "helper.ores", "pub fnc help(): int { return 1; }")); + var worker = build.units().get("worker.ores"); + var helper = build.units().get("helper.ores"); + + try (HotReloadManager hot = new HotReloadManager(IsolatePolicy.developer(), ExecutionProfile.serverJit())) { + var workerGeneration = hot.load(worker); + var helperGeneration = hot.load(helper); + assertEquals("worker.ores", workerGeneration.codeUnitId()); + assertEquals(worker.sourceDigest(), workerGeneration.sha256()); + assertSame(workerGeneration, hot.active("worker.ores")); + assertSame(helperGeneration, hot.active("helper.ores")); + assertEquals(2, hot.activeGenerations().size()); + assertFalse(workerGeneration.started()); + assertFalse(helperGeneration.started()); + } + } + + @Test + void unresolvedRelativeImportsFailIncrementalCompilation() { + IncrementalCompiler compiler = new IncrementalCompiler(); + assertThrows(IllegalArgumentException.class, () -> compiler.compile(Map.of( + "app.ores", """ + import fnc {missing} from "./missing.ores"; + pub routine main(): void { return; } + """))); + } + + @Test + void staticClassFunctionsUseStaticFncAndDoNotReceiveSelf() throws Exception { + String output = run(""" + define module model + define class Counter as + pub val int value = 9; + + pub static fnc twice(int x): int { + return x * 2; + } + + pub read(): int { + return self.value; + } + end + end + + pub routine main(): void { + val c = new model.Counter(); + stdio.stdout.write(model.Counter.twice(c.read())); + } + """); + assertEquals("18", output); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define class Bad as + static nope(): int { return 1; } + end + """)); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Bad as + static fnc nope(): int { return self.value; } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Bad as + static fnc make(): int { return 1; } + end + fnc bad(): int { + val b = new Bad(); + return b.make(); + } + """))); + } + + @Test + void functionAliasesNestedFunctionTypesAndPipeLambdasWork() throws Exception { + String output = run(""" + type F = typeof fnc() => int; + + fnc find(bool found): F { + return || -> { + return found ? 5 : 6; + }; + } + + fnc sink(): ((bool foo) => void) { + return |foo| -> { + if foo; do + stdio.stdout.write("T"); + else + stdio.stdout.write("F"); + fi + return; + }; + } + + pub routine main(): void { + val F result = find(true); + val ((bool flag) =>void) callback = sink(); + stdio.stdout.write(result()); + callback(true); + } + """); + assertEquals("5T", output); + } + + @Test + void slimArrowIsExecutableWhileFatArrowRemainsTypeLevel() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + type Mapper = typeof fnc(bool value) => int; + fnc make(): ((bool value) => int) { + return |value| -> { + if value; do + return 1; + else + return 0; + fi + }; + } + """))); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc right() -> int { return 1; } + """))); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + fnc wrong(): int { + return || => { return 1; }; + } + """)); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + fnc wrong(): int { + val Fnc x = () -> 1; + return x(); + } + """)); + } + + @Test + void nonVoidLambdasMustReturnOnEveryPath() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + type F = typeof fnc(bool x) => int; + fnc make(): F { + return |x| -> { + if x; do + return 1; + fi + }; + } + """))); + } + + private static String run(String program) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "incremental-functors.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + return output.toString(StandardCharsets.UTF_8); + } + @Test + void fromJsonKeyChangesInvalidateIncrementalImporters() { + IncrementalCompiler compiler = new IncrementalCompiler(); + Map first = Map.of( + "model.ores", """ + define class Payload as + @FromJson("foo") + foo: String; + end + """, + "consumer.ores", """ + import class Payload from "./model.ores"; + pub fnc consume(Payload payload) : void { return; } + """); + + compiler.compile(first); + + Map changed = Map.of( + "model.ores", """ + define class Payload as + @FromJson("external_foo") + foo: String; + end + """, + "consumer.ores", first.get("consumer.ores")); + + var result = compiler.compile(changed); + assertTrue(result.rebuilt("model.ores")); + assertTrue(result.rebuilt("consumer.ores"), + "changing a JSON wire key is an ABI change for importers"); + } + +} diff --git a/src/test/java/dev/oreslang/InitializationBarrierTest.java b/src/test/java/dev/oreslang/InitializationBarrierTest.java new file mode 100644 index 00000000..2cf76fb1 --- /dev/null +++ b/src/test/java/dev/oreslang/InitializationBarrierTest.java @@ -0,0 +1,218 @@ +package dev.oreslang; + +import dev.oreslang.nodes.OresEvalRootNode; +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +final class InitializationBarrierTest { + @Test + void moduleAndRootInitHooksRunBeforeMainAfterAllModulesAreIndexed() throws Exception { + String output = run(""" + define module early + routine init() : void { + stdio.stdout.write("early:"); + later.ping(); + stdio.stdout.write("|"); + return; + } + end + + define module later + pub fnc ping() : void { + stdio.stdout.write("linked"); + return; + } + + fnc init() : void { + stdio.stdout.write("later|"); + return; + } + end + + fnc init() : void { + stdio.stdout.write("root|"); + return; + } + + pub routine main() : void { + stdio.stdout.write("main"); + return; + } + """); + + assertEquals("early:linked|later|root|main", output); + } + + @Test + void initMayBeFncOrRoutineButMustRemainClosedAndSynchronous() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module a + fnc init() : void { return; } + end + + define module b + routine init() : void { return; } + end + + fnc init() : void { return; } + pub routine main() : void { return; } + """))); + + assertInitRejected("fnc init(int value) : void { return; }"); + assertInitRejected("fnc init() : void { return; }"); + assertInitRejected("fnc init() : int { return 1; }"); + assertInitRejected("async fnc init() : void { return; }"); + assertInitRejected("pub fnc init() : void { return; }"); + assertInitRejected("actor fnc init() : void { return; }"); + assertInitRejected("shared actor fnc init() : void { return; }"); + } + + @Test + void moduleInitUsesTheSameLifecycleRestrictions() { + assertInitRejected(""" + define module bad + pub routine init() : void { return; } + end + """); + + assertInitRejected(""" + define module bad + actor routine init() : void { return; } + end + """); + + assertInitRejected(""" + define module bad + routine init(String value) : void { return; } + end + """); + } + + @Test + void initCannotBeCalledDirectlyByGuestCode() { + IllegalArgumentException rootCall = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + fnc init() : void { return; } + + pub routine main() : void { + init(); + return; + } + """))); + assertTrue(rootCall.getMessage().contains("cannot be called directly")); + + IllegalArgumentException moduleCall = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module lifecycle + fnc init() : void { return; } + end + + pub routine main() : void { + lifecycle.init(); + return; + } + """))); + assertTrue(moduleCall.getMessage().contains("cannot be called directly")); + } + + @Test + void mainOnlyCommandCannotBypassInitializationBarrier() throws Exception { + Source source = Source.newBuilder(OresLanguage.ID, """ + fnc init() : void { + return; + } + + pub routine main() : void { + return; + } + """, "barrier.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .build()) { + Value unit = context.parse(source); + + unit.execute(OresEvalRootNode.LINK_ONLY_COMMAND); + + PolyglotException premature = assertThrows( + PolyglotException.class, + () -> unit.execute(OresEvalRootNode.MAIN_ONLY_COMMAND)); + assertTrue(premature.getMessage().contains("main cannot run before successful initialization")); + + unit.execute(OresEvalRootNode.INIT_ONLY_COMMAND); + assertDoesNotThrow(() -> unit.execute(OresEvalRootNode.MAIN_ONLY_COMMAND)); + } + } + + @Test + void initFailureIsTerminalAndMainNeverRuns() throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, """ + fnc init() : void { + stdio.stdout.write("I"); + val values = arr[1]; + val impossible = values[9]; + return; + } + + pub routine main() : void { + stdio.stdout.write("M"); + return; + } + """, "failed-init.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + Value unit = context.parse(source); + unit.execute(OresEvalRootNode.LINK_ONLY_COMMAND); + + assertThrows(PolyglotException.class, () -> unit.execute(OresEvalRootNode.INIT_ONLY_COMMAND)); + assertThrows(PolyglotException.class, () -> unit.execute(OresEvalRootNode.MAIN_ONLY_COMMAND)); + } + + assertEquals("I", output.toString(StandardCharsets.UTF_8)); + } + + private static void assertInitRejected(String source) { + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(source))); + assertTrue( + error.getMessage().contains("init hook"), + () -> "unexpected init diagnostic: " + error.getMessage()); + } + + private static String run(String sourceText) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, sourceText, "init-barrier.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + return output.toString(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/dev/oreslang/InterfaceFncKeywordTest.java b/src/test/java/dev/oreslang/InterfaceFncKeywordTest.java new file mode 100644 index 00000000..f93883d1 --- /dev/null +++ b/src/test/java/dev/oreslang/InterfaceFncKeywordTest.java @@ -0,0 +1,18 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +final class InterfaceFncKeywordTest { + @Test + void interfaceFunctionsRequireFnc() { + String legacy = "f" + "n"; + var error = assertThrows(IllegalArgumentException.class, () -> + Parser.parse(("define interface Api %s run() => void; end").formatted(legacy))); + assertTrue(error.getMessage().contains("fnc")); + assertDoesNotThrow(() -> + Parser.parse("define interface Api fnc run() => void; end")); + } +} diff --git a/src/test/java/dev/oreslang/IsolationHotReloadTest.java b/src/test/java/dev/oreslang/IsolationHotReloadTest.java new file mode 100644 index 00000000..348f163a --- /dev/null +++ b/src/test/java/dev/oreslang/IsolationHotReloadTest.java @@ -0,0 +1,232 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.runtime.CapabilityChecker; +import dev.oreslang.runtime.ExecutionProfile; +import dev.oreslang.runtime.HotReloadManager; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +final class IsolationHotReloadTest { + @Test + void executionProfilesCoverJitAotAndHybrid() { + assertTrue(ExecutionProfile.serverJit().guestJitAllowed()); + assertFalse(ExecutionProfile.serverJit().hostAheadOfTime()); + + ExecutionProfile hybrid = ExecutionProfile.serverHybrid(); + assertTrue(hybrid.guestJitAllowed()); + assertTrue(hybrid.hostAheadOfTime()); + + ExecutionProfile ios = ExecutionProfile.mobileAot(ExecutionProfile.Platform.IOS); + assertTrue(ios.hostAheadOfTime()); + assertFalse(ios.guestJitAllowed()); + assertTrue(ios.supportsSourceHotReload()); + + assertThrows(IllegalArgumentException.class, + () -> new ExecutionProfile(ExecutionProfile.Mode.JIT, ExecutionProfile.Platform.IOS)); + } + + @Test + void capabilityAdmissionRejectsForbiddenApiBeforeGuestExecution() { + var program = TypeChecker.check(Parser.parse(""" + pub routine main(): void { + stdio.stdout.write(process.context_id); + } + """)); + + SecurityException denied = assertThrows(SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.strictFaas())); + assertTrue(denied.getMessage().contains("PROCESS_INFO")); + + assertDoesNotThrow(() -> CapabilityChecker.check(program, + IsolatePolicy.strictFaas().withCapabilities(IsolatePolicy.Capability.PROCESS_INFO))); + } + + @Test + void runtimeCapabilityCheckCannotBeBypassedByFacadeDispatch() throws Exception { + IsolatePolicy noOutput = new IsolatePolicy(Set.of(), 64L * 1024 * 1024, 32, Duration.ofSeconds(5)); + Source source = Source.newBuilder(OresLanguage.ID, """ + pub routine main(): void { + stdio.stdout.write("forbidden"); + } + """, "denied.ores").mimeType(OresLanguage.MIME_TYPE).buildLiteral(); + + RuntimeException error = assertThrows(RuntimeException.class, () -> { + try (Context context = noOutput.restrictedContextBuilder(ExecutionProfile.serverJit()).build()) { + context.eval(source); + } + }); + assertTrue(error.getMessage().contains("STDOUT")); + } + + @Test + void hotReloadCreatesDistinctVersionedContextsWithoutFfi() { + IsolatePolicy policy = IsolatePolicy.developer(); + try (HotReloadManager hot = new HotReloadManager(policy, ExecutionProfile.serverJit())) { + var first = hot.load("v1.ores", """ + pub routine main(): void { return; } + """); + var second = hot.load("v2.ores", """ + pub routine main(): void { + val version = 2; + return; + } + """); + + assertNotEquals(first.id(), second.id()); + assertNotEquals(first.sha256(), second.sha256()); + assertNotSame(first.context(), second.context()); + assertEquals(second.id(), hot.active().id()); + assertEquals(2, hot.liveGenerations()); + + hot.retire(first.id()); + assertEquals(1, hot.liveGenerations()); + } + } + + @Test + void hotLoadStagesWithoutRunningMainUntilExplicitStart() { + IsolatePolicy policy = IsolatePolicy.developer(); + try (HotReloadManager hot = new HotReloadManager(policy, ExecutionProfile.serverJit())) { + var generation = hot.load("staged.ores", """ + pub routine main(): void { + val values = arr[1]; + val boom = values[99]; + return; + } + """); + assertFalse(generation.started()); + assertThrows(RuntimeException.class, generation::start); + assertTrue(generation.closed()); + } + } + + @Test + void hotReloadRequiresExplicitCapability() { + assertThrows(SecurityException.class, + () -> new HotReloadManager(IsolatePolicy.strictFaas(), ExecutionProfile.serverJit())); + } + + @Test + void allExplicitStructuralParameterSpellingsWork() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + pub interface Bar { + marker: 'brand' + } + + pub interface Foo extends Bar { + markerBrand: 'marking/branding' + } + + fnc first(y structural Foo): void { + return; + } + + @AllowStructural(y) + fnc second(y Foo): void { + return; + } + + fnc third(@Structural Foo y): void { + return; + } + + pub routine main(): void { + val branded = obj{marker: "brand", markerBrand: "marking/branding"}; + first(branded); + second(branded); + third(branded); + return; + } + """))); + } + + @Test + void structuralPermissionIsNotImplicit() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub interface Foo { + marker: 'brand' + } + + fnc nominal(Foo y): void { return; } + + pub routine main(): void { + val branded = obj{marker: "brand"}; + nominal(branded); + return; + } + """))); + } + + @Test + void instanceMethodValuesRequireExplicitLambdaAndSelfCannotBeRebound() throws Exception { + IllegalArgumentException extracted = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Box as + val int value; + + pub get(): int { + return self.value; + } + end + + fnc bad(Box box): void { + val Fnc callback = box.get; + } + """))); + assertTrue(extracted.getMessage().contains("direct-call-only")); + + String output = run(""" + define class Box as + val int value; + + pub get(): int { + return self.value; + } + end + + pub routine main(): void { + val box = new Box(17); + val Fnc callback = || -> { + return box.get(); + }; + stdio.stdout.write(callback()) + } + """); + assertEquals("17", output); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Box as + pub bad(): void { + self = new Box(); + return; + } + end + """))); + } + + private static String run(String program) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "receiver.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + try (Context context = IsolatePolicy.developer() + .restrictedContextBuilder(ExecutionProfile.serverJit()) + .out(output) + .build()) { + context.eval(source); + } + return output.toString(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/dev/oreslang/LanguageHardeningTest.java b/src/test/java/dev/oreslang/LanguageHardeningTest.java new file mode 100644 index 00000000..e1d45fa2 --- /dev/null +++ b/src/test/java/dev/oreslang/LanguageHardeningTest.java @@ -0,0 +1,731 @@ +package dev.oreslang; + +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Lexer; +import dev.oreslang.parser.Parser; +import dev.oreslang.parser.Token; +import dev.oreslang.runtime.CapabilityChecker; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.types.TypeChecker; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +final class LanguageHardeningTest { + @Test + void parsesAllRequestedImportForms() { + Ast.Program program = Parser.parse(""" + import module foo from "../xyz"; + import module {bar, baz} from '../xyz'; + import class {x} from '../xyz'; + import fnc * as funcs from '../xyz'; + import * as everything from './xyz'; + + define module app + pub fnc main(): void { return; } + end + """); + + assertEquals(5, program.imports().size()); + assertEquals(Ast.ImportKind.MODULE, program.imports().getFirst().kind()); + assertEquals("foo", program.imports().getFirst().names().getFirst()); + assertEquals("funcs", program.imports().get(3).namespace()); + assertEquals(Ast.ImportKind.ALL, program.imports().get(4).kind()); + assertEquals("everything", program.imports().get(4).namespace()); + } + + @Test + void fiIsARealDistinctTokenAndEndDoesNotCloseIf() { + var tokens = new Lexer("if true; do return; fi end").scan(); + assertTrue(tokens.stream().anyMatch(t -> t.type() == Token.Type.FI)); + assertTrue(tokens.stream().anyMatch(t -> t.type() == Token.Type.END)); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define module app + fnc main(): void { + if true; do + return; + end + } + end + """)); + } + + @Test + void modulesAreTypedNamespacesAndCanAdhereToInterfaces() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module contracts + define interface MathApi + fnc add(int a, int b) => int; + end + end + + @AdheresTo(contracts.MathApi) + define module math + pub fnc add(int a, int b): int { return a + b; } + end + + define module app + pub fnc main(): void { + val answer = math.add(40, 2); + stdio.println(answer); + return; + } + end + """))); + } + + @Test + void directOnlyRoutineStillParticipatesInModuleCallableContracts() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module contracts + define interface Api + fnc ping(int value) => int; + end + end + + @AdheresTo(contracts.Api) + define module service + pub routine ping(int value): int { + return value + 1; + } + end + + fnc callDirectly(): int { + return service.ping(41); + } + """))); + + IllegalArgumentException extracted = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module service + pub routine ping(int value): int { + return value + 1; + } + end + + fnc bad(): void { + val Fnc callback = service.ping; + } + """))); + assertTrue(extracted.getMessage().contains("direct-call-only")); + } + + @Test + void interfaceMethodsAreDirectOnlyAndInheritedGenericCallsStayTyped() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module model + define interface Base + fnc apply(T value) => T; + end + + define interface IntApi extends Base + end + + fnc invoke(IntApi api): int { + return api.apply(41); + } + end + """))); + + IllegalArgumentException extracted = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module model + define interface Base + fnc apply(T value) => T; + end + + define interface IntApi extends Base + end + + fnc bad(IntApi api): void { + val Fnc callback = api.apply; + return; + } + end + """))); + assertTrue(extracted.getMessage().contains("interface method")); + assertTrue(extracted.getMessage().contains("direct-call-only")); + + IllegalArgumentException badArgument = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module model + define interface Base + fnc apply(T value) => T; + end + + define interface IntApi extends Base + end + + fnc bad(IntApi api): int { + return api.apply("wrong"); + } + end + """))); + assertTrue(badArgument.getMessage().contains("argument 1")); + } + + @Test + void structuralMethodsAreDirectOnlyButDirectCallsRemainTyped() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module model + define interface Api + fnc apply(int value) => int; + end + + fnc invoke(@Structural Api api): int { + return api.apply(41); + } + end + """))); + + IllegalArgumentException extracted = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module model + define interface Api + fnc apply(int value) => int; + end + + fnc bad(@Structural Api api): void { + val Fnc callback = api.apply; + return; + } + end + """))); + assertTrue(extracted.getMessage().contains("structural method")); + assertTrue(extracted.getMessage().contains("direct-call-only")); + + IllegalArgumentException badArgument = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module model + define interface Api + fnc apply(int value) => int; + end + + fnc bad(@Structural Api api): int { + return api.apply("wrong"); + } + end + """))); + assertTrue(badArgument.getMessage().contains("argument 1")); + } + + @Test + void genericStructuralMethodsInferAndSpecializeWithoutUnknownEscape() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module model + define interface GenericApi + fnc identity(T value) => T; + end + + fnc infer(@Structural GenericApi api): int { + return api.identity(41); + } + + fnc explicit(@Structural GenericApi api): int { + return api.identity(41); + } + end + """))); + + IllegalArgumentException explicitMismatch = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module model + define interface GenericApi + fnc identity(T value) => T; + end + + fnc bad(@Structural GenericApi api): int { + return api.identity("wrong"); + } + end + """))); + assertTrue(explicitMismatch.getMessage().contains("argument 1")); + + IllegalArgumentException inferredReturnMismatch = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module model + define interface GenericApi + fnc identity(T value) => T; + end + + fnc bad(@Structural GenericApi api): int { + return api.identity("wrong"); + } + end + """))); + assertTrue(inferredReturnMismatch.getMessage().contains("return value")); + } + + @Test + void fieldAndInstanceMethodNamesCannotCollideLocallyOrThroughInheritance() { + IllegalArgumentException local = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Bad as + pub val int value = 1; + + pub value(): int { + return 2; + } + end + """))); + assertTrue(local.getMessage().contains("both a field and an instance method")); + + IllegalArgumentException inherited = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class HasField as + pub val int value = 1; + end + + define class HasMethod as + pub value(): int { + return 2; + } + end + + define class Bad extends HasField, HasMethod as + end + """))); + assertTrue(inherited.getMessage().contains("both a field and an instance method")); + + IllegalArgumentException iface = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define interface HasField + val int value; + end + + define interface HasMethod + fnc value() => int; + end + + define interface Bad extends HasField, HasMethod + end + """))); + assertTrue(iface.getMessage().contains("both a field and a method")); + } + + @Test + void storageFieldNamesMustBeUniqueAcrossInheritanceButDiamondsMayShareOneAncestorSlot() { + IllegalArgumentException shadow = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Parent as + private val int id = 1; + end + + define class Child extends Parent as + private val int id = 2; + end + """))); + assertTrue(shadow.getMessage().contains("must be unique across inheritance")); + + IllegalArgumentException siblingCollision = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Left as + private val int id = 1; + end + + define class Right as + private val int id = 2; + end + + define class Combined extends Left, Right as + end + """))); + assertTrue(siblingCollision.getMessage().contains("must be unique across inheritance")); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define class Root as + private val int id = 1; + end + + define class Left extends Root as + end + + define class Right extends Root as + end + + define class Diamond extends Left, Right as + end + """))); + } + + @Test + void moduleAliasesPreserveFncReifiabilityButKeepRoutinesDirectOnly() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module service + pub fnc transform(int value): int { + return value + 1; + } + + pub routine direct_only(int value): int { + return value + 2; + } + end + + fnc good(): int { + val alias = service; + val Fnc callback = alias.transform; + return callback(40) + alias.direct_only(0); + } + """))); + + IllegalArgumentException routineValue = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module service + pub routine direct_only(int value): int { + return value + 1; + } + end + + fnc bad(): void { + val alias = service; + val Fnc callback = alias.direct_only; + return; + } + """))); + assertTrue(routineValue.getMessage().contains("direct-call-only")); + } + + @Test + void moduleRuntimeValueNamespaceRejectsCrossCategoryNameCollisions() { + IllegalArgumentException fncVsBinding = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module bad + fnc value(): int { + return 1; + } + + val int value = 2; + end + """))); + assertTrue(fncVsBinding.getMessage().contains("runtime value namespace")); + + IllegalArgumentException classVsRoutine = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module bad + define class Worker as + end + + routine Worker(): void { + return; + } + end + """))); + assertTrue(classVsRoutine.getMessage().contains("runtime value namespace")); + } + + @Test + void moduleAdherenceRejectsMissingExports() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module contracts + define interface Api + fnc ping() => int; + end + end + + @AdheresTo(contracts.Api) + define module broken + pub fnc pong(): int { return 1; } + end + """))); + assertTrue(error.getMessage().contains("does not adhere")); + } + + @Test + void classesSupportMultipleParentsAndMultipleInterfaces() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module model + define interface AApi + fnc a() => int; + end + define interface BApi + fnc b() => int; + end + + define class A as + pub a(): int { return 1; } + end + define class B as + pub b(): int { return 2; } + end + + define class Combined extends A, B implements AApi, BApi as + end + + define class ObjectChild extends Object as + end + define class ListChild extends List as + end + end + """))); + } + + @Test + void inheritanceCyclesAndConflictingDiamondsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module m + define class A extends B as + end + define class B extends A as + end + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module m + define class A as + pub val int id = 1; + end + define class B as + pub val String id = "b"; + end + define class C extends A, B as + end + end + """))); + } + + @Test + void objArrTupleIndexAndLetAssignmentAreStaticallyChecked() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + pub fnc main(): void { + val person = obj{name: "ore", age: 1}; + val values = arr[10, 20, 30]; + val first = values[0]; + [const left, let right] = (1, "two"); + let n = first; + n = 99; + stdio.println(person.name); + stdio.println(right); + return; + } + end + """))); + } + + @Test + void valAndConstCannotBeReassigned() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc f(): void { + val x = 1; + x = 2; + return; + } + end + """))); + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc f(): void { + const x = 1; + x = 2; + return; + } + end + """))); + } + + @Test + void nullIsForbiddenAsAValueOrStandaloneTypeButOptionNullIsExplicitlyAllowed() { + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define module app + fnc bad(): String { return null; } + end + """)); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc bad(null x): void { return; } + end + """))); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc keep(Option x): Option { return x; } + fnc explicit_marker(Option x): Option { return x; } + fnc some_value(): Option { return Some(1); } + fnc no_value(): Option { return None; } + end + """))); + } + + @Test + void nonVoidFunctionsMustReturnOnEveryControlFlowPath() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc incomplete(bool flag): int { + if flag; do + return 1; + fi + } + end + """))); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc complete(bool flag): int { + if flag; do + return 1; + else + return 2; + fi + } + end + """))); + } + + @Test + void duplicateImportBindingsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + import module foo from './a'; + import class {foo} from './b'; + define module app + end + """))); + } + @Test + void capabilityAdmissionTraversesMatchSwitchAndDestructureLoops() { + Ast.Program inMatch = TypeChecker.check(Parser.parse(""" + fnc hidden(int tag): void { + match first tag + _ -> { + val lock = SharedMutex.new(1); + return; + } + end + } + """)); + assertThrows( + SecurityException.class, + () -> CapabilityChecker.check( + inMatch, + IsolatePolicy.strictFaas())); + + Ast.Program inSwitch = TypeChecker.check(Parser.parse(""" + fnc hidden(int tag): void { + switch tag + case 1 -> { + val lock = SharedMutex.new(1); + } + default -> { + return; + } + end + return; + } + """)); + assertThrows( + SecurityException.class, + () -> CapabilityChecker.check( + inSwitch, + IsolatePolicy.strictFaas())); + + Ast.Program inDestructureLoop = TypeChecker.check(Parser.parse(""" + fnc hidden(): void { + for [left, right] of arr[arr[1, 2]] { + val lock = SharedMutex.new(left + right); + } + return; + } + """)); + assertThrows( + SecurityException.class, + () -> CapabilityChecker.check( + inDestructureLoop, + IsolatePolicy.strictFaas())); + } + + @Test + void staticClassGenericCannotHideInsideSelectArm() { + IllegalArgumentException rejected = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Token as + end + + define class Box as + pub static fnc bad(): void { + val Channel input = Channel.new(1); + + try select { + case readch input: val value + val token = new Token(); + default: + stdio.println("not ready"); + } + + return; + } + end + """))); + + assertTrue( + rejected.getMessage().contains("static") + && rejected.getMessage().contains("T"), + rejected::getMessage); + } + + @Test + void strictFaasRejectsSharedActorDeclarationsAtAdmission() { + Ast.Program sharedActor = TypeChecker.check(Parser.parse(""" + shared actor Account { + let balance = 100; + + pub fnc current(): int { + return self.balance; + } + } + """)); + + assertThrows(SecurityException.class, () -> + CapabilityChecker.check(sharedActor, IsolatePolicy.strictFaas())); + assertDoesNotThrow(() -> + CapabilityChecker.check(sharedActor, IsolatePolicy.developer())); + } + + + +@Test + void destructureDiscardNeverBecomesAReadableBinding() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc f(): int { + [_, const value] = (1, 2); + return _; + } + end + """))); + + assertTrue(error.getMessage().contains("unknown name '_'")); + } + +@Test + void explicitBindingKindOnUnderscoreIsAlsoDiscarded() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc f(): int { + [const _, let value] = (1, 2); + [let _, const next] = (3, 4); + return value + next; + } + end + """))); + } + +} diff --git a/src/test/java/dev/oreslang/MixedSourceInteropTest.java b/src/test/java/dev/oreslang/MixedSourceInteropTest.java new file mode 100644 index 00000000..f43b8108 --- /dev/null +++ b/src/test/java/dev/oreslang/MixedSourceInteropTest.java @@ -0,0 +1,417 @@ +package dev.oreslang; + +import dev.oreslang.interop.MixedSourceUnit; +import dev.oreslang.parser.Parser; +import dev.oreslang.runtime.ExecutionProfile; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.runtime.LinkedProgramRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +final class MixedSourceInteropTest { + @TempDir Path temp; + + @Test + void splitsOresJavaIslandsWithoutInventingAnOresModule() { + MixedSourceUnit unit = MixedSourceUnit.parse("/tmp/demo.ores", "demo.ores", """ + pub fnc main(): void { + stdio.println(Hashing.decorate("hello")); + return; + } + + java { + final class Hashing { + public static String decorate(String value) { + return value + "}"; + } + } + } + """); + + assertEquals(MixedSourceUnit.PrimaryLanguage.ORES, unit.primaryLanguage()); + assertEquals(1, unit.javaBindings().size()); + assertEquals("Hashing", unit.javaBindings().getFirst().simpleName()); + assertEquals(MixedSourceUnit.IslandKind.DECLARATION, unit.foreignIslands().getFirst().kind()); + assertFalse(unit.oresSource().contains("module demo")); + assertDoesNotThrow(() -> Parser.parse(unit.oresSource())); + } + + @Test + void splitsJavaOresIslandsWhileIgnoringBracesInsideJavaStrings() { + MixedSourceUnit unit = MixedSourceUnit.parse("/tmp/MixedDemo.java", "MixedDemo.java", """ + public final class MixedDemo { + static String brace() { return "}"; } + + ores { + pub fnc add(int a, int b): int { + return a + b; + } + } + } + """); + + assertEquals(MixedSourceUnit.PrimaryLanguage.JAVA, unit.primaryLanguage()); + assertEquals(1, unit.foreignIslands().size()); + assertTrue(unit.javaSource().contains("public final class MixedDemo")); + assertFalse(unit.javaSource().contains("pub fnc add")); + assertDoesNotThrow(() -> Parser.parse(unit.oresSource())); + } + + @Test + void oresJavaIslandCannotDeclareItsOwnPackage() { + assertThrows(IllegalArgumentException.class, () -> MixedSourceUnit.parse( + "/tmp/demo.ores", "demo.ores", """ + pub fnc main(): void { return; } + java { + package wrong.identity; + public final class Helper {} + } + """)); + } + + @Test + void oresCallsJavaDeclaredInSameFile() throws Exception { + Path source = temp.resolve("same-file.ores"); + Files.writeString(source, """ + pub fnc main(): void { + stdio.println(Hashing.decorate("hello")); + return; + } + + java { + final class Hashing { + public static String decorate(String value) { + return value + ":java"; + } + } + } + """); + + IsolatePolicy policy = trustedMixedPolicy(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + LinkedProgramRunner.run( + source, + policy, + ExecutionProfile.serverJit(), + Set.of(), + out, + new ByteArrayOutputStream()); + + assertTrue(out.toString(StandardCharsets.UTF_8).contains("hello:java")); + } + + @Test + void javaCallsOresAndPreservesJavaObjectIdentity() throws Exception { + Path source = temp.resolve("MixedDemo.java"); + Files.writeString(source, """ + import java.util.ArrayList; + + public final class MixedDemo { + public static void main(String[] args) { + var values = new ArrayList(); + values.add("java"); + + Object returned = Ores.identity(values); + if (returned != values) { + throw new AssertionError("Java object identity was not preserved"); + } + + int size = Ores.count(values); + if (size != 1) { + throw new AssertionError("Oreslang did not receive the original Java object"); + } + } + + ores { + import class ArrayList as JArrayList from "java:java.util.ArrayList"; + + pub fnc identity(JArrayList value): JArrayList { + return value; + } + + pub fnc count(JArrayList value): int { + return value.size(); + } + } + } + """); + + assertDoesNotThrow(() -> LinkedProgramRunner.run( + source, + trustedMixedPolicy(), + ExecutionProfile.serverJit(), + Set.of("java.util.ArrayList"), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + } + + @Test + void mixedJavaSourceRequiresSeparateTrustedCapability() throws Exception { + Path source = temp.resolve("capability.ores"); + Files.writeString(source, """ + pub fnc main(): void { return; } + java { final class Helper {} } + """); + + IsolatePolicy onlyHostInterop = IsolatePolicy.developer() + .withCapabilities(IsolatePolicy.Capability.JAVA_INTEROP); + + SecurityException denied = assertThrows(SecurityException.class, () -> LinkedProgramRunner.run( + source, + onlyHostInterop, + ExecutionProfile.serverJit(), + Set.of(), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + assertTrue(denied.getMessage().contains("JAVA_SOURCE_INTEROP")); + } + + @Test + void mixedJavaSourceIsJitOnlyUntilJavaIsPrecompiled() throws Exception { + Path source = temp.resolve("mode.ores"); + Files.writeString(source, """ + pub fnc main(): void { return; } + java { final class Helper {} } + """); + + IllegalArgumentException denied = assertThrows(IllegalArgumentException.class, () -> LinkedProgramRunner.run( + source, + trustedMixedPolicy(), + new ExecutionProfile(ExecutionProfile.Mode.AOT, ExecutionProfile.Platform.SERVER), + Set.of(), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + assertTrue(denied.getMessage().contains("require --mode=jit")); + } + + @Test + void inertJavaDeclarationDoesNotRunByPresence() throws Exception { + Path source = temp.resolve("inert.ores"); + Files.writeString(source, """ + pub fnc main(): void { + stdio.println("ores-main"); + return; + } + + java { + final class Dormant { + static { + if (true) throw new AssertionError("java declaration was initialized eagerly"); + } + } + } + """); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertDoesNotThrow(() -> LinkedProgramRunner.run( + source, + trustedMixedPolicy(), + ExecutionProfile.serverJit(), + Set.of(), + out, + new ByteArrayOutputStream())); + + assertTrue(out.toString(StandardCharsets.UTF_8).contains("ores-main")); + } + + @Test + void doJavaLowersToRunnableAndExecutesExactlyAtStatementPosition() throws Exception { + Path source = temp.resolve("do-java.ores"); + Files.writeString(source, """ + java { + final class State { + private static int count = 0; + public static void increment() { count++; } + public static int count() { return count; } + } + } + + pub fnc main(): void { + stdio.println(State.count()); + + do java { + State.increment(); + } + + stdio.println(State.count()); + return; + } + """); + + MixedSourceUnit parsed = MixedSourceUnit.parse(source, Files.readString(source)); + assertEquals(2, parsed.foreignIslands().size()); + assertEquals(MixedSourceUnit.IslandKind.DECLARATION, parsed.foreignIslands().get(0).kind()); + assertEquals(MixedSourceUnit.IslandKind.EXECUTION, parsed.foreignIslands().get(1).kind()); + assertTrue(parsed.javaSources().stream().anyMatch(s -> s.sourceText().contains("implements java.lang.Runnable"))); + assertTrue(parsed.oresSource().contains(".run();")); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + LinkedProgramRunner.run( + source, + trustedMixedPolicy(), + ExecutionProfile.serverJit(), + Set.of(), + out, + new ByteArrayOutputStream()); + + String output = out.toString(StandardCharsets.UTF_8).replace("\r\n", "\n"); + assertTrue(output.contains("0\n1\n"), output); + } + + @Test + void declarationJavaInsideExecutableOresBodyIsRejected() { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> + MixedSourceUnit.parse("/tmp/bad.ores", "bad.ores", """ + pub fnc main(): void { + java { + final class Hidden {} + } + return; + } + """)); + assertTrue(failure.getMessage().contains("use do java")); + } + + @Test + void doJavaAtDeclarationScopeIsRejected() { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> + MixedSourceUnit.parse("/tmp/bad.ores", "bad.ores", """ + do java { + System.out.println("not a statement scope"); + } + + pub fnc main(): void { return; } + """)); + assertTrue(failure.getMessage().contains("inside an Oreslang callable")); + } + + @Test + void doJavaDoesNotImplicitlyCaptureOresLocals() throws Exception { + Path source = temp.resolve("no-capture.ores"); + Files.writeString(source, """ + pub fnc main(): void { + val ores_value = 42; + + do java { + if (ores_value != 42) { + throw new AssertionError("unreachable"); + } + } + + return; + } + """); + + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> + LinkedProgramRunner.run( + source, + trustedMixedPolicy(), + ExecutionProfile.serverJit(), + Set.of(), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + + assertTrue(failure.getMessage().contains("mixed Java source compilation failed")); + assertTrue(failure.getMessage().contains("ores_value")); + } + + @Test + void doJavaCanCallExportedOresThroughGeneratedFacade() throws Exception { + Path source = temp.resolve("do-java-calls-ores.ores"); + Files.writeString(source, """ + pub fnc bridge_hit(): void { + stdio.println("bridge-hit"); + return; + } + + pub fnc main(): void { + do java { + Ores.bridge_hit(); + } + return; + } + """); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertDoesNotThrow(() -> LinkedProgramRunner.run( + source, + trustedMixedPolicy(), + ExecutionProfile.serverJit(), + Set.of(), + out, + new ByteArrayOutputStream())); + + assertTrue(out.toString(StandardCharsets.UTF_8).contains("bridge-hit")); + } + + @Test + void doJavaCheckedExceptionsMustBeHandledInsideRunnableRun() throws Exception { + Path source = temp.resolve("checked-exception.ores"); + Files.writeString(source, """ + pub fnc main(): void { + do java { + throw new java.io.IOException("checked"); + } + return; + } + """); + + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> + LinkedProgramRunner.run( + source, + trustedMixedPolicy(), + ExecutionProfile.serverJit(), + Set.of(), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + + assertTrue(failure.getMessage().contains("mixed Java source compilation failed")); + assertTrue(failure.getMessage().contains("IOException") + || failure.getMessage().contains("must be caught") + || failure.getMessage().contains("unreported exception")); + } + + @Test + void doJavaRunCannotReturnAValueBecauseRunnableRunIsVoid() throws Exception { + Path source = temp.resolve("void-run.ores"); + Files.writeString(source, """ + pub fnc main(): void { + do java { + return 42; + } + return; + } + """); + + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> + LinkedProgramRunner.run( + source, + trustedMixedPolicy(), + ExecutionProfile.serverJit(), + Set.of(), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + + assertTrue(failure.getMessage().contains("mixed Java source compilation failed")); + } + + @Test + void adversarialPolicyCannotAcquireJavaSourceInterop() { + assertThrows(IllegalArgumentException.class, () -> + IsolatePolicy.strictFaas().withCapabilities(IsolatePolicy.Capability.JAVA_SOURCE_INTEROP)); + } + + private static IsolatePolicy trustedMixedPolicy() { + return IsolatePolicy.developer().withCapabilities( + IsolatePolicy.Capability.JAVA_INTEROP, + IsolatePolicy.Capability.JAVA_SOURCE_INTEROP); + } +} diff --git a/src/test/java/dev/oreslang/ModulesNamespacesCallableSemanticsTest.java b/src/test/java/dev/oreslang/ModulesNamespacesCallableSemanticsTest.java new file mode 100644 index 00000000..25c74a41 --- /dev/null +++ b/src/test/java/dev/oreslang/ModulesNamespacesCallableSemanticsTest.java @@ -0,0 +1,114 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +final class ModulesNamespacesCallableSemanticsTest { + + @Test + void namespaceModulesRoutineFncAndLambdaWorkTogether() throws Exception { + String output = run(""" + namespace callable_demo; + + type IntFn = typeof fnc(int value) => int; + + define module math + pub fnc factorial(int n): int { + if n <= 1; do + return 1; + else + return n * factorial(n - 1); + fi + } + end + + define module closures + pub fnc makeAdder(int base): IntFn { + return |value| -> { + return base + value; + }; + } + end + + pub routine main(): void { + val IntFn addTen = closures.makeAdder(10); + stdio.stdout.write(math.factorial(5)); + stdio.stdout.write("|"); + stdio.stdout.write(addTen(7)); + return; + } + """); + + assertEquals("120|17", output); + } + + @Test + void routineAndFncMayBothRecurse() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + routine boot(bool finished): void { + if finished; do + return; + else + boot(true); + return; + fi + } + """))); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc countdown(int n): int { + if n <= 0; do + return 0; + else + return countdown(n - 1); + fi + } + """))); + } + + @Test + void lambdaIsLexicalAndAnonymous() throws Exception { + String output = run(""" + type IntFn = typeof fnc(int value) => int; + + fnc makeAdder(int base): IntFn { + return |value| -> { + return base + value; + }; + } + + pub routine main(): void { + val IntFn addTwo = makeAdder(2); + val IntFn addForty = makeAdder(40); + stdio.stdout.write(addTwo(5)); + stdio.stdout.write("|"); + stdio.stdout.write(addForty(2)); + return; + } + """); + + assertEquals("7|42", output); + } + + private static String run(String program) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "modules-namespaces-callables.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + return output.toString(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/dev/oreslang/MutexLanguageTest.java b/src/test/java/dev/oreslang/MutexLanguageTest.java new file mode 100644 index 00000000..1a85403f --- /dev/null +++ b/src/test/java/dev/oreslang/MutexLanguageTest.java @@ -0,0 +1,987 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.runtime.CapabilityChecker; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +final class MutexLanguageTest { + @Test + void nestedSharedMutexTypesAreRejectedConservatively() { + var program = Parser.parse(""" + define module app + fnc bad(SharedMutex> value): void { + return; + } + end + """); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("shared-safe")); + } + + @Test + void sharedActorFunctionsRejectBlockingSharedMutexLock() { + var program = Parser.parse(""" + pub shared actor fnc worker(SharedMutex mutex): void { + val guard = mutex.lock(); + guard.release(); + return; + } + """); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains( + "actor code cannot use blocking SharedMutex.lock")); + } + + @Test + void sharedActorMethodsRejectBlockingSharedMutexWithLock() { + var program = Parser.parse(""" + shared actor Worker { + pub fnc run(SharedMutex mutex): void { + mutex.with_lock(|value| -> { + return; + }); + return; + } + } + """); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains( + "actor code cannot use blocking SharedMutex.with_lock")); + } + + @Test + void actorCodeMayUseNonblockingSharedMutexOperations() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + pub shared actor fnc try_worker(SharedMutex mutex): void { + val maybe_guard = mutex.try_lock(); + stdio.println(mutex.is_poisoned()); + return; + } + + pub shared actor fnc async_worker(SharedMutex mutex): void { + val future_guard = mutex.lock_async(); + return; + } + """))); + } + + @Test + void sharedMutexAcceptsFullySharedSafeUnionTypes() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc good(SharedMutex value): void { + return; + } + end + """))); + } + + @Test + void sharedMutexRejectsUnionWhenAnyAlternativeIsActorLocal() { + var program = Parser.parse(""" + define module app + fnc bad(SharedMutex> value): void { + return; + } + end + """); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("shared-safe")); + } + + @Test + void sharedMutexSubstitutesGenericsInsideUnionFields() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module model + define class Box as + pub val T | int value; + end + end + + define module app + fnc good(Box box): void { + val shared = SharedMutex.new(box); + stdio.println(shared.is_poisoned()); + return; + } + end + """))); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module model + define class Box as + pub val T | int value; + end + end + + define module app + fnc collapsed(Box box): void { + val shared = SharedMutex.new(box); + stdio.println(shared.is_poisoned()); + return; + } + end + """))); + + var unsafe = Parser.parse(""" + define module model + define class Box as + pub val T | int value; + end + end + + define module app + fnc bad(Box> box): void { + val shared = SharedMutex.new(box); + stdio.println(shared.is_poisoned()); + return; + } + end + """); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(unsafe)); + assertTrue(error.getMessage().contains("shared-safe")); + } + + @Test + void declaredSharedMutexTypesMustAlsoBeSharedSafe() { + var unsafe = Parser.parse(""" + define module app + fnc bad(SharedMutex> value): void { + return; + } + end + """); + IllegalArgumentException unsafeError = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(unsafe)); + assertTrue(unsafeError.getMessage().contains("concrete shared-safe type")); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc good(SharedMutex value): void { + return; + } + end + """))); + } + + @Test + void unconstrainedGenericSharedMutexTypesAreRejectedConservatively() { + var program = Parser.parse(""" + define module app + fnc bad(SharedMutex value): void { + return; + } + end + """); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("concrete shared-safe type")); + } + + @Test + void sharedMutexRejectsActorLocalMutexValues() { + var program = Parser.parse(""" + define module app + fnc bad(): void { + val local = Mutex.new(arr[1, 2, 3]); + val shared = SharedMutex.new(local); + stdio.println(shared.is_poisoned()); + return; + } + end + """); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("shared-safe owned data")); + } + + @Test + void sharedMutexRejectsClosuresAndPendingFutures() { + var closureProgram = Parser.parse(""" + define module app + fnc bad(): void { + val callback = || -> { return; }; + val shared = SharedMutex.new(callback); + stdio.println(shared.is_poisoned()); + return; + } + end + """); + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(closureProgram)); + + var futureProgram = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + async fnc bad(): void { + val mutex = SharedMutex.new(new Counter()); + val pending = mutex.lock_async(); + val nested = SharedMutex.new(pending); + stdio.println(nested.is_poisoned()); + return; + } + end + """); + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(futureProgram)); + } + + @Test + void sharedMutexPreservesParentGenericBindingsDuringSafetyCheck() { + var program = Parser.parse(""" + define module model + define class Parent as + pub val T value; + end + + define class Child extends Parent> as + end + end + + define module app + fnc bad(Child child): void { + val shared = SharedMutex.new(child); + stdio.println(shared.is_poisoned()); + return; + } + end + """); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("shared-safe owned data")); + } + + @Test + void sharedMutexRecursivelyChecksClassFields() { + var program = Parser.parse(""" + define module model + define class UnsafeBox as + pub val Fnc callback = || -> { return; }; + end + end + + define module app + fnc bad(): void { + val shared = SharedMutex.new(new UnsafeBox()); + stdio.println(shared.is_poisoned()); + return; + } + end + """); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("shared-safe owned data")); + } + + @Test + void sharedMutexRequiresExplicitSharedMemoryCapability() { + var program = Parser.parse(""" + define module app + fnc main(): void { + val shared = SharedMutex.new(arr[1, 2, 3]); + stdio.println(shared.is_poisoned()); + return; + } + end + """); + + assertThrows(SecurityException.class, () -> CapabilityChecker.check(program, IsolatePolicy.strictFaas())); + assertDoesNotThrow(() -> CapabilityChecker.check(program, IsolatePolicy.developer())); + } + + @Test + void sharedMutexAcceptsOrdinaryOwnedClassState() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + fnc good(): void { + val shared = SharedMutex.new(new Counter()); + stdio.println(shared.is_poisoned()); + return; + } + end + """))); + } + + @Test + void sharedMutexRejectsActorLocalStateGraph() { + var program = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + fnc bad(): void { + val local = Mutex.new(new Counter()); + val shared = SharedMutex.new(local); + stdio.println(shared.is_poisoned()); + return; + } + end + """); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("SharedMutex requires shared-safe owned data")); + } + + @Test + void mutexGuardTransparentlyProtectsClassStateAndReleasesLexically() throws Exception { + String program = """ + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + pub fnc main(): void { + val mutex = Mutex.new(new Counter()); + val guard = mutex.lock(); + guard.value = guard.value + 2; + stdio.println(guard.value); + guard.release(); + + val again = mutex.lock(); + stdio.println(again.value); + return; + } + end + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "mutex.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.lines().filter("2"::equals).count() >= 2); + } + + @Test + void awaitWhileHoldingGuardIsRejectedStatically() { + var program = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + async fnc bad(): void { + val mutex = Mutex.new(new Counter()); + val guard = mutex.lock(); + await mutex.lock_async(); + guard.value = 1; + return; + } + end + """); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("cannot await while holding a MutexGuard")); + } + + @Test + void withLockProvidesMutableProtectedValue() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + fnc good(): void { + val mutex = Mutex.new(new Counter()); + mutex.with_lock(|counter| -> { + counter.value = counter.value + 1; + return; + }); + return; + } + end + """))); + } + + @Test + void awaitInsideWithLockCriticalSectionIsRejected() { + var program = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + async fnc bad(): void { + val mutex = Mutex.new(new Counter()); + mutex.with_lock(|counter| -> { + await mutex.lock_async(); + return; + }); + return; + } + end + """); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("cannot await while holding a MutexGuard")); + } + + @Test + void guardBearingResultsMustBeBoundAndCannotBeOverwritten() { + var discarded = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + define module app + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + mutex.lock(); + return; + } + end + """); + IllegalArgumentException discardedError = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(discarded)); + assertTrue(discardedError.getMessage().contains("cannot be discarded")); + + var mutableBinding = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + define module app + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + let guard = mutex.lock(); + guard.release(); + return; + } + end + """); + IllegalArgumentException mutableError = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(mutableBinding)); + assertTrue(mutableError.getMessage().contains("cannot use let")); + } + + @Test + void guardBearingValuesCannotCrossArbitraryCallsOrNestedMutexes() { + var callProgram = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + define module app + fnc consume(T value): void { return; } + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + val guard = mutex.lock(); + consume(guard); + return; + } + end + """); + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(callProgram)); + + var nestedProgram = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + define module app + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + val guard = mutex.lock(); + val nested = Mutex.new(guard); + stdio.println(nested.is_poisoned()); + return; + } + end + """); + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(nestedProgram)); + } + + @Test + void guardedMoveOnlyFieldsAndMethodValuesCannotEscape() { + var fieldProgram = Parser.parse(""" + define module model + define class Child as + pub let int value = 1; + end + define class Holder as + pub val Child child = new Child(); + end + end + define module app + fnc bad(): void { + val mutex = Mutex.new(new Holder()); + val guard = mutex.lock(); + val escaped = guard.child; + stdio.println(escaped); + return; + } + end + """); + IllegalArgumentException fieldError = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(fieldProgram)); + assertTrue(fieldError.getMessage().contains("cannot extract move-only field")); + + var methodProgram = Parser.parse(""" + define module model + define class Counter as + pub read(): int { return 1; } + end + end + define module app + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + val guard = mutex.lock(); + val callback = guard.read; + stdio.println(callback); + return; + } + end + """); + IllegalArgumentException methodError = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(methodProgram)); + assertTrue(methodError.getMessage().contains("direct-call-only")); + } + + @Test + void directGuardedCopyReturningMethodCallIsAllowed() throws Exception { + String program = """ + define module model + define class Counter as + pub read(): int { return 7; } + end + end + define module app + pub fnc main(): void { + val mutex = Mutex.new(new Counter()); + val guard = mutex.lock(); + val value = guard.read(); + stdio.println(value); + guard.release(); + return; + } + end + """; + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(program))); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "guard-method.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + assertTrue(output.toString(StandardCharsets.UTF_8).lines().anyMatch("7"::equals)); + } + + @Test + void withLockProtectedBorrowCannotMoveOrReturnState() { + var moved = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + define module app + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + mutex.with_lock(|counter| -> { + val escaped = counter; + stdio.println(escaped); + return; + }); + return; + } + end + """); + IllegalArgumentException movedError = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(moved)); + assertTrue(movedError.getMessage().contains("protected with_lock/recover state cannot be moved")); + + var returned = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + define module app + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + mutex.with_lock(|counter| -> { + return counter.value; + }); + return; + } + end + """); + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(returned)); + } + + @Test + void withLockRequiresInlineLambda() { + var program = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + define module app + fnc mutate(Counter mut counter): void { + counter.value = counter.value + 1; + return; + } + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + mutex.with_lock(mutate); + return; + } + end + """); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("requires an inline one-argument lambda")); + } + + @Test + void guardCannotBeStoredInAggregate() { + var program = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + val guard = mutex.lock(); + val escaped = arr[guard]; + stdio.println(escaped); + return; + } + end + """); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("MutexGuard cannot be stored")); + } + + @Test + void guardCannotEscapeThroughClosureCapture() { + var program = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + val guard = mutex.lock(); + val callback = || -> { + stdio.println(guard.value); + return; + }; + callback(); + return; + } + end + """); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(program)); + assertTrue(error.getMessage().contains("closure cannot capture guard-bearing value")); + } + + @Test + void sharedMutexCapabilityAdmissionCoversSignaturesAndAliases() { + var signature = Parser.parse(""" + define module app + fnc pass(SharedMutex value): void { + return; + } + end + """); + assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(signature, IsolatePolicy.strictFaas())); + assertDoesNotThrow( + () -> CapabilityChecker.check(signature, IsolatePolicy.developer())); + + var alias = Parser.parse(""" + define module app + type SharedCounter = SharedMutex; + fnc main(): void { + return; + } + end + """); + assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(alias, IsolatePolicy.strictFaas())); + } + + @Test + void bareSharedMutexNamespaceRequiresCapabilityBeforeRebinding() { + var program = Parser.parse(""" + define module app + fnc main(): void { + val factory = SharedMutex; + return; + } + end + """); + + assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.strictFaas())); + assertDoesNotThrow( + () -> CapabilityChecker.check(program, IsolatePolicy.developer())); + } + + + @Test + void guardBearingValuesCannotBeHiddenInConstructedObjects() { + var direct = Parser.parse(""" + define module model + define class Box as + pub let T value; + end + + define class Counter as + pub let int value = 0; + end + end + + define module app + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + val guard = mutex.lock(); + val hidden = new Box<>(guard); + stdio.println(hidden); + return; + } + end + """); + + IllegalArgumentException directError = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(direct)); + assertTrue(directError.getMessage().contains( + "MutexGuard cannot be stored in a constructed object")); + + var pending = Parser.parse(""" + define module model + define class Box as + pub let T value; + end + + define class Counter as + pub let int value = 0; + end + end + + define module app + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + val futureGuard = mutex.lock_async(); + val hidden = new Box<>(futureGuard); + stdio.println(hidden); + return; + } + end + """); + + IllegalArgumentException pendingError = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(pending)); + assertTrue(pendingError.getMessage().contains( + "MutexGuard cannot be stored in a constructed object")); + + var borrowed = Parser.parse(""" + define module model + define class Box as + pub let T value; + end + + define class Counter as + pub let int value = 0; + end + end + + define module app + fnc bad(): void { + val mutex = Mutex.new(new Counter()); + val guard = mutex.lock(); + val hidden = new Box<>(&guard); + stdio.println(hidden); + return; + } + end + """); + + IllegalArgumentException borrowedError = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(borrowed)); + assertTrue(borrowedError.getMessage().contains( + "MutexGuard cannot be stored in a constructed object")); + } + + + @Test + void sharedSafeUnionPayloadsRequireEveryArmToBeSafe() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc good(SharedMutex value): void { + return; + } + end + """))); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module app + fnc bad(SharedMutex> value): void { + return; + } + end + """))); + assertTrue(error.getMessage().contains("concrete shared-safe type")); + } + + + @Test + void mutexPayloadsMustBeOwnedRatherThanBorrowed() { + IllegalArgumentException factoryError = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module app + fnc bad(): void { + val data = arr[1, 2, 3]; + val mutex = Mutex.new(&data); + stdio.println(mutex); + return; + } + end + """))); + assertTrue(factoryError.getMessage().contains("requires owned data")); + + IllegalArgumentException declaredError = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + fnc bad(Mutex<&Counter> value): void { + return; + } + end + """))); + assertTrue(declaredError.getMessage().contains("owned value type")); + } + + + @Test + void conditionalExpressionsCannotEraseGuardLinearity() { + var awaitProgram = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + async fnc bad(bool choose): void { + val mutex = Mutex.new(new Counter()); + val maybe_guard = choose ? 1 : mutex.lock(); + await mutex.lock_async(); + stdio.println(maybe_guard); + return; + } + end + """); + + IllegalArgumentException awaitError = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(awaitProgram)); + assertTrue(awaitError.getMessage().contains( + "cannot await while holding a MutexGuard")); + + var returnProgram = Parser.parse(""" + define module model + define class Counter as + pub let int value = 0; + end + end + + define module app + fnc bad(bool choose): int | Counter { + val mutex = Mutex.new(new Counter()); + return choose ? 1 : mutex.lock(); + } + end + """); + + assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(returnProgram)); + } + +} diff --git a/src/test/java/dev/oreslang/MutexRuntimeTest.java b/src/test/java/dev/oreslang/MutexRuntimeTest.java new file mode 100644 index 00000000..97d0ccb8 --- /dev/null +++ b/src/test/java/dev/oreslang/MutexRuntimeTest.java @@ -0,0 +1,1219 @@ +package dev.oreslang; + +import dev.oreslang.runtime.ActorRuntime; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.runtime.OresMutex; +import org.junit.jupiter.api.Test; + +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import java.time.Duration; + +final class MutexRuntimeTest { + + private static final class BlockingSingleElementList extends AbstractList { + private final Object value; + private final CountDownLatch entered; + private final CountDownLatch release; + private final AtomicBoolean blocked = new AtomicBoolean(); + + private BlockingSingleElementList( + Object value, + CountDownLatch entered, + CountDownLatch release) { + this.value = value; + this.entered = entered; + this.release = release; + } + + @Override + public Object get(int index) { + if (index != 0) throw new IndexOutOfBoundsException(index); + if (blocked.compareAndSet(false, true)) { + entered.countDown(); + try { + if (!release.await(2, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting to release transport validation"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new java.util.concurrent.CancellationException(); + } + } + return value; + } + + @Override + public int size() { + return 1; + } + } + + @Test + void localMutexIsNonReentrantAndExecutionDomainConfined() throws Exception { + var mutex = OresMutex.local(new int[]{0}); + var guard = mutex.lock(); + guard.value()[0] = 7; + + assertThrows(OresMutex.RecursiveLockException.class, mutex::lock); + assertTrue(mutex.tryLock().isEmpty(), "try_lock should report busy rather than recurse"); + + AtomicReference otherThreadFailure = new AtomicReference<>(); + Thread thread = Thread.ofPlatform().start(() -> { + try { + mutex.tryLock(); + } catch (Throwable failure) { + otherThreadFailure.set(failure); + } + }); + thread.join(); + + assertInstanceOf(OresMutex.WrongMutexDomainException.class, otherThreadFailure.get()); + guard.release(); + + var again = mutex.lock(); + assertEquals(7, again.value()[0]); + again.release(); + } + + @Test + void actorLocalMutexPersistsAcrossMessagesInOneSemanticDomain() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch done = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + + var ref = runtime.spawn(() -> { + var mutex = OresMutex.local(new int[]{0}); + return (message, context) -> mutex.withLock(value -> { + value[0] += message; + if (value[0] == 3) { + observed.set(value[0]); + done.countDown(); + } + return null; + }); + }); + + ref.send(1); + ref.send(2); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertEquals(3, observed.get()); + } + } + + @Test + void actorLocalMutexCannotBeUsedByAnotherActorEvenInSameProcess() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch created = new CountDownLatch(1); + CountDownLatch checked = new CountDownLatch(1); + AtomicReference> local = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + + var owner = runtime.spawn(() -> (message, context) -> { + var mutex = OresMutex.local(new int[]{0}); + mutex.withLock(value -> { + value[0] = 41; + return null; + }); + local.set(mutex); + created.countDown(); + }); + + var other = runtime.spawn(() -> (message, context) -> { + try { + assertTrue(created.await(2, TimeUnit.SECONDS)); + assertThrows(OresMutex.WrongMutexDomainException.class, () -> local.get().tryLock()); + } catch (Throwable problem) { + failure.set(problem); + } finally { + checked.countDown(); + } + }); + + owner.send("create"); + other.send("check"); + + assertTrue(checked.await(3, TimeUnit.SECONDS)); + assertNull(failure.get()); + } + } + + @Test + void sharedMutexSerializesRealJvmThreads() throws Exception { + var mutex = OresMutex.shared(new int[]{0}); + List workers = new ArrayList<>(); + for (int w = 0; w < 6; w++) { + workers.add(Thread.ofPlatform().start(() -> { + for (int i = 0; i < 500; i++) { + mutex.withLock(value -> { + value[0]++; + return null; + }); + } + })); + } + for (Thread worker : workers) worker.join(); + assertEquals(3000, mutex.withLock(value -> value[0]).intValue()); + } + + @Test + void recoveryCannotBeUsedAsAnOrdinaryLock() { + var mutex = OresMutex.shared(new int[]{0}); + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> mutex.recover(value -> { + value[0] = 99; + return null; + })); + + assertTrue(error.getMessage().contains("not poisoned")); + assertFalse(mutex.isPoisoned()); + assertEquals(0, mutex.withLock(value -> value[0]).intValue()); + } + + @Test + void sharedMutexPoisonsAndRequiresExplicitRecovery() { + var mutex = OresMutex.shared(new int[]{0}); + + assertThrows(IllegalStateException.class, () -> mutex.withLock(value -> { + value[0] = 99; + throw new IllegalStateException("boom"); + })); + + assertTrue(mutex.isPoisoned()); + assertThrows(OresMutex.PoisonedMutexException.class, mutex::lock); + + mutex.recover(value -> { + value[0] = 0; + return null; + }); + + assertFalse(mutex.isPoisoned()); + assertEquals(0, mutex.withLock(value -> value[0]).intValue()); + } + + @Test + void asyncMutexAcquisitionUsesTaggedGuardFutures() { + var local = OresMutex.local(new int[]{1}); + var localFuture = local.lockAsync(); + assertInstanceOf(OresMutex.GuardFuture.class, localFuture); + var localGuard = localFuture.join(); + localGuard.release(); + + var shared = OresMutex.shared(new int[]{2}); + var sharedFuture = shared.lockAsync(); + assertInstanceOf(OresMutex.GuardFuture.class, sharedFuture); + var sharedGuard = sharedFuture.join(); + sharedGuard.release(); + } + + @Test + void sharedTryLockReportsBusyAndCancelledAsyncWaitDoesNotLeakPermit() throws Exception { + var mutex = OresMutex.shared(new int[]{0}); + var guard = mutex.lock(); + + assertTrue(mutex.tryLock().isEmpty()); + + AtomicReference>> waitingRef = new AtomicReference<>(); + CountDownLatch queued = new CountDownLatch(1); + Thread requester = Thread.ofPlatform().start(() -> { + waitingRef.set(mutex.lockAsync()); + queued.countDown(); + }); + requester.join(); + + assertTrue(queued.await(1, TimeUnit.SECONDS)); + var waiting = waitingRef.get(); + assertNotNull(waiting); + assertFalse(waiting.isDone()); + assertTrue(waiting.cancel(true)); + + guard.release(); + + var next = mutex.lock(); + next.release(); + assertTrue(waiting.isCancelled()); + } + + @Test + void sharedMutexRejectsSameDomainAsyncReentryBeforeDeadlock() { + var mutex = OresMutex.shared(new int[]{0}); + var guard = mutex.lockAsync().join(); + + assertThrows(OresMutex.RecursiveLockException.class, mutex::lockAsync); + + guard.release(); + var next = mutex.lock(); + next.release(); + } + + @Test + void releasedGuardsCannotExposeProtectedValues() { + var local = OresMutex.local(new int[]{1}); + var localGuard = local.lock(); + assertEquals(1, localGuard.value()[0]); + localGuard.release(); + assertThrows(IllegalStateException.class, localGuard::value); + + var shared = OresMutex.shared(new int[]{2}); + var sharedGuard = shared.lock(); + assertEquals(2, sharedGuard.value()[0]); + sharedGuard.release(); + assertThrows(IllegalStateException.class, sharedGuard::value); + } + + @Test + void sharedMutexCannotBeClosureCapturedAcrossActorRuntimes() throws Exception { + try (ActorRuntime runtimeA = new ActorRuntime(); + ActorRuntime runtimeB = new ActorRuntime()) { + var shared = OresMutex.shared(new int[]{0}); + CountDownLatch bound = new CountDownLatch(1); + CountDownLatch checked = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + + var owner = runtimeA.>spawnShared(() -> (mutex, context) -> { + // The send itself binds the SharedMutex to runtimeA. + assertFalse(mutex.isPoisoned()); + bound.countDown(); + }); + owner.send(shared); + assertTrue(bound.await(2, TimeUnit.SECONDS)); + + var foreign = runtimeB.spawnShared(() -> (message, context) -> { + try { + shared.tryLock(); + } catch (Throwable problem) { + failure.set(problem); + } finally { + checked.countDown(); + } + }); + foreign.send("check"); + + assertTrue(checked.await(2, TimeUnit.SECONDS)); + assertInstanceOf(OresMutex.WrongMutexDomainException.class, failure.get()); + } + } + + @Test + void actorCanRecoverPoisonedSharedMutexWithoutBlocking() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var shared = OresMutex.shared(new int[]{0}); + CountDownLatch poisoned = new CountDownLatch(1); + CountDownLatch recovered = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + + var poisoner = runtime.>spawnShared(() -> (mutex, context) -> { + try { + var guard = mutex.lockAsync().join(); + guard.value()[0] = 17; + guard.fail(); + } catch (Throwable problem) { + failure.compareAndSet(null, problem); + } finally { + poisoned.countDown(); + } + }); + + poisoner.send(shared); + assertTrue(poisoned.await(2, TimeUnit.SECONDS)); + assertTrue(shared.isPoisoned()); + + var repairer = runtime.>spawnShared(() -> (mutex, context) -> { + try { + mutex.recover(value -> { + value[0] = 0; + return null; + }); + } catch (Throwable problem) { + failure.compareAndSet(null, problem); + } finally { + recovered.countDown(); + } + }); + + repairer.send(shared); + assertTrue(recovered.await(2, TimeUnit.SECONDS)); + assertNull(failure.get()); + assertFalse(shared.isPoisoned()); + assertEquals(0, shared.withLock(value -> value[0]).intValue()); + } + } + + @Test + void actorUsesAsyncAcquisitionForSharedMutex() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch done = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + + var ref = runtime.>spawnShared(() -> (mutex, context) -> { + try { + assertThrows(OresMutex.WrongMutexDomainException.class, mutex::lock); + var guard = mutex.lockAsync().join(); + guard.value()[0]++; + guard.release(); + } catch (Throwable problem) { + failure.set(problem); + } finally { + done.countDown(); + } + }); + + var shared = OresMutex.shared(new int[]{0}); + ref.send(shared); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertNull(failure.get()); + assertEquals(1, shared.withLock(value -> value[0]).intValue()); + } + } + + @Test + void sharedMutexBindsOnlyAfterAuthorizedRuntimePublication() { + IsolatePolicy strict = IsolatePolicy.strictFaas(); + var shared = OresMutex.shared(new int[]{0}); + + try (ActorRuntime denied = new ActorRuntime(strict); + ActorRuntime runtimeA = new ActorRuntime(); + ActorRuntime runtimeB = new ActorRuntime()) { + var deniedRef = denied.>spawnPrivate( + strict, factoryContext -> (message, context) -> { }); + var refA = runtimeA.>spawnShared( + () -> (message, context) -> { }); + var refB = runtimeB.>spawnShared( + () -> (message, context) -> { }); + + assertThrows(SecurityException.class, () -> deniedRef.send(shared)); + assertDoesNotThrow(() -> refA.send(shared)); + + IllegalArgumentException crossRuntime = assertThrows( + IllegalArgumentException.class, + () -> refB.send(shared)); + assertTrue(crossRuntime.getMessage().contains("ActorRuntime")); + } + } + + @Test + void strictActorPolicyRejectsSharedMemoryHandles() { + IsolatePolicy strict = IsolatePolicy.strictFaas(); + try (ActorRuntime runtime = new ActorRuntime(strict)) { + var ref = runtime.>spawnPrivate( + strict, factoryContext -> (message, context) -> { }); + var shared = OresMutex.shared(new int[]{0}); + assertThrows(SecurityException.class, () -> ref.send(shared)); + } + } + + @Test + void strictPrivateActorRejectsCapturedSharedMutexBeforeStartup() { + try (ActorRuntime runtime = new ActorRuntime()) { + IsolatePolicy strict = IsolatePolicy.strictFaas(); + var shared = OresMutex.shared(new int[]{0}); + + SecurityException failure = assertThrows( + SecurityException.class, + () -> runtime.spawnPrivate(strict, factoryContext -> { + shared.tryLock(); + return (message, context) -> { }; + })); + + assertTrue(failure.getMessage().contains("stateless") + || failure.getMessage().contains("captured host state")); + } + } + + @Test + void strictActorCannotCreateSharedMutexDirectly() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + IsolatePolicy strict = IsolatePolicy.strictFaas(); + + var ref = runtime.spawnPrivate( + strict, + factoryContext -> (message, context) -> OresMutex.shared(new int[]{0})); + + ref.send("check"); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (ref.failure().isEmpty() && System.nanoTime() < deadline) Thread.sleep(2); + + assertTrue(ref.failure().isPresent()); + assertInstanceOf(SecurityException.class, ref.failure().orElseThrow()); + } + } + + +@Test + void guardFutureCannotBeForgedOrTimeoutCompletedByCallers() throws Exception { + var mutex = OresMutex.shared(new int[]{0}); + var guard = mutex.lock(); + + AtomicReference>> waitingRef = new AtomicReference<>(); + Thread requester = Thread.ofPlatform().start(() -> waitingRef.set(mutex.lockAsync())); + requester.join(); + + var waiting = waitingRef.get(); + assertInstanceOf(OresMutex.GuardFuture.class, waiting); + assertFalse(waiting.isDone()); + + assertThrows(UnsupportedOperationException.class, () -> waiting.complete(null)); + assertThrows(UnsupportedOperationException.class, + () -> waiting.completeExceptionally(new RuntimeException("forged"))); + assertThrows(UnsupportedOperationException.class, + () -> waiting.completeAsync(() -> null)); + assertThrows(UnsupportedOperationException.class, + () -> waiting.orTimeout(1, TimeUnit.MILLISECONDS)); + assertThrows(UnsupportedOperationException.class, + () -> waiting.completeOnTimeout(null, 1, TimeUnit.MILLISECONDS)); + assertThrows(UnsupportedOperationException.class, () -> waiting.obtrudeValue(null)); + assertThrows(UnsupportedOperationException.class, + () -> waiting.obtrudeException(new RuntimeException("forged"))); + + assertTrue(waiting.cancel(true)); + guard.release(); + + var next = mutex.lock(); + next.release(); + } + +@Test + void cancelledQueuedWaiterIsSkippedByDirectHandoff() throws Exception { + var mutex = OresMutex.shared(new int[]{0}); + var guard = mutex.lock(); + + AtomicReference>> firstRef = new AtomicReference<>(); + AtomicReference>> secondRef = new AtomicReference<>(); + + Thread firstRequester = Thread.ofPlatform().start(() -> firstRef.set(mutex.lockAsync())); + Thread secondRequester = Thread.ofPlatform().start(() -> secondRef.set(mutex.lockAsync())); + firstRequester.join(); + secondRequester.join(); + + var first = firstRef.get(); + var second = secondRef.get(); + assertFalse(first.isDone()); + assertFalse(second.isDone()); + assertTrue(first.cancel(true)); + + guard.release(); + + var secondGuard = second.get(2, TimeUnit.SECONDS); + secondGuard.value()[0] = 9; + secondGuard.release(); + + assertTrue(first.isCancelled()); + assertEquals(9, mutex.withLock(value -> value[0]).intValue()); + } + +@Test + void poisoningFailsQueuedAsyncWaitersAndRecoveryRestoresHandoff() throws Exception { + var mutex = OresMutex.shared(new int[]{0}); + var guard = mutex.lock(); + + AtomicReference>> firstRef = new AtomicReference<>(); + AtomicReference>> secondRef = new AtomicReference<>(); + Thread firstRequester = Thread.ofPlatform().start(() -> firstRef.set(mutex.lockAsync())); + Thread secondRequester = Thread.ofPlatform().start(() -> secondRef.set(mutex.lockAsync())); + firstRequester.join(); + secondRequester.join(); + + guard.value()[0] = 17; + guard.fail(); + + for (var waiting : List.of(firstRef.get(), secondRef.get())) { + var failure = assertThrows( + java.util.concurrent.ExecutionException.class, + () -> waiting.get(2, TimeUnit.SECONDS)); + assertInstanceOf(OresMutex.PoisonedMutexException.class, failure.getCause()); + } + + assertTrue(mutex.isPoisoned()); + mutex.recover(value -> { + value[0] = 0; + return null; + }); + + var next = mutex.lockAsync().get(2, TimeUnit.SECONDS); + next.value()[0] = 3; + next.release(); + assertEquals(3, mutex.withLock(value -> value[0]).intValue()); + } + + @Test + void asyncFastPathDoesNotBargeAfterReleaseToQueuedHostWaiter() throws Exception { + var mutex = OresMutex.shared(new int[]{0}); + var initial = mutex.lock(); + + CountDownLatch blockingAttempted = new CountDownLatch(1); + CountDownLatch blockingAcquired = new CountDownLatch(1); + CountDownLatch releaseBlocking = new CountDownLatch(1); + AtomicReference blockingFailure = new AtomicReference<>(); + + Thread blocking = Thread.ofPlatform().start(() -> { + blockingAttempted.countDown(); + try { + var guard = mutex.lock(); + blockingAcquired.countDown(); + releaseBlocking.await(); + guard.release(); + } catch (Throwable failure) { + blockingFailure.set(failure); + } + }); + + assertTrue(blockingAttempted.await(1, TimeUnit.SECONDS)); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (blocking.getState() != Thread.State.WAITING + && blocking.getState() != Thread.State.TIMED_WAITING + && System.nanoTime() < deadline) { + Thread.onSpinWait(); + } + assertTrue(blocking.getState() == Thread.State.WAITING + || blocking.getState() == Thread.State.TIMED_WAITING, + "blocking host waiter must be queued before release"); + + // No async waiter exists yet, so this release deliberately exposes the + // fair Semaphore permit to the already-queued blocking host waiter. + initial.release(); + + // Arrive only after the release. An untimed tryAcquire() may barge here + // before the awakened host thread runs; timed-zero fair acquisition may not. + var async = mutex.lockAsync(); + + assertTrue(blockingAcquired.await(2, TimeUnit.SECONDS), + "queued blocking waiter must retain its fair position after release"); + assertFalse(async.isDone(), + "later async fast-path request must not steal the released permit"); + + releaseBlocking.countDown(); + var asyncGuard = async.get(2, TimeUnit.SECONDS); + asyncGuard.release(); + + blocking.join(); + assertNull(blockingFailure.get()); + } + + @Test + void blockingAndAsyncWaitersBothMakeProgress() throws Exception { + var mutex = OresMutex.shared(new int[]{0}); + var initial = mutex.lock(); + CountDownLatch blockingStarted = new CountDownLatch(1); + CountDownLatch blockingDone = new CountDownLatch(1); + AtomicReference blockingFailure = new AtomicReference<>(); + + Thread blocking = Thread.ofPlatform().start(() -> { + blockingStarted.countDown(); + try { + var guard = mutex.lock(); + guard.value()[0] += 1; + guard.release(); + } catch (Throwable failure) { + blockingFailure.set(failure); + } finally { + blockingDone.countDown(); + } + }); + assertTrue(blockingStarted.await(1, TimeUnit.SECONDS)); + + AtomicReference>> asyncRef = new AtomicReference<>(); + Thread asyncRequester = Thread.ofPlatform().start(() -> asyncRef.set(mutex.lockAsync())); + asyncRequester.join(); + var async = asyncRef.get(); + assertFalse(async.isDone()); + + initial.release(); + + var asyncGuard = async.get(2, TimeUnit.SECONDS); + asyncGuard.value()[0] += 10; + asyncGuard.release(); + + assertTrue(blockingDone.await(2, TimeUnit.SECONDS)); + blocking.join(); + assertNull(blockingFailure.get()); + assertEquals(11, mutex.withLock(value -> value[0]).intValue()); + } + +@Test + void sharedTimedLockSaturatesHugePositiveDurations() { + var mutex = OresMutex.shared(new int[]{1}); + + var guard = mutex.lockFor(Duration.ofSeconds(Long.MAX_VALUE)).orElseThrow(); + assertEquals(1, guard.value()[0]); + guard.release(); + } + +@Test + void failedRecoveryKeepsMutexPoisoned() { + var mutex = OresMutex.shared(new int[]{0}); + + assertThrows(IllegalStateException.class, () -> mutex.withLock(value -> { + value[0] = 9; + throw new IllegalStateException("poison"); + })); + + assertThrows(IllegalStateException.class, () -> mutex.recover(value -> { + value[0] = 3; + throw new IllegalStateException("repair failed"); + })); + + assertTrue(mutex.isPoisoned()); + assertThrows(OresMutex.PoisonedMutexException.class, mutex::tryLock); + + mutex.recover(value -> { + value[0] = 0; + return null; + }); + assertFalse(mutex.isPoisoned()); + } + +@Test + void normalReleaseFollowedByFailDoesNotPoisonMutex() { + var mutex = OresMutex.shared(new int[]{0}); + var guard = mutex.lock(); + + guard.release(); + guard.fail(); + + assertFalse(mutex.isPoisoned()); + var next = mutex.lock(); + next.release(); + } + + + @Test + void failedActorAdmissionDoesNotPermanentlyBindSharedMutex() throws Exception { + var shared = OresMutex.shared(new int[]{0}); + + try (ActorRuntime runtimeA = new ActorRuntime(); + ActorRuntime runtimeB = new ActorRuntime()) { + CountDownLatch enteredBehavior = new CountDownLatch(1); + CountDownLatch allowFailure = new CountDownLatch(1); + CountDownLatch validationEntered = new CountDownLatch(1); + CountDownLatch releaseValidation = new CountDownLatch(1); + AtomicReference senderFailure = new AtomicReference<>(); + + var doomed = runtimeA.spawnShared(() -> (message, context) -> { + if ("die".equals(message)) { + enteredBehavior.countDown(); + allowFailure.await(); + throw new IllegalStateException("intentional actor failure"); + } + }); + + doomed.send("die"); + assertTrue(enteredBehavior.await(2, TimeUnit.SECONDS)); + + Object blockedMessage = + new BlockingSingleElementList(shared, validationEntered, releaseValidation); + Thread sender = Thread.ofPlatform().start(() -> { + try { + doomed.send(blockedMessage); + } catch (Throwable failure) { + senderFailure.set(failure); + } + }); + + assertTrue(validationEntered.await(2, TimeUnit.SECONDS)); + allowFailure.countDown(); + for (int i = 0; i < 500 && doomed.failure().isEmpty(); i++) Thread.yield(); + assertTrue(doomed.failure().isPresent()); + + releaseValidation.countDown(); + sender.join(); + + assertNotNull(senderFailure.get()); + assertTrue(senderFailure.get().getMessage().contains("terminated") + || senderFailure.get().getMessage().contains("closed")); + + CountDownLatch delivered = new CountDownLatch(1); + var receiver = runtimeB.>spawnShared(() -> (mutex, context) -> { + var guard = mutex.tryLock().orElseThrow(); + guard.value()[0] = 42; + guard.release(); + delivered.countDown(); + }); + + assertDoesNotThrow(() -> receiver.send(shared)); + assertTrue(delivered.await(2, TimeUnit.SECONDS)); + assertEquals(42, shared.withLock(value -> value[0]).intValue()); + } + } + + @Test + void actorCannotUseBlockingSharedMutexApis() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch checked = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + + var ref = runtime.>spawnShared(() -> (mutex, context) -> { + try { + assertThrows(OresMutex.WrongMutexDomainException.class, mutex::lock); + assertThrows( + OresMutex.WrongMutexDomainException.class, + () -> mutex.lockFor(Duration.ZERO)); + assertThrows( + OresMutex.WrongMutexDomainException.class, + () -> mutex.withLock(value -> null)); + + var guard = mutex.tryLock().orElseThrow(); + guard.release(); + } catch (Throwable problem) { + failure.set(problem); + } finally { + checked.countDown(); + } + }); + + ref.send(OresMutex.shared(new int[]{0})); + assertTrue(checked.await(2, TimeUnit.SECONDS)); + assertNull(failure.get()); + } + } + + @Test + void sharedGuardCannotBeReleasedFromAnotherActorDomain() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var shared = OresMutex.shared(new int[]{0}); + AtomicReference> guardRef = new AtomicReference<>(); + AtomicReference observed = new AtomicReference<>(); + CountDownLatch acquired = new CountDownLatch(1); + CountDownLatch attempted = new CountDownLatch(1); + CountDownLatch ownerCanRelease = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(1); + + var owner = runtime.>spawnShared(() -> (mutex, context) -> { + var guard = mutex.lockAsync().join(); + guardRef.set(guard); + acquired.countDown(); + ownerCanRelease.await(); + guard.release(); + done.countDown(); + }); + + var intruder = runtime.spawnShared(() -> (message, context) -> { + assertTrue(acquired.await(2, TimeUnit.SECONDS)); + try { + guardRef.get().release(); + } catch (Throwable failure) { + observed.set(failure); + } finally { + attempted.countDown(); + ownerCanRelease.countDown(); + } + }); + + owner.send(shared); + intruder.send("try"); + + assertTrue(attempted.await(2, TimeUnit.SECONDS)); + assertInstanceOf(OresMutex.WrongMutexDomainException.class, observed.get()); + assertTrue(done.await(2, TimeUnit.SECONDS)); + + var next = shared.lock(); + next.release(); + } + } + + @Test + void shutdownRacingSendDoesNotBindOrAdmitSharedMutex() throws Exception { + ActorRuntime runtimeA = new ActorRuntime(); + var shared = OresMutex.shared(new int[]{0}); + CountDownLatch behaviorStarted = new CountDownLatch(1); + CountDownLatch releaseBehavior = new CountDownLatch(1); + CountDownLatch validationEntered = new CountDownLatch(1); + CountDownLatch releaseValidation = new CountDownLatch(1); + AtomicReference senderFailure = new AtomicReference<>(); + AtomicReference closeFailure = new AtomicReference<>(); + + var target = runtimeA.spawnShared(() -> (message, context) -> { + if ("block".equals(message)) { + behaviorStarted.countDown(); + while (true) { + try { + releaseBehavior.await(); + return; + } catch (InterruptedException ignored) { + // Deliberately keep the turn alive through the first shutdown interrupt. + } + } + } + }); + + target.send("block"); + assertTrue(behaviorStarted.await(2, TimeUnit.SECONDS)); + + Object blockedMessage = + new BlockingSingleElementList(shared, validationEntered, releaseValidation); + Thread sender = Thread.ofPlatform().start(() -> { + try { + target.send(blockedMessage); + } catch (Throwable failure) { + senderFailure.set(failure); + } + }); + assertTrue(validationEntered.await(2, TimeUnit.SECONDS)); + + Thread closer = Thread.ofPlatform().start(() -> { + try { + runtimeA.close(); + } catch (Throwable failure) { + closeFailure.set(failure); + } + }); + + IllegalStateException closedObserved = null; + for (int i = 0; i < 500 && closedObserved == null; i++) { + try { + target.send("probe"); + Thread.yield(); + } catch (IllegalStateException failure) { + if (failure.getMessage().contains("actor runtime is closed")) { + closedObserved = failure; + } + } + } + assertNotNull(closedObserved); + + releaseValidation.countDown(); + sender.join(); + assertInstanceOf(IllegalStateException.class, senderFailure.get()); + assertTrue(senderFailure.get().getMessage().contains("actor runtime is closed")); + + releaseBehavior.countDown(); + closer.join(); + assertNull(closeFailure.get()); + + try (ActorRuntime runtimeB = new ActorRuntime()) { + CountDownLatch delivered = new CountDownLatch(1); + var receiver = runtimeB.>spawnShared(() -> (mutex, context) -> { + var guard = mutex.tryLock().orElseThrow(); + guard.value()[0] = 7; + guard.release(); + delivered.countDown(); + }); + + assertDoesNotThrow(() -> receiver.send(shared)); + assertTrue(delivered.await(2, TimeUnit.SECONDS)); + assertEquals(7, shared.withLock(value -> value[0]).intValue()); + } + } + + @Test + void mixedRuntimePublicationFailsAtomicallyAcrossAllHandles() throws Exception { + var unbound = OresMutex.shared(new int[]{1}); + var foreign = OresMutex.shared(new int[]{2}); + + try (ActorRuntime runtimeA = new ActorRuntime(); + ActorRuntime runtimeB = new ActorRuntime(); + ActorRuntime runtimeC = new ActorRuntime()) { + CountDownLatch boundForeign = new CountDownLatch(1); + var owner = runtimeA.>spawnShared(() -> (mutex, context) -> { + assertFalse(mutex.isPoisoned()); + boundForeign.countDown(); + }); + owner.send(foreign); + assertTrue(boundForeign.await(2, TimeUnit.SECONDS)); + + var rejected = runtimeB.>>spawnShared( + () -> (message, context) -> { }); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> rejected.send(List.of(unbound, foreign))); + assertTrue(error.getMessage().contains("ActorRuntime")); + + CountDownLatch delivered = new CountDownLatch(1); + var receiver = runtimeC.>spawnShared(() -> (mutex, context) -> { + var guard = mutex.tryLock().orElseThrow(); + guard.value()[0] = 11; + guard.release(); + delivered.countDown(); + }); + + assertDoesNotThrow(() -> receiver.send(unbound)); + assertTrue(delivered.await(2, TimeUnit.SECONDS)); + assertEquals(11, unbound.withLock(value -> value[0]).intValue()); + } + } + + + + @Test + void actorTransportRejectsHostCreatedSharedMutexWithUnsafePayload() { + try (ActorRuntime runtime = new ActorRuntime()) { + var receiver = runtime.>spawnShared( + () -> (message, context) -> { }); + + var nestedLocal = OresMutex.shared((Object) OresMutex.local(new int[]{1})); + IllegalArgumentException localError = assertThrows( + IllegalArgumentException.class, + () -> receiver.send(nestedLocal)); + assertTrue(localError.getMessage().contains("actor-local mutex state")); + + var opaque = OresMutex.shared(new Object()); + IllegalArgumentException opaqueError = assertThrows( + IllegalArgumentException.class, + () -> receiver.send(opaque)); + assertTrue(opaqueError.getMessage().contains("opaque host value")); + } + } + + @Test + void actorTransportAcceptsRuntimeInspectableSharedState() throws Exception { + record SafeBox(int value) implements OresMutex.SharedState { + @Override public Iterable sharedStateChildren() { return List.of(value); } + } + + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch delivered = new CountDownLatch(1); + var receiver = runtime.>spawnShared( + () -> (message, context) -> { + var guard = message.tryLock().orElseThrow(); + assertEquals(7, guard.value().value()); + guard.release(); + delivered.countDown(); + }); + + var shared = OresMutex.shared(new SafeBox(7)); + assertDoesNotThrow(() -> receiver.send(shared)); + assertTrue(delivered.await(2, TimeUnit.SECONDS)); + } + } + + + @Test + void nestedSharedMutexPayloadsAreRejectedUntilRecursiveLockOrderingExists() { + record Box(OresMutex.Shared inner) implements OresMutex.SharedState { + @Override public Iterable sharedStateChildren() { return List.of(inner); } + } + + var outer = OresMutex.shared(new Box(OresMutex.shared(new int[]{3}))); + + try (ActorRuntime runtime = new ActorRuntime()) { + var receiver = runtime.>spawnShared( + () -> (message, context) -> { }); + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> receiver.send(outer)); + assertTrue(error.getMessage().contains("cannot contain another SharedMutex")); + } + } + + @Test + void transportInspectionNeverBlocksOnALockedSharedMutex() { + var shared = OresMutex.shared(new int[]{1}); + var guard = shared.lock(); + + try (ActorRuntime runtime = new ActorRuntime()) { + var receiver = runtime.>spawnShared( + () -> (message, context) -> { }); + + IllegalStateException busy = assertThrows( + IllegalStateException.class, + () -> receiver.send(shared)); + assertTrue(busy.getMessage().contains("cannot be published while locked or contended")); + + guard.release(); + assertDoesNotThrow(() -> receiver.send(shared)); + } + } + + @Test + void cyclicRuntimeSharedStateIsRejectedAtTransportBoundary() { + final class CyclicBox implements OresMutex.SharedState { + private Object child; + @Override public Iterable sharedStateChildren() { return List.of(child); } + } + + CyclicBox box = new CyclicBox(); + box.child = box; + var shared = OresMutex.shared(box); + + try (ActorRuntime runtime = new ActorRuntime()) { + var receiver = runtime.>spawnShared( + () -> (message, context) -> { }); + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> receiver.send(shared)); + assertTrue(error.getMessage().contains("cyclic SharedMutex payload")); + } + } + + + @Test + void asyncTimedLockTimesOutAndReleasesItsDomainReservation() throws Exception { + var mutex = OresMutex.shared(new int[]{0}); + var guard = mutex.lock(); + + AtomicReference>> waitingRef = new AtomicReference<>(); + Thread requester = Thread.ofPlatform().start( + () -> waitingRef.set(mutex.lockAsyncFor(Duration.ofMillis(25)))); + requester.join(); + + var waiting = waitingRef.get(); + var failure = assertThrows( + java.util.concurrent.ExecutionException.class, + () -> waiting.get(2, TimeUnit.SECONDS)); + assertInstanceOf(OresMutex.LockTimeoutException.class, failure.getCause()); + + guard.release(); + var next = mutex.lock(); + next.release(); + } + + @Test + void asyncTimedLockCanWinBeforeDeadline() throws Exception { + var mutex = OresMutex.shared(new int[]{0}); + var guard = mutex.lock(); + + AtomicReference>> waitingRef = new AtomicReference<>(); + Thread requester = Thread.ofPlatform().start( + () -> waitingRef.set(mutex.lockAsyncFor(Duration.ofSeconds(2)))); + requester.join(); + var waiting = waitingRef.get(); + assertFalse(waiting.isDone()); + + guard.release(); + + var acquired = waiting.get(2, TimeUnit.SECONDS); + acquired.value()[0] = 5; + acquired.release(); + assertEquals(5, mutex.withLock(value -> value[0]).intValue()); + } + + @Test + void asyncTimedLockZeroAndNegativeTimeoutsAreWellDefined() throws Exception { + var mutex = OresMutex.shared(new int[]{0}); + var guard = mutex.lock(); + + AtomicReference>> zeroRef = new AtomicReference<>(); + Thread requester = Thread.ofPlatform().start( + () -> zeroRef.set(mutex.lockAsyncFor(Duration.ZERO))); + requester.join(); + + var zero = zeroRef.get(); + var failure = assertThrows( + java.util.concurrent.ExecutionException.class, + () -> zero.get(2, TimeUnit.SECONDS)); + assertInstanceOf(OresMutex.LockTimeoutException.class, failure.getCause()); + + assertThrows(IllegalArgumentException.class, + () -> mutex.lockAsyncFor(Duration.ofMillis(-1))); + + guard.release(); + var next = mutex.lock(); + next.release(); + } + + + @Test + void crossMutexTwoDomainCycleIsRejectedInsteadOfHanging() throws Exception { + var left = OresMutex.shared(new int[]{0}); + var right = OresMutex.shared(new int[]{0}); + CountDownLatch bothHeld = new CountDownLatch(2); + CountDownLatch startCrossAcquire = new CountDownLatch(1); + AtomicReference firstFailure = new AtomicReference<>(); + AtomicReference secondFailure = new AtomicReference<>(); + + Thread first = Thread.ofPlatform().start(() -> { + OresMutex.Guard leftGuard = left.lock(); + try { + bothHeld.countDown(); + assertTrue(bothHeld.await(2, TimeUnit.SECONDS)); + assertTrue(startCrossAcquire.await(2, TimeUnit.SECONDS)); + try { + var rightGuard = right.lock(); + rightGuard.release(); + } catch (Throwable failure) { + firstFailure.set(failure); + } + } catch (Throwable failure) { + firstFailure.compareAndSet(null, failure); + } finally { + leftGuard.release(); + } + }); + + Thread second = Thread.ofPlatform().start(() -> { + OresMutex.Guard rightGuard = right.lock(); + try { + bothHeld.countDown(); + assertTrue(bothHeld.await(2, TimeUnit.SECONDS)); + assertTrue(startCrossAcquire.await(2, TimeUnit.SECONDS)); + try { + var leftGuard = left.lock(); + leftGuard.release(); + } catch (Throwable failure) { + secondFailure.set(failure); + } + } catch (Throwable failure) { + secondFailure.compareAndSet(null, failure); + } finally { + rightGuard.release(); + } + }); + + assertTrue(bothHeld.await(2, TimeUnit.SECONDS)); + startCrossAcquire.countDown(); + first.join(3_000); + second.join(3_000); + + assertFalse(first.isAlive(), "first domain must not remain deadlocked"); + assertFalse(second.isAlive(), "second domain must not remain deadlocked"); + assertTrue( + firstFailure.get() instanceof OresMutex.DeadlockDetectedException + || secondFailure.get() instanceof OresMutex.DeadlockDetectedException, + "at least one edge that closes the cycle must be rejected"); + } + + @Test + void timedOutAsyncWaitRemovesDeadlockGraphEdge() throws Exception { + var held = OresMutex.shared(new int[]{0}); + var free = OresMutex.shared(new int[]{0}); + var owner = held.lock(); + + AtomicReference failure = new AtomicReference<>(); + Thread waiter = Thread.ofPlatform().start(() -> { + try { + var timed = held.lockAsyncFor(Duration.ofMillis(25)); + var timedFailure = assertThrows( + java.util.concurrent.ExecutionException.class, + () -> timed.get(2, TimeUnit.SECONDS)); + assertInstanceOf(OresMutex.LockTimeoutException.class, timedFailure.getCause()); + + var next = free.lock(); + next.release(); + } catch (Throwable problem) { + failure.set(problem); + } + }); + + waiter.join(3_000); + assertFalse(waiter.isAlive()); + assertNull(failure.get()); + owner.release(); + } + + @Test + void zeroDurationLockForRemainsTryOnly() throws Exception { + var mutex = OresMutex.shared(new int[]{0}); + var owner = mutex.lock(); + AtomicReference>> result = new AtomicReference<>(); + Thread contender = Thread.ofPlatform().start( + () -> result.set(mutex.lockFor(Duration.ZERO))); + contender.join(); + assertTrue(result.get().isEmpty()); + owner.release(); + } + + +} diff --git a/src/test/java/dev/oreslang/OptionResultSemanticsTest.java b/src/test/java/dev/oreslang/OptionResultSemanticsTest.java new file mode 100644 index 00000000..a58825f5 --- /dev/null +++ b/src/test/java/dev/oreslang/OptionResultSemanticsTest.java @@ -0,0 +1,273 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +final class OptionResultSemanticsTest { + @Test + void typechecksOptionAndResultExtractionSurface() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc option_value(Option value): int { + return value.unwrap(); + } + + fnc option_safe(Option value): Result { + return value.unwrap_safe(); + } + + fnc option_default(Option value): int { + return value.unwrap_or(7); + } + + fnc result_value(Result value): int { + return value.expect("expected an integer"); + } + + fnc result_safe(Result value): Result { + return value.unwrap_safe(); + } + + fnc constructors(): Result { + val Option some = Some(42); + val Option none = None; + val Result ok = Ok(some.unwrap()); + if none.is_none(); do + return ok; + else + return Err("impossible"); + fi + } + end + """))); + } + + @Test + void sumTypesRejectCrossVariantAndUnknownMembers() { + IllegalArgumentException option = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc bad(Option value): bool { + return value.is_ok(); + } + """))); + assertTrue(option.getMessage().contains("unknown Option member")); + + IllegalArgumentException result = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc bad(Result value): bool { + return value.is_some(); + } + """))); + assertTrue(result.getMessage().contains("unknown Result member")); + } + + @Test + void resultRequiresTwoExplicitTypeArguments() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc bad(Result value): int { + return 1; + } + end + """))); + } + + @Test + void runtimeSupportsSafeAndPanickingExtraction() throws Exception { + String program = """ + define module app + pub fnc main(): void { + val some = Some(42); + stdio.println(some.is_some()); + stdio.println(some.unwrap()); + + val missing = None; + stdio.println(missing.is_none()); + val safe = missing.unwrap_safe(); + stdio.println(safe.is_err()); + stdio.println(safe); + + val Result err = Err("bad"); + stdio.println(err.unwrap_or(7)); + stdio.println(Ok(9).unwrap()); + return; + } + end + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "option-result.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("true")); + assertTrue(text.contains("42")); + assertTrue(text.contains("Err(OptionUnwrapError(None))")); + assertTrue(text.contains("7")); + assertTrue(text.contains("9")); + } + + @Test + void stringPayloadConstructorsWidenToString() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc option(): Option { + return Some("hello"); + } + + fnc result(): Result { + return Err("bad"); + } + """))); + } + + @Test + void unwrapNoneRaisesLanguagePanicThatOrdinaryCatchCannotSwallow() throws Exception { + String program = """ + define module app + pub fnc main(): void { + try { + None.unwrap(); + } catch (err) { + stdio.println("caught"); + } + return; + } + end + """; + + Source source = Source.newBuilder(OresLanguage.ID, program, "option-panic.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + Throwable thrown; + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .build()) { + thrown = assertThrows(Throwable.class, () -> context.eval(source)); + } + assertNotNull(thrown.getMessage()); + assertTrue(thrown.getMessage().contains("Option::unwrap")); + } + + @Test + void moveOnlyOptionsStayAffineEvenWhenInitializedWithNone() { + IllegalArgumentException moved = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Box as + end + + fnc consume(Option value): void { + return; + } + + fnc bad(): void { + val Option value = None; + consume(value); + consume(value); + return; + } + """))); + assertTrue(moved.getMessage().contains("moved value")); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc consume(Option value): void { + return; + } + + fnc good(): void { + val Option value = None; + consume(value); + consume(value); + return; + } + """))); + } + + @Test + void someMovesMoveOnlyPayloadAndUnwrapConsumesMoveOnlyOption() { + IllegalArgumentException wrappedTwice = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Box as + end + + fnc bad(): void { + let Box box = new Box(); + val first = Some(box); + val second = Some(box); + return; + } + """))); + assertTrue(wrappedTwice.getMessage().contains("moved value")); + + IllegalArgumentException unwrappedTwice = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Box as + end + + fnc bad(): void { + val Option value = Some(new Box()); + val first = value.unwrap(); + val second = value.unwrap(); + return; + } + """))); + assertTrue(unwrappedTwice.getMessage().contains("moved value")); + } + + @Test + void ownedSumValuesCannotHideStackBorrows() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Box as + end + + fnc bad(): void { + let Box box = new Box(); + val maybe = Some(&box); + return; + } + """))); + assertTrue(error.getMessage().contains("cannot store a borrow")); + } + + @Test + void mutexTryLockOptionCanBeSafelyUnwrappedIntoLinearGuard() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define class Box as + end + + fnc good(): void { + let Mutex mutex = Mutex.new(new Box()); + val maybe = mutex.try_lock(); + val guard = maybe.unwrap(); + guard.release(); + return; + } + """))); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Box as + end + + fnc bad(): void { + let Mutex mutex = Mutex.new(new Box()); + val maybe = mutex.try_lock(); + val guard = maybe.unwrap(); + val again = maybe.unwrap(); + guard.release(); + return; + } + """))); + assertTrue(error.getMessage().contains("moved value")); + } +} diff --git a/src/test/java/dev/oreslang/OreslangActorIsolationExampleTest.java b/src/test/java/dev/oreslang/OreslangActorIsolationExampleTest.java new file mode 100644 index 00000000..398b242d --- /dev/null +++ b/src/test/java/dev/oreslang/OreslangActorIsolationExampleTest.java @@ -0,0 +1,44 @@ +package dev.oreslang; + +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Parser; +import dev.oreslang.runtime.CapabilityChecker; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.types.TypeChecker; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +final class OreslangActorIsolationExampleTest { + + @Test + void oreslangExampleKeepsSharedAndPrivateActorDomainsDistinct() throws Exception { + String source = Files.readString( + Path.of("examples/shared-private-actor-isolation.ores")); + + Ast.Program program = TypeChecker.check(Parser.parse(source)); + + Ast.ClassDecl shared = null; + Ast.ClassDecl isolated = null; + + for (Ast.ModuleDecl module : program.modules()) { + for (Ast.Decl decl : module.declarations()) { + if (decl instanceof Ast.ClassDecl actor) { + if (actor.name().equals("SharedCounter")) shared = actor; + if (actor.name().equals("PrivateCounter")) isolated = actor; + } + } + } + + assertNotNull(shared); + assertNotNull(isolated); + assertEquals(Ast.ActorKind.SHARED, shared.actorKind()); + assertEquals(Ast.ActorKind.PRIVATE, isolated.actorKind()); + + assertDoesNotThrow(() -> + CapabilityChecker.check(program, IsolatePolicy.developer())); + } +} diff --git a/src/test/java/dev/oreslang/OwnershipAndClosureTest.java b/src/test/java/dev/oreslang/OwnershipAndClosureTest.java new file mode 100644 index 00000000..568bef9c --- /dev/null +++ b/src/test/java/dev/oreslang/OwnershipAndClosureTest.java @@ -0,0 +1,348 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +final class OwnershipAndClosureTest { + + @Test + void lexicalClosureEscapesAndRetainsMutableCapturedState() throws Exception { + String output = run(""" + fnc makeCounter(): (() => int) { + let int count = 0; + return || -> { + count = count + 1; + return count; + }; + } + + pub routine main(): void { + val (() =>int) counter = makeCounter(); + stdio.stdout.write(counter()); + stdio.stdout.write(counter()); + return; + } + """); + assertEquals("12", output); + } + + @Test + void ordinaryParametersAreImmutableForFieldMutation() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> + TypeChecker.check(Parser.parse(""" + define class Bar as + pub let String foo = "start"; + end + + fnc change(Bar b): void { + b.foo = "foobar"; + return; + } + """))); + assertTrue(error.getMessage().contains("immutable parameter/binding")); + } + + @Test + void ownedMutParameterMayMutateAndReturnOwnership() throws Exception { + String output = run(""" + define class Bar as + pub let String foo = "start"; + end + + fnc change(Bar mut b): Bar { + b.foo = "foobar"; + return b; + } + + pub routine main(): void { + let Bar b = new Bar(); + let Bar changed = change(b); + stdio.stdout.write(changed.foo); + return; + } + """); + assertEquals("foobar", output); + } + + @Test + void mutableBorrowAllowsMutationWithoutMovingOwner() throws Exception { + String output = run(""" + define class Bar as + pub let String foo = "start"; + end + + fnc change(&mut Bar b): void { + b.foo = "borrowed"; + return; + } + + pub routine main(): void { + let Bar b = new Bar(); + change(&mut b); + stdio.stdout.write(b.foo); + return; + } + """); + assertEquals("borrowed", output); + } + + @Test + void immutableBorrowBlocksOverlappingMutableBorrow() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> + TypeChecker.check(Parser.parse(""" + define class Bar as + pub let String foo = "start"; + end + + fnc mutate(&mut Bar b): void { + b.foo = "changed"; + return; + } + + fnc bad(): void { + let Bar b = new Bar(); + val &Bar read = &b; + mutate(&mut b); + stdio.println(read.foo); + return; + } + """))); + assertTrue(error.getMessage().toLowerCase().contains("borrow")); + } + + @Test + void useAfterMoveIsRejected() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> + TypeChecker.check(Parser.parse(""" + define class Bar as + pub let String foo = "start"; + end + + fnc consume(Bar b): void { + return; + } + + fnc bad(): void { + let Bar b = new Bar(); + consume(b); + stdio.println(b.foo); + return; + } + """))); + assertTrue(error.getMessage().contains("use of moved value 'b'")); + } + + @Test + void borrowOfLocalCannotEscapeFunction() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> + TypeChecker.check(Parser.parse(""" + define class Bar as + pub let String foo = "start"; + end + + fnc bad(): &Bar { + let Bar b = new Bar(); + return &b; + } + """))); + assertTrue(error.getMessage().contains("outlive its owner")); + } + + @Test + void borrowedParameterCanBeReturned() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define class Bar as + pub let String foo = "start"; + end + + fnc identity(&Bar b): &Bar { + return b; + } + """))); + } + + + @Test + void multipleImmutableBorrowsMayCoexist() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define class Bar as + pub let String foo = "start"; + end + + fnc ok(): void { + let Bar b = new Bar(); + val &Bar first = &b; + val &Bar second = &b; + stdio.println(first.foo); + stdio.println(second.foo); + return; + } + """))); + } + + @Test + void secondMutableBorrowIsRejected() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> + TypeChecker.check(Parser.parse(""" + define class Bar as + pub let String foo = "start"; + end + + fnc bad(): void { + let Bar b = new Bar(); + val &mut Bar first = &mut b; + val &mut Bar second = &mut b; + stdio.println(first.foo); + stdio.println(second.foo); + return; + } + """))); + assertTrue(error.getMessage().toLowerCase().contains("borrow")); + } + + @Test + void moveWhileBorrowedIsRejected() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> + TypeChecker.check(Parser.parse(""" + define class Bar as + pub let String foo = "start"; + end + + fnc consume(Bar b): void { return; } + + fnc bad(): void { + let Bar b = new Bar(); + val &Bar read = &b; + consume(b); + stdio.println(read.foo); + return; + } + """))); + assertTrue(error.getMessage().contains("cannot move")); + } + + @Test + void lexicalScopeEndsStoredBorrow() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define class Bar as + pub let String foo = "start"; + end + + fnc mutate(&mut Bar b): void { + b.foo = "changed"; + return; + } + + fnc ok(): void { + let Bar b = new Bar(); + if true; do + val &Bar read = &b; + stdio.println(read.foo); + fi + mutate(&mut b); + return; + } + """))); + } + + + @Test + void moveInBothIfBranchesIsAllowedButValueIsMovedAfterJoin() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define class Bar as + pub let String foo = "start"; + end + + fnc consume(Bar b): void { return; } + + fnc ok(bool flag): void { + let Bar b = new Bar(); + if flag; do + consume(b); + else + consume(b); + fi + return; + } + """))); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> + TypeChecker.check(Parser.parse(""" + define class Bar as + pub let String foo = "start"; + end + + fnc consume(Bar b): void { return; } + + fnc bad(bool flag): void { + let Bar b = new Bar(); + if flag; do + consume(b); + else + consume(b); + fi + stdio.println(b.foo); + return; + } + """))); + assertTrue(error.getMessage().contains("use of moved value 'b'")); + } + + @Test + void immutableFieldStaysImmutableEvenThroughMutOwner() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> + TypeChecker.check(Parser.parse(""" + define class Bar as + pub val String foo = "start"; + end + + fnc bad(Bar mut b): void { + b.foo = "changed"; + return; + } + """))); + assertTrue(error.getMessage().contains("field 'Bar.foo' is immutable")); + } + + @Test + void moveOnlyCaptureTransfersIntoClosure() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> + TypeChecker.check(Parser.parse(""" + define class Box as + pub val int value = 7; + end + + fnc bad(): void { + let Box box = new Box(); + val (() => int) read = || -> { + return box.value; + }; + stdio.println(box.value); + return; + } + """))); + assertTrue(error.getMessage().contains("use of moved value 'box'")); + } + + private static String run(String program) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "ownership.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + return output.toString(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/dev/oreslang/ParserTest.java b/src/test/java/dev/oreslang/ParserTest.java new file mode 100644 index 00000000..bbd0f915 --- /dev/null +++ b/src/test/java/dev/oreslang/ParserTest.java @@ -0,0 +1,562 @@ +package dev.oreslang; + +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Lexer; +import dev.oreslang.parser.Parser; +import dev.oreslang.parser.Token; +import dev.oreslang.types.OwnershipChecker; +import dev.oreslang.types.TypeChecker; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +final class ParserTest { + @Test + void supportsMultipleModulesAndComplexNumbers() { + String source = """ + define module math + fnc z(): complex { + return 3 + 4i; + } + end + + define module app + pub fnc main(): void { + const answer = 40 + 2; + [const first, let second] = [1, 2]; + stdio.println("oreslang"); + return; + } + end + """; + + Ast.Program program = TypeChecker.check(Parser.parse(source)); + assertEquals(2, program.modules().size()); + assertEquals("math", program.modules().getFirst().name()); + } + + @Test + void parsesIfDoFiWithCommaAndPipeConditions() { + String source = """ + define module app + fnc choose(bool a, bool b): int { + if a, b | false; do + return 1; + else + return 0; + fi + } + end + """; + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(source))); + } + + @Test + void methodReceiverIsImplicitOrExplicitSelf() { + String source = """ + define module model + define class x as + @Ret + find() { + return self; + } + + @Ret + find_with_arg(self x)(int foo) { + return self; + } + end + end + """; + Ast.Program program = Parser.parse(source); + Ast.ClassDecl klass = (Ast.ClassDecl) program.modules().getFirst().declarations().getFirst(); + assertNull(klass.methods().getFirst().explicitReceiverType()); + assertEquals("x", klass.methods().get(1).explicitReceiverType().name()); + } + + @Test + void lexerRecognizesExecutableAndTypeArrows() { + var tokens = new Lexer("|| -> { return; }; type F = () => void;").scan(); + assertTrue(tokens.stream().anyMatch(t -> t.type() == Token.Type.ARROW)); + assertTrue(tokens.stream().anyMatch(t -> t.type() == Token.Type.FAT_ARROW)); + } + + @Test + void callableDeclarationSyntaxSeparatesCodeFromFunctionTypes() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + pub fnc run(): (() => void) { + return || -> { + return; + }; + } + + pub routine helper = || -> { + return; + } + + pub fnc no_result = || -> { + helper(); + return; + } + + pub routine main = || -> void { + val (() => void) callback = run(); + callback(); + no_result(); + return; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub fnc bad_implicit_void = || -> { + return 1; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + pub fnc bad() => void { return; } + """)); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + type Bad = () -> void; + """)); + } + @Test + void namedExecutableCallablesAcceptColonOrSlimArrowReturns() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc by_colon(int value): int { + return value; + } + + routine by_arrow(int value) -> int { + return value; + } + + define class Box as + pub value() -> int { + return 1; + } + + pub static fnc twice(int value) -> int { + return value * 2; + } + end + + actor Worker { + pub handle(int value) -> int { + return value; + } + } + + pub routine main() -> void { + val box = new Box(); + stdio.stdout.write(by_colon(1)); + stdio.stdout.write(by_arrow(2)); + stdio.stdout.write(box.value()); + stdio.stdout.write(Box.twice(2)); + return; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + fnc still_type_only() => int { + return 1; + } + """)); + } + + @Test + void parsesSharedActorAndAllowsMailboxOwnedStateMutation() { + String source = """ + shared actor Account { + let balance = 100; + + pub fnc withdraw(int amount): void { + self.balance = self.balance - amount; + return; + } + } + """; + + Ast.Program program = Parser.parse(source); + Ast.ClassDecl actor = (Ast.ClassDecl) program.modules().getFirst().declarations().getFirst(); + + assertEquals(Ast.ActorKind.SHARED, actor.actorKind()); + assertEquals("Account", actor.name()); + assertEquals("withdraw", actor.methods().getFirst().name()); + + Ast.Program typed = TypeChecker.check(program); + assertDoesNotThrow(() -> OwnershipChecker.check(typed)); + } + + @Test + void actorFncDefaultsSharedAndIsoactorIsPrivate() { + Ast.Program sharedProgram = Parser.parse(""" + pub actor fnc worker(int value): int { + return value; + } + """); + Ast.FunctionDecl sharedActor = (Ast.FunctionDecl) sharedProgram.modules().getFirst().declarations().getFirst(); + assertEquals(Ast.ActorKind.SHARED, sharedActor.actorKind()); + + Ast.Program explicitSharedProgram = Parser.parse(""" + pub shared actor fnc worker(int value): int { + return value; + } + """); + Ast.FunctionDecl explicitShared = (Ast.FunctionDecl) explicitSharedProgram.modules().getFirst().declarations().getFirst(); + assertEquals(Ast.ActorKind.SHARED, explicitShared.actorKind()); + + Ast.Program privateProgram = Parser.parse(""" + pub isoactor routine worker(int value): int { + return value; + } + """); + Ast.FunctionDecl privateActor = (Ast.FunctionDecl) privateProgram.modules().getFirst().declarations().getFirst(); + assertEquals(Ast.ActorKind.PRIVATE, privateActor.actorKind()); + assertEquals(Ast.CallableKind.ROUTINE, privateActor.kind()); + } + + @Test + void sharedWithoutActorIsRejected() { + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + shared fnc nope(): void { + return; + } + """)); + } + + @Test + void actorCallablesMayBeCalledButActorClassesCannotBeConstructedOrdinarily() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + pub actor fnc worker(int value): int { + return value; + } + + pub fnc good(): int { + return worker(1); + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + shared actor Account { + let int balance = 100; + + pub fnc read(): int { + return self.balance; + } + } + + pub fnc bad(): Account { + return new Account(); + } + """))); + } + + @Test + void rejectsDuplicateAndConflictingActorModifiers() { + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + shared shared actor Account { + let balance = 1; + } + """)); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + pub private actor fnc worker(): void { + return; + } + """)); + } + + @Test + void actorFunctionCannotBeProgramMain() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub actor fnc main(): void { + return; + } + """))); + } + + + @Test + void actorSelfAndMutableActorStateCannotEscapeMailboxTurn() { + assertThrows(IllegalArgumentException.class, () -> { + Ast.Program typed = TypeChecker.check(Parser.parse(""" + shared actor Account { + let balance = 100; + + pub fnc leak(): Account { + return self; + } + } + """)); + OwnershipChecker.check(typed); + }); + + assertThrows(IllegalArgumentException.class, () -> { + Ast.Program typed = TypeChecker.check(Parser.parse(""" + shared actor Account { + let balance = 100; + + pub fnc leak(): &mut Account { + return &mut self; + } + } + """)); + OwnershipChecker.check(typed); + }); + + assertThrows(IllegalArgumentException.class, () -> { + Ast.Program typed = TypeChecker.check(Parser.parse(""" + shared actor Account { + let balance = 100; + + pub fnc leak(): &mut Account { + val alias = &mut self; + return alias; + } + } + """)); + OwnershipChecker.check(typed); + }); + + assertThrows(IllegalArgumentException.class, () -> { + Ast.Program typed = TypeChecker.check(Parser.parse(""" + shared actor Account { + let Array items = [1, 2, 3]; + + pub fnc leak(): Array { + return self.items; + } + } + """)); + OwnershipChecker.check(typed); + }); + } + + @Test + void actorMayReturnCopyLikeStateButCannotPassSelfBorrowToOrdinaryFunction() { + assertDoesNotThrow(() -> { + Ast.Program typed = TypeChecker.check(Parser.parse(""" + shared actor Account { + let balance = 100; + + pub fnc current(): int { + return self.balance; + } + } + """)); + OwnershipChecker.check(typed); + }); + + assertThrows(IllegalArgumentException.class, () -> { + Ast.Program typed = TypeChecker.check(Parser.parse(""" + fnc inspect(&Account account): int { + return 1; + } + + shared actor Account { + let balance = 100; + + pub fnc inspectSelf(): int { + return inspect(&self); + } + } + """)); + OwnershipChecker.check(typed); + }); + } + + + @Test + void actorInheritanceMustPreserveIsolationKind() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + shared actor Parent { + } + + shared actor Child extends Parent { + pub fnc current(): int { + return 1; + } + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + isoactor Parent { + } + + shared actor Child extends Parent { + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + shared actor Child extends Object { + let value = 1; + } + """))); + } + + + +@Test + void parsesReusableUnderscoreDestructureDiscards() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + define module app + pub fnc main(): void { + [const foo, _, let bar] = (1, 2, 3); + [_, _, const tail] = (4, 5, 6); + [const z, _, let y] = (7, 8, 9); + stdio.println(foo); + stdio.println(bar); + stdio.println(tail); + stdio.println(z); + stdio.println(y); + return; + } + end + """)); + + Ast.FunctionDecl main = (Ast.FunctionDecl) program.modules().getFirst().declarations().getFirst(); + Ast.DestructureStmt first = (Ast.DestructureStmt) main.body().getFirst(); + assertFalse(first.bindings().getFirst().isDiscard()); + assertTrue(first.bindings().get(1).isDiscard()); + + Ast.DestructureStmt second = (Ast.DestructureStmt) main.body().get(1); + assertTrue(second.bindings().getFirst().isDiscard()); + assertTrue(second.bindings().get(1).isDiscard()); + assertFalse(second.bindings().get(2).isDiscard()); + } + +@Test + void reservedWordsMayNameMembersButRemainReservedLexically() { + assertDoesNotThrow(() -> Parser.parse(""" + define module app + fnc main(): void { + val mutex = SharedMutex.new(arr[1, 2, 3]); + return; + } + end + """)); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define module app + fnc main(): void { + val new = 1; + return; + } + end + """)); + } + + @Test + void stopDoAndDoneAreReservedButMayNameCallables() { + var tokens = new Lexer("stop do done").scan(); + assertEquals(Token.Type.STOP, tokens.get(0).type()); + assertEquals(Token.Type.DO, tokens.get(1).type()); + assertEquals(Token.Type.DONE, tokens.get(2).type()); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc stop(): int { return 1; } + fnc do(): int { return 2; } + routine done(): int { return 3; } + + fnc total(): int { + return stop() + do() + done(); + } + end + """))); + + assertDoesNotThrow(() -> Parser.parse(""" + import fnc {stop, do, done} from './flow'; + + define module app + fnc main(): void { return; } + end + """)); + + assertDoesNotThrow(() -> Parser.parse(""" + define class Flow as + pub stop(): int { return 1; } + pub do(): int { return 2; } + pub done(): int { return 3; } + end + + define interface FlowApi + fnc stop() => int; + fnc do() => int; + fnc done() => int; + end + """)); + } + + @Test + void stopDoAndDoneCannotBeUsedAsOrdinaryIdentifiers() { + for (String keyword : List.of("stop", "do", "done")) { + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define module app + fnc main(): void { + val %s = 1; + return; + } + end + """.formatted(keyword))); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define module app + fnc take(int %s): void { return; } + end + """.formatted(keyword))); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define class %s as + end + """.formatted(keyword))); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define module app + fnc %s(): int { return 1; } + fnc main(): void { + val callback = %s; + return; + } + end + """.formatted(keyword, keyword))); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define class Flow as + pub %s(): int { return 1; } + end + define module app + fnc main(): void { + val flow = new Flow(); + val callback = flow.%s; + return; + } + end + """.formatted(keyword, keyword))); + } + } + + @Test + void classDeclarationsRequireAsDelimiter() { + assertDoesNotThrow(() -> Parser.parse(""" + define class CounterState as + pub let int value = 10; + end + """)); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> Parser.parse(""" + define class CounterState + pub let int value = 10; + end + """)); + + assertTrue(failure.getMessage().contains("expected 'as' after class header")); + } + +} diff --git a/src/test/java/dev/oreslang/PatternMatchingHardeningTest.java b/src/test/java/dev/oreslang/PatternMatchingHardeningTest.java new file mode 100644 index 00000000..69a720cb --- /dev/null +++ b/src/test/java/dev/oreslang/PatternMatchingHardeningTest.java @@ -0,0 +1,327 @@ +package dev.oreslang; + +import dev.oreslang.parser.Lexer; +import dev.oreslang.parser.Parser; +import dev.oreslang.parser.Token; +import dev.oreslang.types.OwnershipChecker; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +final class PatternMatchingHardeningTest { + @Test + void lexerAndParserSeparateTypeTestsPatternsCasesAndArrowRoles() { + var tokens = new Lexer("is matches match when case default first switch").scan(); + assertTrue(tokens.stream().anyMatch(t -> t.type() == Token.Type.IS)); + assertTrue(tokens.stream().anyMatch(t -> t.type() == Token.Type.MATCHES)); + assertTrue(tokens.stream().anyMatch(t -> t.type() == Token.Type.MATCH)); + assertTrue(tokens.stream().anyMatch(t -> t.type() == Token.Type.WHEN)); + assertTrue(tokens.stream().anyMatch(t -> t.type() == Token.Type.CASE)); + assertTrue(tokens.stream().anyMatch(t -> t.type() == Token.Type.DEFAULT)); + assertTrue(tokens.stream().anyMatch(t -> t.type() == Token.Type.IDENT && t.lexeme().equals("first")), + "first is contextual in 'match first', not a globally reserved identifier"); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + fnc bad(bool flag): void { + if flag { + return; + } + } + """)); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc good(bool flag): void { + if flag { + return; + } fi + return; + } + """))); + + IllegalArgumentException fatMatch = assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + fnc bad(Option value): void { + match value + Some(v) => { return; } + None -> { return; } + end + } + """)); + assertTrue(fatMatch.getMessage().contains("slim arrow")); + + IllegalArgumentException fatSwitch = assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + fnc bad(int value): void { + switch value + case 1 => { return; } + default -> { return; } + end + } + """)); + assertTrue(fatSwitch.getMessage().contains("slim arrow")); + } + + @Test + void isNarrowsAndBindsInsideIfFi() { + assertDoesNotThrow(() -> { + var typed = TypeChecker.check(Parser.parse(""" + define class Animal as + end + + define class Dog extends Animal as + end + + fnc acceptDog(Dog dog): int { + return 7; + } + + fnc classify(Animal animal): int { + if animal is Dog dog then + return acceptDog(dog); + else + return 0; + fi + } + """)); + OwnershipChecker.check(typed); + }); + } + + @Test + void matchesCanDestructureSumTypesInsideIfFi() { + assertDoesNotThrow(() -> { + var typed = TypeChecker.check(Parser.parse(""" + fnc valueOrZero(Option value): int { + if value matches Some(inner) then + return inner; + else + return 0; + fi + } + """)); + OwnershipChecker.check(typed); + }); + } + + @Test + void exclusiveMatchProvesConstructorsAndRejectsOverlaps() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc valueOrZero(Option value): int { + match value + Some(inner) -> { return inner; } + None -> { return 0; } + end + } + """))); + + IllegalArgumentException overlap = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Animal as + end + + define class Dog extends Animal as + end + + fnc classify(Animal animal): int { + match animal + is Dog dog -> { return 1; } + is Animal any -> { return 2; } + end + } + """))); + assertTrue(overlap.getMessage().contains("overlapping match arms")); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define class Animal as + end + + define class Dog extends Animal as + end + + fnc classify(Animal animal): int { + match first animal + is Dog dog -> { return 1; } + is Animal any -> { return 2; } + end + } + """))); + } + + @Test + void exclusiveMatchCanProveSimpleGuardPartitionsAndFallbackIsComplement() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc sign(int n): int { + match n + is int x when x < 0 -> { return -1; } + is int x when x == 0 -> { return 0; } + is int x when x > 0 -> { return 1; } + else -> { return 99; } + end + } + """))); + + IllegalArgumentException overlap = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc ambiguous(int n): int { + match n + is int x when x >= 0 -> { return 1; } + is int x when x <= 10 -> { return 2; } + else -> { return 3; } + end + } + """))); + assertTrue(overlap.getMessage().contains("overlapping match arms")); + } + + @Test + void matchRequiresExhaustivenessWithoutFallback() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc incomplete(Option value): int { + match value + Some(inner) -> { return inner; } + end + } + """))); + assertTrue(error.getMessage().contains("non-exhaustive match")); + } + + @Test + void switchIsConstantDispatchAndRejectsDuplicateCases() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc classify(int value): int { + switch value + case 1 -> { return 10; } + case 2, 3 -> { return 20; } + default -> { return 0; } + end + } + """))); + + IllegalArgumentException duplicate = assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc classify(int value): int { + switch value + case 1 -> { return 10; } + case 1 -> { return 20; } + default -> { return 0; } + end + } + """))); + assertTrue(duplicate.getMessage().contains("duplicate switch case")); + } + + @Test + void castsAreCheckedAndOptionalWithoutJavaHostTypeSemantics() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define class Animal as + end + + define class Dog extends Animal as + end + + fnc checked(Animal animal): Dog { + return animal as Dog; + } + + fnc optional(Animal animal): Option { + return animal as? Dog; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Dog as + end + + fnc impossible(int value): Dog { + return value as Dog; + } + """))); + } + + @Test + void runtimeExecutesNativeSemanticTypeAndPatternRelations() throws Exception { + String program = """ + define class Animal as + end + + define class Dog extends Animal as + end + + fnc classify(Animal animal): int { + if animal is Dog dog then + return 7; + else + return 0; + fi + } + + fnc fromOption(Option value): int { + match value + Some(inner) -> { return inner; } + None -> { return 0; } + end + } + + fnc fromSwitch(int value): int { + switch value + case 1 -> { return 11; } + case 2 -> { return 22; } + default -> { return 0; } + end + } + + pub fnc main(): void { + val Animal animal = new Dog(); + stdio.println(classify(animal)); + stdio.println(fromOption(Some(42))); + stdio.println(fromSwitch(2)); + return; + } + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "pattern-runtime.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("7")); + assertTrue(text.contains("42")); + assertTrue(text.contains("22")); + } + + @Test + void refinementAliasCannotDuplicateMoveOnlyOwnership() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> { + var typed = TypeChecker.check(Parser.parse(""" + define class Animal as + end + + define class Dog extends Animal as + end + + fnc consume(Dog dog): void { + return; + } + + fnc bad(Animal animal): void { + if animal is Dog dog then + consume(dog); + consume(dog); + fi + return; + } + """)); + OwnershipChecker.check(typed); + }); + assertTrue(error.getMessage().contains("moved value")); + } +} diff --git a/src/test/java/dev/oreslang/PolyglotFeatureTest.java b/src/test/java/dev/oreslang/PolyglotFeatureTest.java new file mode 100644 index 00000000..b97b80e3 --- /dev/null +++ b/src/test/java/dev/oreslang/PolyglotFeatureTest.java @@ -0,0 +1,89 @@ +package dev.oreslang; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class PolyglotFeatureTest { + @Test + void executesNamespacesCollectionsAssignmentAndInheritedMethods() throws Exception { + String program = """ + define module math + pub fnc add(int a, int b): int { return a + b; } + end + + define module model + define class A as + pub value(): int { return 7; } + end + define class B extends A as + end + end + + define module app + pub fnc main(): void { + let answer = math.add(1, 2); + answer = answer + 4; + val values = arr[answer, 9]; + val person = obj{name: "ores"}; + val inherited = new B(); + stdio.println(values[0]); + stdio.println(person.name); + stdio.println(inherited.value()); + return; + } + end + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "features.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("7")); + assertTrue(text.contains("ores")); + } + +@Test + void executesRepeatedUnderscoreDestructureDiscards() throws Exception { + String program = """ + define module app + pub fnc main(): void { + [const foo, _, let bar] = (1, 200, 3); + [const z, _, let y] = (4, 500, 6); + [_, _, const tail] = (700, 800, 9); + stdio.println(foo + bar + z + y + tail); + return; + } + end + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "destructure-discard.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + assertTrue(output.toString(StandardCharsets.UTF_8).contains("23")); + } + +} diff --git a/src/test/java/dev/oreslang/PolyglotSmokeTest.java b/src/test/java/dev/oreslang/PolyglotSmokeTest.java new file mode 100644 index 00000000..5ce1bc8f --- /dev/null +++ b/src/test/java/dev/oreslang/PolyglotSmokeTest.java @@ -0,0 +1,38 @@ +package dev.oreslang; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class PolyglotSmokeTest { + @Test + void evaluatesOreslangThroughGraalPolyglotContext() throws Exception { + String program = """ + define module app + pub fnc main(): void { + stdio.println("oreslang-ok"); + return; + } + end + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "smoke.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + assertTrue(output.toString(StandardCharsets.UTF_8).contains("oreslang-ok")); + } +} diff --git a/src/test/java/dev/oreslang/PrivateActorIsolationTest.java b/src/test/java/dev/oreslang/PrivateActorIsolationTest.java new file mode 100644 index 00000000..1b6bb7a6 --- /dev/null +++ b/src/test/java/dev/oreslang/PrivateActorIsolationTest.java @@ -0,0 +1,486 @@ +package dev.oreslang; + +import dev.oreslang.runtime.ActorRuntime; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.runtime.OresMutex; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +final class PrivateActorIsolationTest { + + @Test + void compilerPrivateFactoryRejectsCapturedHostState() { + try (ActorRuntime runtime = new ActorRuntime()) { + StringBuilder hostMutable = new StringBuilder("host"); + + SecurityException failure = assertThrows( + SecurityException.class, + () -> runtime.spawnPrivate(factoryContext -> { + hostMutable.append("captured"); + return (message, context) -> { }; + })); + + assertTrue(failure.getMessage().contains("stateless")); + } + } + + + @Test + void captureFreeFactoryStillCannotHideMutableJvmStateInBehavior() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var ref = runtime.spawnPrivate(factoryContext -> { + int[] ordinaryJvmState = new int[]{41}; + return (message, context) -> ordinaryJvmState[0]++; + }); + + ref.send("run"); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(ref.failure().isPresent()); + assertInstanceOf(SecurityException.class, ref.failure().orElseThrow()); + assertTrue(ref.failure().orElseThrow().getMessage().contains("context.privateMemory")); + assertEquals(0L, runtime.privateMemoryBytes()); + } + } + + @Test + void trustedHostEscapeHatchIsExplicitAndUnavailableToAdversarialPolicy() { + StringBuilder hostMutable = new StringBuilder("host"); + + try (ActorRuntime runtime = new ActorRuntime()) { + var ref = runtime.spawnPrivateTrusted(factoryContext -> { + hostMutable.append("-trusted"); + return (message, context) -> context.self().stop(); + }); + ref.send("run"); + assertDoesNotThrow(() -> assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS))); + assertEquals("host-trusted", hostMutable.toString()); + } + + IsolatePolicy strict = IsolatePolicy.strictFaas(); + try (ActorRuntime runtime = new ActorRuntime(strict)) { + assertThrows(SecurityException.class, () -> + runtime.spawnPrivateTrusted( + strict, + factoryContext -> (message, context) -> { })); + } + } + + @Test + void privateActorPolicyStripsAllSharedMemoryCapabilities() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var ref = runtime.spawnPrivate(factoryContext -> { + if (factoryContext.policy().allows(IsolatePolicy.Capability.SHARED_MEMORY)) { + throw new AssertionError("private actor retained SHARED_MEMORY"); + } + if (factoryContext.policy().allows(IsolatePolicy.Capability.ACTOR_SHARE_READONLY)) { + throw new AssertionError("private actor retained ACTOR_SHARE_READONLY"); + } + return (message, context) -> context.self().stop(); + }); + + ref.send("check"); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(ref.failure().isEmpty()); + } + } + + @Test + void privateMailboxIsolationCopiesMutableContainersBeforeEnqueue() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch received = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + ArrayList mutable = new ArrayList<>(List.of("before")); + + var ref = runtime.spawnPrivate(() -> (message, context) -> { + observed.set(message); + received.countDown(); + context.self().stop(); + }); + + ref.send(mutable); + mutable.add("after"); + + assertTrue(received.await(2, TimeUnit.SECONDS)); + assertEquals(List.of("before"), observed.get()); + @SuppressWarnings("unchecked") + List isolated = (List) observed.get(); + assertThrows(UnsupportedOperationException.class, () -> isolated.add("mutate")); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + } + } + + @Test + void readonlySharedInputIsCopiedIntoPrivateDomainNotRetainedAsHandle() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + ActorRuntime.Shared> shared = runtime.shareReadonly(List.of("a", "b")); + CountDownLatch received = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + + var ref = runtime.spawnPrivate(() -> (message, context) -> { + observed.set(message); + received.countDown(); + context.self().stop(); + }); + + ref.send(shared); + + assertTrue(received.await(2, TimeUnit.SECONDS)); + assertEquals(List.of("a", "b"), observed.get()); + assertFalse(observed.get() instanceof ActorRuntime.Shared); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + } + } + + @Test + void privateActorRejectsWritableSharedMemoryHandlesAtMailboxBoundary() { + try (ActorRuntime runtime = new ActorRuntime()) { + var privateRef = runtime.spawnPrivate(() -> (message, context) -> { }); + var syncCell = runtime.syncCell(1); + var sharedMutex = OresMutex.shared(new int[]{1}); + + assertThrows(IllegalArgumentException.class, () -> privateRef.send(syncCell)); + assertThrows(RuntimeException.class, () -> privateRef.send(sharedMutex)); + privateRef.stop(); + } + } + + @Test + void privateActorCannotCreateOrDereferenceSharedMemoryFromInsideTurn() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + ActorRuntime.Shared> shared = runtime.shareReadonly(List.of("outside")); + CountDownLatch checked = new CountDownLatch(1); + AtomicReference first = new AtomicReference<>(); + AtomicReference second = new AtomicReference<>(); + + var ref = runtime.spawnPrivate(() -> (message, context) -> { + try { + context.runtime().shareReadonly(List.of("inside")); + } catch (Throwable failure) { + first.set(failure); + } + try { + shared.value(); + } catch (Throwable failure) { + second.set(failure); + } + checked.countDown(); + context.self().stop(); + }); + + ref.send("check"); + assertTrue(checked.await(2, TimeUnit.SECONDS)); + assertNotNull(first.get()); + assertNotNull(second.get()); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + } + } + + @Test + void directPrivateMemoryIsActorOwnedAndReleasedOnlyAfterFinalization() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var ref = runtime.spawnPrivate(factoryContext -> { + ActorRuntime.PrivateMemoryBlock block = + factoryContext.privateMemory().orElseThrow().allocatePrivateBytes(256); + block.writeByte(0, (byte) 11); + + return (message, context) -> { + if (block.readByte(0) != 11) throw new AssertionError("private block state changed"); + block.writeByte(1, message); + if (block.readByte(1) != message) throw new AssertionError("private block write failed"); + context.self().stop(); + }; + }); + + ref.send((byte) 77); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertEquals(0L, runtime.privateMemoryBytes()); + assertTrue(ref.failure().isEmpty()); + } + } + + @Test + void leakedDirectMemoryHandleCannotBeAccessedOutsideOwningActor() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + AtomicReference leaked = new AtomicReference<>(); + CountDownLatch created = new CountDownLatch(1); + + var ref = runtime.spawnPrivateTrusted(factoryContext -> { + ActorRuntime.PrivateMemoryBlock block = + factoryContext.privateMemory().orElseThrow().allocatePrivateBytes(32); + block.writeByte(0, (byte) 9); + leaked.set(block); + created.countDown(); + return (message, context) -> { }; + }); + + ref.send("initialize"); + assertTrue(created.await(2, TimeUnit.SECONDS)); + + assertThrows(IllegalStateException.class, () -> leaked.get().readByte(0)); + + ref.stop(); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(leaked.get().closed()); + assertEquals(0L, runtime.privateMemoryBytes()); + } + } + + @Test + void privateMemoryHandleCannotCrossToAnotherActor() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + AtomicReference leaked = new AtomicReference<>(); + CountDownLatch created = new CountDownLatch(1); + + var owner = runtime.spawnPrivateTrusted(factoryContext -> { + leaked.set(factoryContext.privateMemory().orElseThrow().allocatePrivateBytes(16)); + created.countDown(); + return (message, context) -> { }; + }); + owner.send("initialize"); + assertTrue(created.await(2, TimeUnit.SECONDS)); + + var other = runtime.spawnPrivate(() -> (message, context) -> { }); + assertThrows(IllegalArgumentException.class, () -> other.send(leaked.get())); + + owner.stop(); + other.stop(); + assertTrue(owner.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(other.awaitTermination(2, TimeUnit.SECONDS)); + } + } + + @Test + void privateAndSharedActorsStayOnDifferentCarrierPools() throws Exception { + var config = new ActorRuntime.DispatcherConfig(1, 1, 8); + try (ActorRuntime runtime = new ActorRuntime(IsolatePolicy.developer(), config)) { + CountDownLatch done = new CountDownLatch(2); + AtomicReference privateThread = new AtomicReference<>(); + AtomicReference sharedThread = new AtomicReference<>(); + + var privateRef = runtime.spawnPrivate(() -> (message, context) -> { + privateThread.set(Thread.currentThread().getName()); + done.countDown(); + context.self().stop(); + }); + var sharedRef = runtime.spawnShared(() -> (message, context) -> { + sharedThread.set(Thread.currentThread().getName()); + done.countDown(); + context.self().stop(); + }); + + privateRef.send("private"); + sharedRef.send("shared"); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertTrue(privateThread.get().startsWith("ores-private-actor-dispatcher-")); + assertTrue(sharedThread.get().startsWith("ores-shared-actor-dispatcher-")); + assertTrue(privateRef.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(sharedRef.awaitTermination(2, TimeUnit.SECONDS)); + } + } + @Test + void privateActorCannotEscalateBySpawningSharedChild() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var parent = runtime.spawnPrivate(factoryContext -> (message, context) -> { + try { + context.runtime().spawnShared( + childContext -> (childMessage, childTurn) -> { }); + throw new AssertionError("private actor spawned shared child"); + } catch (SecurityException expected) { + if (!expected.getMessage().contains("SHARED_MEMORY")) throw expected; + } + context.self().stop(); + }); + + parent.send("check"); + assertTrue(parent.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(parent.failure().isEmpty()); + } + } + + @Test + void privateChildInheritsParentStrippedCapabilities() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var parent = runtime.spawnPrivate(factoryContext -> (message, context) -> { + ActorRuntime.ActorRef child = context.runtime().spawnPrivate(childContext -> { + if (childContext.policy().allows(IsolatePolicy.Capability.SHARED_MEMORY)) { + throw new AssertionError("private child regained SHARED_MEMORY"); + } + if (childContext.policy().allows(IsolatePolicy.Capability.ACTOR_SHARE_READONLY)) { + throw new AssertionError("private child regained ACTOR_SHARE_READONLY"); + } + return (childMessage, childTurn) -> childTurn.self().stop(); + }); + child.send("run"); + context.self().stop(); + }); + + parent.send("run"); + assertTrue(parent.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(parent.failure().isEmpty()); + } + } + + @Test + void actorCodeCannotUseTrustedHostConstructionEscapeHatches() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var parent = runtime.spawnPrivate(factoryContext -> (message, context) -> { + assertThrows(SecurityException.class, () -> + context.runtime().spawnPrivateTrusted( + childContext -> (childMessage, childTurn) -> { })); + assertThrows(SecurityException.class, () -> + context.runtime().spawnPrivate( + () -> (childMessage, childTurn) -> { })); + context.self().stop(); + }); + + parent.send("check"); + assertTrue(parent.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(parent.failure().isEmpty()); + } + } + + + + @Test + void privateBehaviorCannotPersistPrimitiveStateOnJvmHeap() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var ref = runtime.spawnPrivate(factoryContext -> mutablePrimitiveBehavior()); + + ref.send("increment"); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(ref.failure().isPresent()); + assertInstanceOf(SecurityException.class, ref.failure().orElseThrow()); + assertTrue(ref.failure().orElseThrow().getMessage().contains("context.privateMemory")); + assertEquals(0L, runtime.privateMemoryBytes()); + } + } + + private static ActorRuntime.Behavior mutablePrimitiveBehavior() { + return new ActorRuntime.Behavior<>() { + private int counter; + + @Override + public void onMessage(String message, ActorRuntime.ActorContext context) { + counter++; + } + }; + } + + @Test + void privateBehaviorCannotRetainNewJvmMutableStateAcrossTurns() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var ref = runtime.spawnPrivate(factoryContext -> mutableJvmStateBehavior()); + + ref.send("store"); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(ref.failure().isPresent()); + assertInstanceOf(SecurityException.class, ref.failure().orElseThrow()); + assertTrue(ref.failure().orElseThrow().getMessage().contains("context.privateMemory")); + assertEquals(0L, runtime.privateMemoryBytes()); + } + } + + private static ActorRuntime.Behavior mutableJvmStateBehavior() { + return new ActorRuntime.Behavior<>() { + private Object retained; + + @Override + public void onMessage(String message, ActorRuntime.ActorContext context) { + retained = new byte[]{1, 2, 3}; + } + }; + } + + + @Test + void privateBehaviorCannotUseMutableStaticJvmState() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var ref = runtime.spawnPrivate(factoryContext -> mutableStaticBehavior()); + + ref.send("run"); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(ref.failure().isPresent()); + assertInstanceOf(SecurityException.class, ref.failure().orElseThrow()); + assertTrue(ref.failure().orElseThrow().getMessage().contains("static JVM state")); + } + } + + private static ActorRuntime.Behavior mutableStaticBehavior() { + return new ActorRuntime.Behavior<>() { + private static int sharedCounter; + + @Override + public void onMessage(String message, ActorRuntime.ActorContext context) { + sharedCounter++; + } + }; + } + + @Test + void privateBehaviorCannotHideSharedMutableObjectBehindStaticFinal() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + var ref = runtime.spawnPrivate(factoryContext -> staticFinalMutableBehavior()); + + ref.send("run"); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(ref.failure().isPresent()); + assertInstanceOf(SecurityException.class, ref.failure().orElseThrow()); + assertTrue(ref.failure().orElseThrow().getMessage().contains("shared static object")); + } + } + + private static ActorRuntime.Behavior staticFinalMutableBehavior() { + return new ActorRuntime.Behavior<>() { + private static final AtomicInteger SHARED_COUNTER = new AtomicInteger(); + + @Override + public void onMessage(String message, ActorRuntime.ActorContext context) { + SHARED_COUNTER.incrementAndGet(); + } + }; + } + + + @Test + void privateFactoryCannotUseMutableStaticJvmState() { + try (ActorRuntime runtime = new ActorRuntime()) { + assertThrows(SecurityException.class, () -> + runtime.spawnPrivate(new ActorRuntime.BehaviorFactory<>() { + private static int SHARED_COUNTER; + + @Override + public ActorRuntime.Behavior create(ActorRuntime.ActorContext context) { + SHARED_COUNTER++; + return (message, turn) -> { }; + } + })); + } + } + + @Test + void privateFactoryCannotHideSharedMutableObjectBehindStaticFinal() { + try (ActorRuntime runtime = new ActorRuntime()) { + assertThrows(SecurityException.class, () -> + runtime.spawnPrivate(new ActorRuntime.BehaviorFactory<>() { + private static final AtomicInteger SHARED_COUNTER = new AtomicInteger(); + + @Override + public ActorRuntime.Behavior create(ActorRuntime.ActorContext context) { + SHARED_COUNTER.incrementAndGet(); + return (message, turn) -> { }; + } + })); + } + } + + +} diff --git a/src/test/java/dev/oreslang/PrivateVisibilityTest.java b/src/test/java/dev/oreslang/PrivateVisibilityTest.java new file mode 100644 index 00000000..9c4c8ec1 --- /dev/null +++ b/src/test/java/dev/oreslang/PrivateVisibilityTest.java @@ -0,0 +1,359 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.runtime.ExecutionProfile; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.runtime.LinkedProgramRunner; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +final class PrivateVisibilityTest { + + @TempDir + Path temp; + + @Test + void declaringClassMayAccessPrivateMembersAcrossInstancesAndClosures() throws Exception { + String output = run(""" + define class Vault as + private let int secret = Vault.initial_secret(); + + private static fnc initial_secret(): int { + return 1; + } + + private reveal(): int { + return self.secret; + } + + private static fnc hidden(): int { + return 7; + } + + pub read_other(&Vault other): int { + return other.reveal(); + } + + pub destructured(Vault other): int { + val { secret } = other; + return secret; + } + + pub static fnc expose_hidden(): int { + return Vault.hidden(); + } + + pub static fnc hidden_callback(): (() => int) { + return || -> { + return Vault.hidden(); + }; + } + end + + pub routine main(): void { + val left = new Vault(); + val right = new Vault(); + val Fnc callback = Vault.hidden_callback(); + + stdio.stdout.write(left.read_other(&right)); + stdio.stdout.write(":"); + stdio.stdout.write(left.destructured(right)); + stdio.stdout.write(":"); + stdio.stdout.write(Vault.expose_hidden()); + stdio.stdout.write(":"); + stdio.stdout.write(callback()); + return; + } + """); + + assertEquals("1:1:7:7", output); + } + + @Test + void externalAndSubclassPrivateAccessIsRejectedStatically() { + IllegalArgumentException privateField = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Vault as + private val int secret = 1; + end + + fnc bad(Vault vault): int { + return vault.secret; + } + """))); + assertTrue(privateField.getMessage().contains("private field")); + + IllegalArgumentException privateMethod = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Vault as + private reveal(): int { + return 1; + } + end + + fnc bad(Vault vault): int { + return vault.reveal(); + } + """))); + assertTrue(privateMethod.getMessage().contains("private method")); + + IllegalArgumentException privateStatic = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Vault as + private static fnc hidden(): int { + return 1; + } + end + + fnc bad(): int { + return Vault.hidden(); + } + """))); + assertTrue(privateStatic.getMessage().contains("private static function")); + + IllegalArgumentException privateStaticValue = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Vault as + private static fnc hidden(): int { + return 1; + } + end + + fnc bad(): void { + val Fnc callback = Vault.hidden; + return; + } + """))); + assertTrue(privateStaticValue.getMessage().contains("private static function")); + + IllegalArgumentException subclass = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Vault as + private reveal(): int { + return 1; + } + end + + define class Child extends Vault as + pub expose(): int { + return self.reveal(); + } + end + """))); + assertTrue(subclass.getMessage().contains("private method")); + } + + @Test + void nlexLambdaDoesNotRetainDeclaringClassPrivateAuthority() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define class Vault as + private static fnc hidden(): int { + return 1; + } + + pub static fnc lexical(): (() => int) { + return || -> { + return Vault.hidden(); + }; + } + end + """))); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Vault as + private static fnc hidden(): int { + return 1; + } + + pub static fnc isolated(): (() => int) { + return nlex || -> { + return Vault.hidden(); + }; + } + end + """))); + assertTrue(failure.getMessage().contains("private static function")); + } + + @Test + void privateIteratorVisibilityIsEnforcedStaticallyButDeclaringClassMayDispatchIt() throws Exception { + IllegalArgumentException outside = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + define class Bag as + private [Symbol.iterator](): Array { + return arr[1, 2]; + } + end + + fnc bad(Bag bag): void { + for value of bag do + stdio.stdout.write(value); + done + return; + } + """))); + assertTrue(outside.getMessage().contains("private method")); + assertTrue(outside.getMessage().contains("Symbol.iterator")); + + String output = run(""" + define class Bag as + private [Symbol.iterator](): Array { + return arr[3, 4]; + } + + pub write_self(): void { + for value of self do + stdio.stdout.write(value); + done + return; + } + end + + pub routine main(): void { + val bag = new Bag(); + bag.write_self(); + return; + } + """); + assertEquals("34", output); + } + + @Test + void wildcardLinkedCodeCannotBypassPrivateRuntimeVisibility() throws Exception { + Path child = temp.resolve("vault.ores"); + Files.writeString(child, """ + define class Vault as + private val int secret = 11; + + private reveal(): int { + return self.secret; + } + + private static fnc hidden(): int { + return 13; + } + + private [Symbol.iterator](): Array { + return arr[1, 2, 3]; + } + end + + pub fnc make_vault(): Vault { + return new Vault(); + } + """); + + Path staticMain = temp.resolve("static-main.ores"); + Files.writeString(staticMain, """ + import * as external from "./vault.ores"; + + pub routine main(): void { + stdio.stdout.write(external.Vault.hidden()); + return; + } + """); + + PolyglotException staticFailure = assertThrows( + PolyglotException.class, + () -> runLinked(staticMain)); + assertTrue(staticFailure.getMessage().contains("private static function")); + + Path methodMain = temp.resolve("method-main.ores"); + Files.writeString(methodMain, """ + import * as external from "./vault.ores"; + + pub routine main(): void { + val vault = external.make_vault(); + stdio.stdout.write(vault.reveal()); + return; + } + """); + + PolyglotException methodFailure = assertThrows( + PolyglotException.class, + () -> runLinked(methodMain)); + assertTrue(methodFailure.getMessage().contains("private method")); + + Path destructureMain = temp.resolve("destructure-main.ores"); + Files.writeString(destructureMain, """ + import * as external from "./vault.ores"; + + pub routine main(): void { + val vault = external.make_vault(); + val { secret } = vault; + stdio.stdout.write(secret); + return; + } + """); + + PolyglotException destructureFailure = assertThrows( + PolyglotException.class, + () -> runLinked(destructureMain)); + assertTrue(destructureFailure.getMessage().contains("private field")); + + Path iteratorMain = temp.resolve("iterator-main.ores"); + Files.writeString(iteratorMain, """ + import * as external from "./vault.ores"; + + pub routine main(): void { + val vault = external.make_vault(); + for value of vault do + stdio.stdout.write(value); + done + return; + } + """); + + assertThrows( + Exception.class, + () -> runLinked(iteratorMain)); + } + + private static String run(String program) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "private-visibility.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + return output.toString(StandardCharsets.UTF_8); + } + + private static String runLinked(Path entry) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + LinkedProgramRunner.run( + entry, + IsolatePolicy.developer(), + ExecutionProfile.serverJit(), + Set.of(), + Map.of(), + output, + new ByteArrayOutputStream()); + return output.toString(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/dev/oreslang/ProjectManifestImportTest.java b/src/test/java/dev/oreslang/ProjectManifestImportTest.java new file mode 100644 index 00000000..85906334 --- /dev/null +++ b/src/test/java/dev/oreslang/ProjectManifestImportTest.java @@ -0,0 +1,378 @@ +package dev.oreslang; + +import dev.oreslang.config.OresProjectConfig; +import dev.oreslang.runtime.ExecutionProfile; +import dev.oreslang.runtime.IsolatePolicy; +import dev.oreslang.runtime.LinkedProgramRunner; +import org.graalvm.polyglot.PolyglotException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ProjectManifestImportTest { + @TempDir Path temp; + + @Test + void manifestSourceRootsResolveBareImportsThroughCompileAndRuntimeLinking() throws Exception { + Path project = temp.resolve("project"); + Path src = project.resolve("src"); + Files.createDirectories(src.resolve("pkg")); + Path main = src.resolve("main.ores"); + + Files.writeString(project.resolve(OresProjectConfig.MANIFEST_NAME), """ + schema_version = "1" + + [project] + name = "manifest-demo" + root = "." + + [source] + roots = ["src"] + + [entrypoints] + main = "src/main.ores" + """); + Files.writeString(src.resolve("pkg/greeting.ores"), """ + pub fnc greeting() : string { + return "manifest-path"; + } + """); + Files.writeString(main, """ + import fnc greeting from "pkg/greeting"; + + pub fnc main() : void { + stdio.println(greeting()); + return; + } + """); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + var build = LinkedProgramRunner.run( + main, + IsolatePolicy.developer(), + ExecutionProfile.serverJit(), + Set.of(), + Map.of(), + out, + new ByteArrayOutputStream()); + + assertEquals(2, build.units().size()); + assertTrue(out.toString(StandardCharsets.UTF_8).contains("manifest-path")); + } + + @Test + void importedWildcardActorCallableCannotBypassActorCompositionBoundary() throws Exception { + Path app = temp.resolve("actor-import"); + Files.createDirectories(app); + Path child = app.resolve("child.ores"); + Path main = app.resolve("main.ores"); + + Files.writeString(child, """ + pub actor fnc child(int value): int { + return value + 1; + } + """); + + Files.writeString(main, """ + import * as external from "./child.ores"; + + actor fnc parent(int value): int { + return external.child(value); + } + + pub routine main(): void { + stdio.stdout.write(parent(1)); + return; + } + """); + + PolyglotException failure = assertThrows( + PolyglotException.class, + () -> LinkedProgramRunner.run( + main, + IsolatePolicy.developer(), + ExecutionProfile.serverJit(), + Set.of(), + Map.of(), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + + assertTrue(failure.getMessage().contains("mailbox-oriented actor composition")); + } + + @Test + void wildcardNamespaceCannotExtractRoutineOrActorCallableValues() throws Exception { + Path app = temp.resolve("wildcard-direct-only"); + Files.createDirectories(app); + Path child = app.resolve("child.ores"); + Path routineMain = app.resolve("routine-main.ores"); + Path actorMain = app.resolve("actor-main.ores"); + + Files.writeString(child, """ + pub routine direct_only(int value): int { + return value + 1; + } + + pub actor fnc actor_only(int value): int { + return value + 1; + } + """); + + Files.writeString(routineMain, """ + import * as external from "./child.ores"; + + pub routine main(): void { + val callback = external.direct_only; + return; + } + """); + + Files.writeString(actorMain, """ + import * as external from "./child.ores"; + + pub routine main(): void { + val callback = external.actor_only; + return; + } + """); + + PolyglotException routineFailure = assertThrows( + PolyglotException.class, + () -> LinkedProgramRunner.run( + routineMain, + IsolatePolicy.developer(), + ExecutionProfile.serverJit(), + Set.of(), + Map.of(), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + assertTrue(routineFailure.getMessage().contains("direct-call-only")); + + PolyglotException actorFailure = assertThrows( + PolyglotException.class, + () -> LinkedProgramRunner.run( + actorMain, + IsolatePolicy.developer(), + ExecutionProfile.serverJit(), + Set.of(), + Map.of(), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + assertTrue(actorFailure.getMessage().contains("scheduler-dispatched")); + assertTrue(actorFailure.getMessage().contains("cannot be extracted")); + } + + @Test + void importedTailCallsPreserveCallerReturnContracts() throws Exception { + Path app = temp.resolve("tail-contract-import"); + Files.createDirectories(app); + Path child = app.resolve("child.ores"); + Path namedMain = app.resolve("named-main.ores"); + Path wildcardMain = app.resolve("wildcard-main.ores"); + + Files.writeString(child, """ + pub fnc wrong(): String { + return "not-an-int"; + } + """); + + Files.writeString(namedMain, """ + import fnc wrong from "./child.ores"; + + fnc wrapped(): int { + return wrong(); + } + + pub routine main(): void { + stdio.stdout.write(wrapped()); + return; + } + """); + + Files.writeString(wildcardMain, """ + import * as external from "./child.ores"; + + fnc wrapped(): int { + return external.wrong(); + } + + pub routine main(): void { + stdio.stdout.write(wrapped()); + return; + } + """); + + for (Path entry : List.of(namedMain, wildcardMain)) { + PolyglotException failure = assertThrows( + PolyglotException.class, + () -> LinkedProgramRunner.run( + entry, + IsolatePolicy.developer(), + ExecutionProfile.serverJit(), + Set.of(), + Map.of(), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + assertTrue(failure.getMessage().contains("function wrapped returned String")); + assertTrue(failure.getMessage().contains("name=int")); + } + } + + @Test + void importFncRejectsDirectOnlyRoutineAndActorCallableAtLinkValidation() throws Exception { + Path app = temp.resolve("fnc-import-contract"); + Files.createDirectories(app); + Path routineUnit = app.resolve("routine.ores"); + Path actorUnit = app.resolve("actor.ores"); + Path routineMain = app.resolve("routine-main.ores"); + Path actorMain = app.resolve("actor-main.ores"); + + Files.writeString(routineUnit, """ + pub routine work(int value): int { + return value + 1; + } + """); + Files.writeString(actorUnit, """ + pub actor fnc work(int value): int { + return value + 1; + } + """); + Files.writeString(routineMain, """ + import fnc work from "./routine.ores"; + pub routine main(): void { return; } + """); + Files.writeString(actorMain, """ + import fnc work from "./actor.ores"; + pub routine main(): void { return; } + """); + + for (Path entry : List.of(routineMain, actorMain)) { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> LinkedProgramRunner.validate(entry)); + assertTrue(failure.getMessage().contains("does not match an exported declaration")); + } + } + + @Test + void genericFncsCannotBeReifiedThroughCrossFileImports() throws Exception { + Path app = temp.resolve("generic-fnc-import-contract"); + Files.createDirectories(app); + Path child = app.resolve("child.ores"); + Path namedMain = app.resolve("named-main.ores"); + Path wildcardMain = app.resolve("wildcard-main.ores"); + Path classMain = app.resolve("class-main.ores"); + + Files.writeString(child, """ + pub fnc identity(T value): T { + return value; + } + + define class GenericTools as + pub static fnc identity(T value): T { + return value; + } + end + """); + + Files.writeString(namedMain, """ + import fnc identity from "./child.ores"; + pub routine main(): void { return; } + """); + + IllegalArgumentException namedFailure = assertThrows( + IllegalArgumentException.class, + () -> LinkedProgramRunner.validate(namedMain)); + assertTrue(namedFailure.getMessage().contains("does not match an exported declaration")); + + Files.writeString(wildcardMain, """ + import * as external from "./child.ores"; + + pub routine main(): void { + val callback = external.identity; + return; + } + """); + + PolyglotException wildcardFailure = assertThrows( + PolyglotException.class, + () -> LinkedProgramRunner.run( + wildcardMain, + IsolatePolicy.developer(), + ExecutionProfile.serverJit(), + Set.of(), + Map.of(), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + assertTrue(wildcardFailure.getMessage().contains("polymorphic function values are not supported yet")); + + Files.writeString(classMain, """ + import * as external from "./child.ores"; + + pub routine main(): void { + val callback = external.GenericTools.identity; + return; + } + """); + + PolyglotException classFailure = assertThrows( + PolyglotException.class, + () -> LinkedProgramRunner.run( + classMain, + IsolatePolicy.developer(), + ExecutionProfile.serverJit(), + Set.of(), + Map.of(), + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + assertTrue(classFailure.getMessage().contains("generic static fnc")); + assertTrue(classFailure.getMessage().contains("polymorphic function values are not supported yet")); + } + + @Test + void oreslangPathResolvesBareImportsWithoutAManifest() throws Exception { + Path app = temp.resolve("app"); + Path shared = temp.resolve("shared-root"); + Files.createDirectories(app); + Files.createDirectories(shared.resolve("common")); + Path main = app.resolve("main.ores"); + + Files.writeString(shared.resolve("common/message.ores"), """ + pub fnc message() : string { + return "oreslang-path"; + } + """); + Files.writeString(main, """ + import fnc message from "common/message"; + + pub fnc main() : void { + stdio.println(message()); + return; + } + """); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + var build = LinkedProgramRunner.run( + main, + IsolatePolicy.developer(), + ExecutionProfile.serverJit(), + Set.of(), + Map.of(OresProjectConfig.ENV_ORESLANG_PATH, shared.toString()), + out, + new ByteArrayOutputStream()); + + assertEquals(2, build.units().size()); + assertTrue(out.toString(StandardCharsets.UTF_8).contains("oreslang-path")); + } +} diff --git a/src/test/java/dev/oreslang/ProperTailCallTest.java b/src/test/java/dev/oreslang/ProperTailCallTest.java new file mode 100644 index 00000000..e3e2c02e --- /dev/null +++ b/src/test/java/dev/oreslang/ProperTailCallTest.java @@ -0,0 +1,211 @@ +package dev.oreslang; + +import dev.oreslang.runtime.ExecutionProfile; +import dev.oreslang.runtime.IsolatePolicy; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class ProperTailCallTest { + + @Test + void deepTailCallsStayConstantStackAcrossCallableKinds() throws Exception { + String output = run(""" + fnc fnc_down(int n): int { + if n == 0; do + return 0; + else + return fnc_down(n - 1); + fi + } + + routine routine_down(int n): int { + if n == 0; do + return 0; + else + return routine_down(n - 1); + fi + } + + fnc is_even(int n): bool { + if n == 0; do + return true; + else + return is_odd(n - 1); + fi + } + + fnc is_odd(int n): bool { + if n == 0; do + return false; + else + return is_even(n - 1); + fi + } + + define class Counter as + pub down(int n): int { + if n == 0; do + return 0; + else + return self.down(n - 1); + fi + } + + pub static fnc static_down(int n): int { + if n == 0; do + return 0; + else + return Counter.static_down(n - 1); + fi + } + end + + pub routine main(): void { + let Fnc lambda_down = |int n| -> { + if n == 0; do + return 0; + else + return lambda_down(n - 1); + fi + }; + + val counter = new Counter(); + + stdio.stdout.write(fnc_down(50000)); + stdio.stdout.write("|"); + stdio.stdout.write(routine_down(50000)); + stdio.stdout.write("|"); + stdio.stdout.write(is_even(50000)); + stdio.stdout.write("|"); + stdio.stdout.write(counter.down(50000)); + stdio.stdout.write("|"); + stdio.stdout.write(Counter.static_down(50000)); + stdio.stdout.write("|"); + stdio.stdout.write(lambda_down(50000)); + return; + } + """); + + assertEquals("0|0|true|0|0|0", output); + } + + @Test + void indirectFncTailCallsDoNotNestTrampolines() throws Exception { + String output = run(""" + fnc apply(Fnc callback, int value): int { + return callback(value); + } + + fnc down(int n): int { + if n == 0; do + return 0; + else + return apply(down, n - 1); + fi + } + + pub routine main(): void { + stdio.stdout.write(down(50000)); + } + """); + + assertEquals("0", output); + } + + @Test + void cleanupAndExceptionHandlersRemainTailCallBarriers() throws Exception { + String output = run(""" + fnc leaf(): int { + stdio.stdout.write("L"); + return 7; + } + + fnc with_defer(): int { + defer stdio.stdout.write("D"); + return leaf(); + } + + fnc with_finally(): int { + try { + return leaf(); + } catch (err) { + return -1; + } finally { + stdio.stdout.write("F"); + } + } + + fnc divide_by_zero(): int { + return 1 / 0; + } + + fnc with_catch(): int { + try { + return divide_by_zero(); + } catch (err) { + return 42; + } + } + + pub routine main(): void { + stdio.stdout.write(with_defer()); + stdio.stdout.write("|"); + stdio.stdout.write(with_finally()); + stdio.stdout.write("|"); + stdio.stdout.write(with_catch()); + return; + } + """); + + assertEquals("LD7|LF7|42", output); + } + + @Test + void blockBodiedLambdaTailRecursionUsesTheTrampoline() throws Exception { + String output = run(""" + pub routine main(): void { + let Fnc down = |int n| -> { return n == 0 ? 0 : down(n - 1); }; + stdio.stdout.write(down(50000)); + return; + } + """); + + assertEquals("0", output); + } + + @Test + void conditionalTailPositionAlsoUsesTheTrampoline() throws Exception { + String output = run(""" + fnc down(int n): int { + return n == 0 ? 0 : down(n - 1); + } + + pub routine main(): void { + stdio.stdout.write(down(50000)); + } + """); + + assertEquals("0", output); + } + + private static String run(String program) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "proper-tail-call.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = IsolatePolicy.developer() + .restrictedContextBuilder(ExecutionProfile.serverJit()) + .out(output) + .build()) { + context.eval(source); + } + return output.toString(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/dev/oreslang/ReservedKeywordsDynamicStructTest.java b/src/test/java/dev/oreslang/ReservedKeywordsDynamicStructTest.java new file mode 100644 index 00000000..0c3f194a --- /dev/null +++ b/src/test/java/dev/oreslang/ReservedKeywordsDynamicStructTest.java @@ -0,0 +1,157 @@ +package dev.oreslang; + +import dev.oreslang.parser.Lexer; +import dev.oreslang.parser.Parser; +import dev.oreslang.parser.Token; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +final class ReservedKeywordsDynamicStructTest { + @Test + void reservedWordsRemainCallableNamesAndMapKeysOnly() { + var tokens = new Lexer("stop do done").scan(); + assertEquals(Token.Type.STOP, tokens.get(0).type()); + assertEquals(Token.Type.DO, tokens.get(1).type()); + assertEquals(Token.Type.DONE, tokens.get(2).type()); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc stop(): int { return 1; } + routine do(): int { return 2; } + fnc done(): int { return 3; } + + pub fnc main(): int { + val values = obj{stop: 4, 'do': 5, "done": 6}; + return stop() + do() + done() + + values["stop"] + values["do"] + values["done"]; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define module app + fnc stop(): int { return 1; } + fnc main(): void { + val callback = stop; + return; + } + end + """)); + + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + define module app + fnc main(): void { + val stop = 1; + return; + } + end + """)); + } + + @Test + void objectKeysSupportSingleDoubleQuotedReservedAndBacktickForms() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module app + fnc make(string key): DynamicStruct { + return obj{ + stop: 1, + 'do': 2, + "done": 3, + `key`: 4 + }; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc bad(): void { + const key = 42; + const value = obj{`key`: 1}; + return; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc bad(): void { + const value = obj{'same': 1, "same": 2}; + return; + } + end + """))); + } + + @Test + void dynamicStructAllowsArbitraryStringKeysWithTypedValuesAtRuntime() throws Exception { + String program = """ + define module app + pub fnc main(): void { + let DynamicStruct bag = new DynamicStruct(); + bag["stop"] = 1; + bag["done"] = 2; + const key = "do"; + bag[key] = 3; + stdio.println(bag["stop"] + bag["done"] + bag[key]); + return; + } + end + """; + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(program))); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "dynamic-struct.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + assertTrue(output.toString(StandardCharsets.UTF_8).contains("6")); + } + + @Test + void dynamicStructRejectsWrongValueAndNonStringIndexTypes() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc bad(): void { + let DynamicStruct bag = new DynamicStruct(); + bag.answer = "forty-two"; + return; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc bad(): void { + let DynamicStruct bag = new DynamicStruct(); + bag[1] = 42; + return; + } + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module app + fnc bad(): void { + let DynamicStruct bag = new DynamicStruct(); + return; + } + end + """))); + } +} diff --git a/src/test/java/dev/oreslang/ReturnedDestructuringTest.java b/src/test/java/dev/oreslang/ReturnedDestructuringTest.java new file mode 100644 index 00000000..9c097820 --- /dev/null +++ b/src/test/java/dev/oreslang/ReturnedDestructuringTest.java @@ -0,0 +1,380 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ReturnedDestructuringTest { + @Test + void unionArraysAndBindingKindPropagationTypeCheck() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + type intOrBoolOrString = bool | int | string; + + pub fnc mixed(): Array { + return [3, true, "yes"]; + } + + pub fnc main(): void { + [const number, flag, answer] = mixed(); + stdio.println(number); + stdio.println(flag); + stdio.println(answer); + return; + } + """))); + } + + @Test + void finiteTupleReturnCanUseListBackedValueAndPrefixConstPattern() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc fixed(): [int, bool, string] { + return [3, true, "yes"]; + } + + pub fnc main(): void { + const [number, flag, answer] = fixed(); + stdio.println(number); + stdio.println(flag); + stdio.println(answer); + return; + } + """))); + } + + @Test + void finiteTupleReturnRejectsWrongArityAndWrongSlotType() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc broken(): [int, bool, string] { + return [3, true]; + } + + pub fnc main(): void { return; } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc broken(): [int, bool, string] { + return [3, "not-bool", "yes"]; + } + + pub fnc main(): void { return; } + """))); + } + + @Test + void explicitLetChangesPropagationForRemainingSequenceBindings() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc fixed(): [int, bool, string] { + return [3, true, "yes"]; + } + + pub fnc main(): void { + [const number, let flag, answer] = fixed(); + flag = false; + answer = "no"; + stdio.println(number); + stdio.println(flag); + stdio.println(answer); + return; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc fixed(): [int, bool, string] { + return [3, true, "yes"]; + } + + pub fnc main(): void { + [const number, flag, answer] = fixed(); + flag = false; + return; + } + """))); + } + + @Test + void recordReturnSupportsBothObjectDestructureSpellings() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc result(): {foo: int, bar: string} { + return obj{foo: 5, bar: "x"}; + } + + fnc prefixed(): void { + const {foo, bar} = result(); + stdio.println(foo); + stdio.println(bar); + return; + } + + fnc inlineKinds(): void { + {const foo, const bar} = result(); + stdio.println(foo); + stdio.println(bar); + return; + } + + pub fnc main(): void { + prefixed(); + inlineKinds(); + return; + } + """))); + } + + @Test + void recordReturnsAndDestructuresRejectMissingOrWrongMembers() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc broken(): {foo: int, bar: string} { + return obj{foo: 5}; + } + + pub fnc main(): void { return; } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc broken(): {foo: int, bar: string} { + return obj{foo: "wrong", bar: "x"}; + } + + pub fnc main(): void { return; } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc result(): {foo: int, bar: string} { + return obj{foo: 5, bar: "x"}; + } + + pub fnc main(): void { + const {foo, missing} = result(); + return; + } + """))); + } + + @Test + void equivalentUnionAndRecordOrderingsHaveCanonicalSignatures() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + @Ret + fnc scalar(): int | string { + return 3; + } + + @Ret<{bar: string, foo: int}> + fnc object(): {foo: int, bar: string} { + return obj{foo: 5, bar: "x"}; + } + + pub fnc main(): void { return; } + """))); + } + + @Test + void prefixedPatternSyntaxDoesNotStealFiniteTupleOrRecordTypedBindings() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc fixed(): [int, bool, string] { + return [3, true, "yes"]; + } + + fnc result(): {foo: int, bar: string} { + return obj{foo: 5, bar: "x"}; + } + + pub fnc main(): void { + val [int, bool, string] tupleValue = fixed(); + val {foo: int, bar: string} recordValue = result(); + stdio.println(tupleValue[0]); + stdio.println(recordValue.foo); + return; + } + """))); + } + + @Test + void unionTupleReturnsDestructureWhenEveryAlternativeHasCompatibleArity() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc variant(bool flag): [int, string] | [bool, string] { + if flag; do + return [3, "number"]; + else + return [true, "boolean"]; + fi + } + + pub fnc main(): void { + const [value, label] = variant(true); + stdio.println(value); + stdio.println(label); + return; + } + """))); + } + + @Test + void unionRecordReturnsDestructureWhenEveryAlternativeProvidesRequestedMembers() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc variant(bool flag): {foo: int, bar: string} | {foo: bool, bar: string} { + if flag; do + return obj{foo: 3, bar: "number"}; + else + return obj{foo: true, bar: "boolean"}; + fi + } + + pub fnc main(): void { + const {foo, bar} = variant(false); + stdio.println(foo); + stdio.println(bar); + return; + } + """))); + } + + @Test + void unionDestructureRejectsIncompatibleArityOrMissingMembers() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc variant(bool flag): [int, string] | [bool, string, int] { + if flag; do + return (3, "number"); + else + return (true, "boolean", 9); + fi + } + + pub fnc main(): void { + const [value, label] = variant(true); + return; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc variant(bool flag): {foo: int, bar: string} | {foo: bool} { + if flag; do + return obj{foo: 3, bar: "number"}; + else + return obj{foo: true}; + fi + } + + pub fnc main(): void { + const {foo, bar} = variant(false); + return; + } + """))); + } + + @Test + void bareDiscardIsRejectedInObjectPatterns() { + assertThrows(IllegalArgumentException.class, () -> Parser.parse(""" + fnc result(): {foo: int, bar: string} { + return obj{foo: 5, bar: "x"}; + } + + pub fnc main(): void { + const {foo, _} = result(); + return; + } + """)); + } + + @Test + void unionTupleAndArrayReturnsExecuteThroughRuntimeShapeChecks() throws Exception { + String program = """ + type Scalar = int | bool | string; + + fnc tupleVariant(bool flag): [int, string] | [bool, string] { + if flag; do + return [3, "number"]; + else + return [true, "boolean"]; + fi + } + + fnc values(): Array { + return [3, true, "yes"]; + } + + pub fnc main(): void { + const [value, label] = tupleVariant(false); + [const number, flag, answer] = values(); + stdio.println(value); + stdio.println(label); + stdio.println(number); + stdio.println(flag); + stdio.println(answer); + return; + } + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "union-return-destructure.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("true")); + assertTrue(text.contains("boolean")); + assertTrue(text.contains("3")); + assertTrue(text.contains("yes")); + } + + @Test + void returnedTupleAndRecordDestructureAtRuntime() throws Exception { + String program = """ + type FixedResult = [int, bool, string]; + type NamedResult = {foo: int, bar: string}; + + fnc fixed(): FixedResult { + return [3, true, "yes"]; + } + + fnc result(): NamedResult { + return obj{foo: 5, bar: "x"}; + } + + pub fnc main(): void { + const [number, flag, answer] = fixed(); + const {foo, bar} = result(); + stdio.println(number); + stdio.println(flag); + stdio.println(answer); + stdio.println(foo); + stdio.println(bar); + return; + } + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "returned-destructure.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("3")); + assertTrue(text.contains("true")); + assertTrue(text.contains("yes")); + assertTrue(text.contains("5")); + assertTrue(text.contains("x")); + } +} diff --git a/src/test/java/dev/oreslang/RoutineAndLoopTest.java b/src/test/java/dev/oreslang/RoutineAndLoopTest.java new file mode 100644 index 00000000..7a743382 --- /dev/null +++ b/src/test/java/dev/oreslang/RoutineAndLoopTest.java @@ -0,0 +1,317 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +final class RoutineAndLoopTest { + @Test + void exactFncProgramCompilesAndRuns() throws Exception { + String program = """ + define module x + define class y as + end + end + + pub fnc main(): void { + val y = new x.y(); + stdio.stdout.write(y); + } + """; + String output = run(program); + assertTrue(output.contains("y{}")); + } + + @Test + void routineMainCompilesWithSafeSemicolonOmission() throws Exception { + String program = """ + define module x + define class y as + end + end + + pub routine main(): void { + val y = new x.y(); + stdio.stdout.write(y) + } + """; + String output = run(program); + assertTrue(output.contains("y{}")); + } + + @Test + void routinesAndFncsCanParticipateInRecursion() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + routine spin(bool shouldStop): void { + if shouldStop; do + return; + else + spin(true); + return; + fi + } + """))); + + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc recurse(bool shouldStop): void { + if shouldStop; do + return; + else + recurse(true); + return; + fi + } + """))); + } + + @Test + void methodsOverloadOnlyByArity() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + define module m + define class C as + pub find(): int { return 0; } + pub find(int value): int { return value; } + end + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define module m + define class C as + pub find(int value): int { return value; } + pub find(String value): int { return 1; } + end + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + fnc find(): int { return 0; } + fnc find(int value): int { return value; } + """))); + } + + @Test + void explicitlyTypedLambdasCanRecurse() throws Exception { + String output = run(""" + pub routine main(): void { + let Fnc fact = |int n| -> { + return n == 0 ? 1 : n * fact(n - 1); + }; + stdio.stdout.write(fact(5)) + } + """); + assertEquals("120", output); + } + + @Test + void ternaryWorksWithOption() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + fnc find(bool found): Option { + return found ? Some(42) : None; + } + """))); + } + + @Test + void structuralParametersAreOptInAndSupportBrandedInterfaces() { + assertDoesNotThrow(() -> TypeChecker.check(Parser.parse(""" + pub interface Bar { + markerBrand: 'marking/branding' + } + + pub interface Foo extends Bar { + } + + fnc structural(@Structural Foo value): String { + return value.markerBrand; + } + + fnc main(): void { + val branded = obj{markerBrand: "marking/branding"}; + stdio.println(structural(branded)); + return; + } + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + pub interface Foo { + markerBrand: 'marking/branding' + } + + fnc nominal(Foo value): String { + return "ok"; + } + + fnc main(): void { + val branded = obj{markerBrand: "marking/branding"}; + stdio.println(nominal(branded)); + return; + } + """))); + } + + @Test + void forOfInjectsSchedulerSafepoints() throws Exception { + String output = run(""" + pub routine main(): void { + for (val item of arr[1, 2, 3]) { + stdio.stdout.write(item); + } + stdio.stdout.write(process.descriptor.scheduler_safepoints) + } + """); + assertTrue(output.startsWith("123")); + assertTrue(output.endsWith("3")); + } + + @Test + void conventionalForLoopAlsoInjectsSafepoints() throws Exception { + String output = run(""" + pub routine main(): void { + for (let i = 0; i < 3; i = i + 1) { + stdio.stdout.write(i); + } + stdio.stdout.write(process.descriptor.scheduler_safepoints) + } + """); + assertEquals("0123", output); + } + + @Test + void doDoneBodiesAndIteratorShorthandRemainUnambiguous() throws Exception { + String output = run(""" + fnc done(): void { + stdio.stdout.write("d"); + return; + } + + pub routine main(): void { + for item of arr[1, 2] do + done(); + stdio.stdout.write(item); + done + + for (let i = 0; i < 2; i = i + 1) do + stdio.stdout.write(i); + done + + let n = 0; + loop do + n = n + 1; + if n == 2 { + break; + } fi + done + + stdio.stdout.write(n); + return; + } + """); + + assertEquals("d1d2012", output); + } + + @Test + void forOfSequencePatternsDestructureTupleElements() throws Exception { + String output = run(""" + pub routine main(): void { + for [key, value] of arr[(1, "a"), (2, "b")] do + stdio.stdout.write(key); + stdio.stdout.write(value); + done + + for [_, let value] of arr[(9, 3), (8, 4)] { + value = value + 1; + stdio.stdout.write(value); + } + return; + } + """); + + assertEquals("1a2b45", output); + } + + @Test + void forOfSequencePatternsRejectKnownArityMismatch() { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> TypeChecker.check(Parser.parse(""" + pub routine main(): void { + for [a, b, c] of arr[(1, 2)] do + stdio.stdout.write(a); + done + return; + } + """))); + + assertTrue(failure.getMessage().contains("destructure arity mismatch")); + } + + @Test + void typedUnparenthesizedForHeadersSupportPostfixUpdates() throws Exception { + String output = run(""" + pub routine main(): void { + for int i = 0; i < 3; i++ do + stdio.stdout.write(i); + done + + for int j = 3; j > 0; j-- { + stdio.stdout.write(j) + } + + for (let k = 0; k < 2; k++) { + stdio.stdout.write(k) + } + + for (int q = 0; q < 2; q++) do + stdio.stdout.write(q) + done + return; + } + """); + + assertEquals("0123210101", output); + } + + @Test + void customJavascriptStyleIteratorDrivesForOf() throws Exception { + String output = run(""" + define module collections + define class Bag as + pub [Symbol.iterator](): Array { + return arr[4, 5]; + } + end + end + + pub routine main(): void { + val bag = new collections.Bag(); + for (val item of bag) { + stdio.stdout.write(item); + } + } + """); + assertEquals("45", output); + } + + private static String run(String program) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "together.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + return output.toString(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/dev/oreslang/SerializationAnnotationTest.java b/src/test/java/dev/oreslang/SerializationAnnotationTest.java new file mode 100644 index 00000000..e0603891 --- /dev/null +++ b/src/test/java/dev/oreslang/SerializationAnnotationTest.java @@ -0,0 +1,184 @@ +package dev.oreslang; + +import dev.oreslang.ast.AnnotationExpander; +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class SerializationAnnotationTest { + @Test + void parsesFromJsonFieldAnnotationsAndColonFieldShorthand() { + Ast.Program program = Parser.parse(""" + define class MyBlob as + @FromJson("foo") + foo: String + @FromJson('bar') + bar: bool + end + """); + + Ast.ClassDecl blob = firstClass(program); + assertEquals(2, blob.fields().size()); + assertEquals(Ast.BindingKind.LET, blob.fields().get(0).bindingKind()); + assertEquals(Ast.BindingKind.LET, blob.fields().get(1).bindingKind()); + assertEquals("foo", AnnotationExpander.fromJsonKey(blob.fields().get(0))); + assertEquals("bar", AnnotationExpander.fromJsonKey(blob.fields().get(1))); + } + + @Test + void expandsFromJsonIntoPublicTypedGettersAndSetters() { + Ast.Program checked = TypeChecker.check(Parser.parse(""" + define class MyBlob as + @FromJson("foo") + foo: String; + @FromJson("bar") + bar: bool; + end + """)); + + Ast.ClassDecl blob = firstClass(checked); + Ast.MethodDecl getFoo = method(blob, "getFoo", 0); + Ast.MethodDecl setFoo = method(blob, "setFoo", 1); + Ast.MethodDecl getBar = method(blob, "getBar", 0); + Ast.MethodDecl setBar = method(blob, "setBar", 1); + + assertEquals(Ast.Visibility.PUBLIC, getFoo.visibility()); + assertEquals("String", getFoo.returnType().name()); + assertEquals("String", setFoo.parameters().getFirst().type().name()); + assertEquals("bool", getBar.returnType().name()); + assertEquals("bool", setBar.parameters().getFirst().type().name()); + assertTrue(AnnotationExpander.isGeneratedFromJsonSetter(setFoo)); + assertTrue(AnnotationExpander.isGeneratedFromJsonSetter(setBar)); + + List bindings = AnnotationExpander.fromJsonBindings(blob); + assertEquals(2, bindings.size()); + assertEquals("foo", bindings.get(0).jsonKey()); + assertEquals("setFoo", bindings.get(0).setterName()); + assertEquals("bar", bindings.get(1).jsonKey()); + assertEquals("setBar", bindings.get(1).setterName()); + } + + @Test + void generatedAccessorsExecuteWithoutRuntimeReflection() throws Exception { + String program = """ + define class MyBlob as + @FromJson("foo") + foo: String + @FromJson("bar") + bar: bool + end + + pub fnc main() : void { + let MyBlob blob = new MyBlob("before", false); + blob.setFoo("after"); + blob.setBar(true); + stdio.println(blob.getFoo()); + stdio.println(blob.getBar()); + return; + } + """; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Source source = Source.newBuilder(OresLanguage.ID, program, "serialization.ores") + .mimeType(OresLanguage.MIME_TYPE) + .build(); + + try (Context context = Context.newBuilder(OresLanguage.ID) + .allowAllAccess(false) + .out(output) + .build()) { + context.eval(source); + } + + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("after")); + assertTrue(text.contains("true")); + } + + @Test + void rejectsMalformedDuplicateImmutableAndCollidingFromJsonDeclarations() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Bad as + @FromJson(foo) + foo: String; + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Bad as + @FromJson("same") + first: String; + @FromJson("same") + second: String; + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Bad as + @FromJson("foo") + val String foo; + end + """))); + + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class Bad as + @FromJson("foo") + foo: String; + getFoo() : String { return self.foo; } + end + """))); + } + + @Test + void generatedSetterRequiresMutableOwner() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + define class MyBlob as + @FromJson("foo") + foo: String; + end + + pub fnc main() : void { + val MyBlob blob = new MyBlob("before"); + blob.setFoo("after"); + return; + } + """))); + } + + @Test + void rejectsFromJsonOnActorState() { + assertThrows(IllegalArgumentException.class, () -> TypeChecker.check(Parser.parse(""" + actor Worker { + @FromJson("state") + let String state = "x"; + } + """))); + } + + private static Ast.ClassDecl firstClass(Ast.Program program) { + for (Ast.ModuleDecl module : program.modules()) { + for (Ast.Decl declaration : module.declarations()) { + if (declaration instanceof Ast.ClassDecl klass) return klass; + } + } + throw new AssertionError("expected class"); + } + + private static Ast.MethodDecl method(Ast.ClassDecl klass, String name, int arity) { + return klass.methods().stream() + .filter(method -> method.name().equals(name) && method.arity() == arity) + .findFirst() + .orElseThrow(() -> new AssertionError("missing method " + name + "/" + arity)); + } +} diff --git a/src/test/java/dev/oreslang/SharedPrivateActorIsolationProofTest.java b/src/test/java/dev/oreslang/SharedPrivateActorIsolationProofTest.java new file mode 100644 index 00000000..685ca652 --- /dev/null +++ b/src/test/java/dev/oreslang/SharedPrivateActorIsolationProofTest.java @@ -0,0 +1,99 @@ +package dev.oreslang; + +import dev.oreslang.runtime.ActorRuntime; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +final class SharedPrivateActorIsolationProofTest { + + @Test + void sharedActorCanShareExplicitStateWhilePrivateActorRemainsConfined() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + // Positive control: SHARED actors may coordinate through an explicit SyncCell. + ActorRuntime.SyncCell sharedCell = runtime.syncCell(10); + CountDownLatch sharedTurn = new CountDownLatch(1); + + var shared = runtime.spawnShared(() -> (message, context) -> { + assertEquals(ActorRuntime.ActorKind.SHARED, context.kind()); + assertTrue(context.privateMemory().isEmpty()); + sharedCell.update(value -> value + 1); + sharedTurn.countDown(); + context.self().stop(); + }); + + shared.send("increment"); + assertTrue(sharedTurn.await(2, TimeUnit.SECONDS)); + assertTrue(shared.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(shared.failure().isEmpty()); + assertEquals(11, sharedCell.snapshot()); + + // Negative control: a PRIVATE actor may not receive that writable shared handle. + var isolated = runtime.spawnPrivate(factoryContext -> { + assertEquals(ActorRuntime.ActorKind.PRIVATE, factoryContext.kind()); + assertTrue(factoryContext.privateMemory().isPresent()); + return (message, context) -> context.self().stop(); + }); + + assertThrows(IllegalArgumentException.class, () -> isolated.send(sharedCell)); + isolated.send("ordinary-value"); + assertTrue(isolated.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(isolated.failure().isEmpty()); + + // Compiler-facing private construction is capture-free: mutable host state + // cannot silently become shared state reachable by the actor. + ArrayList mutableHostState = new ArrayList<>(List.of("host")); + SecurityException capturedState = assertThrows( + SecurityException.class, + () -> runtime.spawnPrivate(factoryContext -> { + mutableHostState.add("should-never-run"); + return (message, context) -> context.self().stop(); + })); + assertTrue(capturedState.getMessage().contains("stateless")); + assertEquals(List.of("host"), mutableHostState); + + // Even the explicit trusted-host escape hatch cannot make a private-memory + // block readable outside the owning actor execution domain. + AtomicReference leaked = new AtomicReference<>(); + CountDownLatch allocated = new CountDownLatch(1); + CountDownLatch ownerVerified = new CountDownLatch(1); + + var memoryOwner = runtime.spawnPrivateTrusted(factoryContext -> { + ActorRuntime.PrivateMemoryBlock block = + factoryContext.privateMemory().orElseThrow().allocatePrivateBytes(32); + block.writeByte(0, (byte) 42); + leaked.set(block); + allocated.countDown(); + + return (message, context) -> { + assertEquals(42, block.readByte(0)); + block.writeByte(1, (byte) 7); + assertEquals(7, block.readByte(1)); + ownerVerified.countDown(); + }; + }); + + // Private actor construction is lazy: admitting work starts the actor and + // creates its private slice. + memoryOwner.send("verify-owner-access"); + assertTrue(allocated.await(2, TimeUnit.SECONDS)); + assertNotNull(leaked.get()); + + // The same block is usable by its owner but unreadable from the host. + assertThrows(IllegalStateException.class, () -> leaked.get().readByte(0)); + assertTrue(ownerVerified.await(2, TimeUnit.SECONDS)); + + memoryOwner.stop(); + assertTrue(memoryOwner.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(memoryOwner.failure().isEmpty()); + assertTrue(leaked.get().closed()); + assertEquals(0L, runtime.privateMemoryBytes()); + } + } +} diff --git a/src/test/java/dev/oreslang/StaticFncKeywordTest.java b/src/test/java/dev/oreslang/StaticFncKeywordTest.java new file mode 100644 index 00000000..d0ca5e2c --- /dev/null +++ b/src/test/java/dev/oreslang/StaticFncKeywordTest.java @@ -0,0 +1,18 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +final class StaticFncKeywordTest { + @Test + void staticFunctionsRequireFnc() { + String legacy = "f" + "n"; + var error = assertThrows(IllegalArgumentException.class, () -> + Parser.parse(("define class C as pub static %s build(): void { return; } end").formatted(legacy))); + assertTrue(error.getMessage().contains("static fnc")); + assertDoesNotThrow(() -> + Parser.parse("define class C as pub static fnc build(): void { return; } end")); + } +} diff --git a/src/test/java/dev/oreslang/TreeShakerTest.java b/src/test/java/dev/oreslang/TreeShakerTest.java new file mode 100644 index 00000000..ab9fdf5d --- /dev/null +++ b/src/test/java/dev/oreslang/TreeShakerTest.java @@ -0,0 +1,222 @@ +package dev.oreslang; + +import dev.oreslang.compiler.BuildOptions; +import dev.oreslang.compiler.OresCompiler; +import dev.oreslang.compiler.TreeShaker; +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Parser; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +final class TreeShakerTest { + @Test + void cliDefinesOverrideEnvironmentDefinesDeterministically() { + Map merged = BuildOptions.mergeDefines( + Map.of( + "ORESLANG_BUILD_DEFINES", "use_a=false,backend=jvm", + "ORESLANG_DEFINE_DEBUG", "false"), + List.of("use_a=true", "backend=native")); + + assertEquals("true", merged.get("use_a")); + assertEquals("native", merged.get("backend")); + assertEquals("false", merged.get("DEBUG")); + } + + @Test + void buildDefineFoldsFunctionReferenceAndRemovesDeadModule() { + TreeShaker.Result result = OresCompiler.compileForBuild(""" + pub const bool use_a = false; + + define module A + pub fnc foo(): String { + return "hi"; + } + end + + define module B + pub fnc foo(): String { + return "bye"; + } + end + + type F = typeof fnc() => String; + + pub fnc choose(bool t): F { + return t ? A.foo : B.foo; + } + + pub routine main(): void { + val F selected = choose(use_a); + stdio.stdout.write(selected()); + return; + } + """, BuildOptions.executable(Map.of("use_a", "true"))); + + assertTrue(result.retained("A.foo")); + assertTrue(result.removed("B.foo")); + assertTrue(result.removed(Parser.ROOT_MODULE + ".use_a"), + "a const used only to fold build-time control flow should disappear"); + assertTrue(result.removed(Parser.ROOT_MODULE + ".choose"), + "a constant-argument helper should disappear after specialization when no runtime call remains"); + + Set modules = result.program().modules().stream() + .map(module -> module.name()) + .collect(java.util.stream.Collectors.toSet()); + assertTrue(modules.contains("A")); + assertFalse(modules.contains("B")); + assertTrue(modules.contains(Parser.ROOT_MODULE)); + } + + @Test + void buildDefineFoldsIfStatementBeforeReachability() { + TreeShaker.Result result = OresCompiler.compileForBuild(""" + pub const bool debug_backend = false; + + define module DebugBackend + pub routine run(): void { + stdio.stdout.write("debug"); + return; + } + end + + define module ReleaseBackend + pub routine run(): void { + stdio.stdout.write("release"); + return; + } + end + + pub routine main(): void { + if debug_backend; do + DebugBackend.run(); + else + ReleaseBackend.run(); + fi + return; + } + """, BuildOptions.executable(Map.of("debug_backend", "true"))); + + assertTrue(result.retained("DebugBackend.run")); + assertTrue(result.removed("ReleaseBackend.run")); + } + + @Test + void reifiedModuleAliasRetainsItsRuntimeVisibleCallableSurface() { + TreeShaker.Result result = OresCompiler.compileForBuild(""" + define module service + pub fnc transform(int value): int { + return value + 1; + } + + pub routine direct_only(int value): int { + return value + 2; + } + + fnc private_helper(): int { + return 99; + } + end + + pub routine main(): void { + val alias = service; + val Fnc callback = alias.transform; + stdio.stdout.write(callback(4)); + stdio.stdout.write(alias.direct_only(4)); + return; + } + """, BuildOptions.executable(Map.of())); + + assertTrue(result.retained("service.transform")); + assertTrue(result.retained("service.direct_only")); + assertTrue(result.removed("service.private_helper")); + } + + @Test + void foldedConditionalPreservesItsLexicalScope() { + TreeShaker.Result result = OresCompiler.compileForBuild(""" + pub const bool enabled = true; + + pub routine main(): void { + if enabled { + val hidden = 1; + stdio.stdout.write(hidden); + } fi + return; + } + """, BuildOptions.executable(Map.of("enabled", "true"))); + + Ast.FunctionDecl main = result.program().modules().stream() + .flatMap(module -> module.declarations().stream()) + .filter(Ast.FunctionDecl.class::isInstance) + .map(Ast.FunctionDecl.class::cast) + .filter(function -> function.name().equals("main")) + .findFirst() + .orElseThrow(); + + assertInstanceOf(Ast.BlockStmt.class, main.body().getFirst(), + "constant folding must not flatten an if arm into its parent lexical scope"); + } + + @Test + void directOnlyRoutineCallsAreEligibleForConstantInlining() { + TreeShaker.Result result = OresCompiler.compileForBuild(""" + routine plus_one(int value): int { + return value + 1; + } + + pub routine main(): void { + stdio.stdout.write(plus_one(41)); + return; + } + """, BuildOptions.executable(Map.of())); + + assertTrue( + result.removed(Parser.ROOT_MODULE + ".plus_one"), + "direct-only routines should be at least as inlineable as reifiable fnc declarations"); + } + + @Test + void libraryBuildPreservesPublicApiButStillDropsPrivateDeadSymbols() { + TreeShaker.Result result = OresCompiler.compileForBuild(""" + pub fnc public_answer(): int { return 42; } + fnc hidden_answer(): int { return 7; } + """, BuildOptions.library(Map.of())); + + assertTrue(result.retained(Parser.ROOT_MODULE + ".public_answer")); + assertTrue(result.removed(Parser.ROOT_MODULE + ".hidden_answer")); + } + + @Test + void defineMustTargetAConstDeclaration() { + assertThrows(IllegalArgumentException.class, () -> OresCompiler.compileForBuild(""" + pub val bool use_a = false; + pub routine main(): void { return; } + """, BuildOptions.executable(Map.of("use_a", "true")))); + } + + @Test + void treeShakingDoesNotHideTypeErrorsInDeadBranches() { + assertThrows(IllegalArgumentException.class, () -> OresCompiler.compileForBuild(""" + pub const bool use_a = true; + + define module A + pub fnc foo(): int { return 1; } + end + + define module B + pub fnc foo(): int { return "not an int"; } + end + + pub routine main(): void { + val chosen = use_a ? A.foo : B.foo; + chosen(); + return; + } + """, BuildOptions.executable(Map.of("use_a", "true")))); + } +} diff --git a/src/test/java/dev/oreslang/TypeofFncKeywordTest.java b/src/test/java/dev/oreslang/TypeofFncKeywordTest.java new file mode 100644 index 00000000..eddc2017 --- /dev/null +++ b/src/test/java/dev/oreslang/TypeofFncKeywordTest.java @@ -0,0 +1,18 @@ +package dev.oreslang; + +import dev.oreslang.parser.Parser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +final class TypeofFncKeywordTest { + @Test + void functionTypesRequireTypeofFnc() { + String legacy = "f" + "n"; + var error = assertThrows(IllegalArgumentException.class, () -> + Parser.parse(("type F = typeof %s() => void; pub routine main(): void { return; }").formatted(legacy))); + assertTrue(error.getMessage().contains("typeof fnc")); + assertDoesNotThrow(() -> + Parser.parse("type F = typeof fnc() => void; pub routine main(): void { return; }")); + } +} diff --git a/src/test/java/dev/oreslang/config/OresProjectConfigTest.java b/src/test/java/dev/oreslang/config/OresProjectConfigTest.java new file mode 100644 index 00000000..2a4693ea --- /dev/null +++ b/src/test/java/dev/oreslang/config/OresProjectConfigTest.java @@ -0,0 +1,119 @@ +package dev.oreslang.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +final class OresProjectConfigTest { + @TempDir Path temp; + + @Test + void discoversNearestManifestAndResolvesProjectPaths() throws Exception { + Path project = temp.resolve("project"); + Path src = project.resolve("src"); + Path lib = project.resolve("lib"); + Files.createDirectories(src.resolve("nested")); + Files.createDirectories(lib); + Files.writeString(src.resolve("main.ores"), "pub fnc main() => void { return; }"); + Files.writeString(project.resolve(OresProjectConfig.MANIFEST_NAME), """ + schema_version = "1" + + [project] + name = "demo" + version = "0.1.0" + root = "." + + [source] + roots = ["src"] + import_paths = ["lib"] + + [entrypoints] + main = "src/main.ores" + """); + + OresProjectConfig config = OresProjectConfig.discover( + src.resolve("nested").resolve("file.ores"), + Map.of()); + + assertEquals(project.toAbsolutePath().normalize(), config.projectRoot()); + assertEquals("demo", config.projectName().orElseThrow()); + assertEquals(src.toAbsolutePath().normalize(), config.sourceRoots().getFirst()); + assertEquals(lib.toAbsolutePath().normalize(), config.importPaths().getFirst()); + assertEquals(src.resolve("main.ores").toAbsolutePath().normalize(), config.mainEntrypoint().orElseThrow()); + } + + @Test + void oreslangPathIsAnOrderedOsNativePathList() throws Exception { + Path one = temp.resolve("one"); + Path two = temp.resolve("two"); + + OresProjectConfig config = OresProjectConfig.discover( + temp, + Map.of(OresProjectConfig.ENV_ORESLANG_PATH, + one + File.pathSeparator + File.pathSeparator + two + File.pathSeparator)); + + assertEquals( + java.util.List.of(one.toAbsolutePath().normalize(), two.toAbsolutePath().normalize()), + config.environmentPaths()); + } + + @Test + void projectRootsWinBeforeOreslangPathFallback() throws Exception { + Path project = temp.resolve("project"); + Path src = project.resolve("src"); + Path ambient = temp.resolve("ambient"); + Files.createDirectories(src.resolve("pkg")); + Files.createDirectories(ambient.resolve("pkg")); + Files.writeString(src.resolve("pkg/value.ores"), "pub fnc value() => int { return 1; }"); + Files.writeString(ambient.resolve("pkg/value.ores"), "pub fnc value() => int { return 2; }"); + Path importer = src.resolve("main.ores"); + Files.writeString(importer, "pub fnc main() => void { return; }"); + Files.writeString(project.resolve(OresProjectConfig.MANIFEST_NAME), """ + schema_version = "1" + [source] + roots = ["src"] + """); + + OresProjectConfig config = OresProjectConfig.discover( + importer, + Map.of(OresProjectConfig.ENV_ORESLANG_PATH, ambient.toString())); + + assertEquals( + src.resolve("pkg/value.ores").toAbsolutePath().normalize(), + config.resolveImport(importer, "pkg/value").orElseThrow()); + } + + @Test + void relativeImportsNeverFallThroughToSearchRoots() throws Exception { + Path importerDir = temp.resolve("src"); + Path ambient = temp.resolve("ambient"); + Files.createDirectories(importerDir); + Files.createDirectories(ambient); + Path importer = importerDir.resolve("main.ores"); + Files.writeString(importer, "pub fnc main() => void { return; }"); + Files.writeString(ambient.resolve("missing.ores"), "pub fnc value() => int { return 1; }"); + + OresProjectConfig config = OresProjectConfig.discover( + importer, + Map.of(OresProjectConfig.ENV_ORESLANG_PATH, ambient.toString())); + + assertTrue(config.resolveImport(importer, "./missing").isEmpty()); + } + + @Test + void rejectsUnsupportedSchemaVersion() throws Exception { + Path manifest = temp.resolve(OresProjectConfig.MANIFEST_NAME); + Files.writeString(manifest, "schema_version = \"999\"\n"); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> OresProjectConfig.load(manifest, Map.of())); + assertTrue(error.getMessage().contains("schema_version")); + } +} diff --git a/src/test/java/dev/oreslang/launcher/CompilerCheckModeTest.java b/src/test/java/dev/oreslang/launcher/CompilerCheckModeTest.java new file mode 100644 index 00000000..fa46af23 --- /dev/null +++ b/src/test/java/dev/oreslang/launcher/CompilerCheckModeTest.java @@ -0,0 +1,50 @@ +package dev.oreslang.launcher; + +import dev.oreslang.compiler.IncrementalCompiler; +import dev.oreslang.runtime.LinkedProgramRunner; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class CompilerCheckModeTest { + @Test + void validationCompilesWithoutExecutingMain() throws Exception { + Path source = Files.createTempFile("ores-check-", ".ores"); + Files.writeString(source, """ + pub fnc main(): void { + let denominator = 0; + let result = 1 / denominator; + return; + } + """); + + IncrementalCompiler.BuildResult result = LinkedProgramRunner.validate(source); + + assertEquals(1, result.units().size()); + assertTrue(result.units().containsKey(source.toAbsolutePath().normalize().toString().replace('\\', '/'))); + } + + @Test + void positionedParserErrorsBecomeEditorDiagnostics() { + Path source = Path.of("demo.ores"); + String diagnostic = OresMain.formatCheckDiagnostic( + source, + new IllegalArgumentException("Oreslang parse error at 7:13: expected expression")); + + assertTrue(diagnostic.endsWith("demo.ores:7:13: error: expected expression")); + } + + @Test + void unpositionedErrorsFallBackToFileStart() { + Path source = Path.of("demo.ores"); + String diagnostic = OresMain.formatCheckDiagnostic( + source, + new IllegalArgumentException("unknown binding 'value'")); + + assertTrue(diagnostic.endsWith("demo.ores:1:1: error: unknown binding 'value'")); + } +} diff --git a/src/test/java/dev/oreslang/runtime/ActorCapabilityIsolationTest.java b/src/test/java/dev/oreslang/runtime/ActorCapabilityIsolationTest.java new file mode 100644 index 00000000..cd6a9161 --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/ActorCapabilityIsolationTest.java @@ -0,0 +1,324 @@ +package dev.oreslang.runtime; + +import dev.oreslang.ast.Ast; +import dev.oreslang.parser.Parser; +import dev.oreslang.types.TypeChecker; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +final class ActorCapabilityIsolationTest { + + @Test + void privateActorStaticallyDeniesReadonlySharingEvenUnderDeveloperPolicy() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + isoactor PrivateWorker { + pub fnc attempt_share(): void { + val shared = process.share_readonly(arr[1, 2, 3]); + stdio.println(shared); + return; + } + } + """)); + + SecurityException error = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.developer())); + + assertTrue(error.getMessage().contains("ACTOR_SHARE_READONLY")); + } + + @Test + void privateActorCannotHideSharedMutexBehindTypeAlias() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + type SharedInt = SharedMutex; + + isoactor PrivateWorker { + let SharedInt hidden; + } + """)); + + SecurityException error = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.developer())); + + assertTrue(error.getMessage().contains("SHARED_MEMORY")); + } + + @Test + void privateActorCannotHideSharedMutexInsideOrdinaryStoredClass() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + define class SharedBox as + let SharedMutex value; + end + + isoactor PrivateWorker { + let SharedBox hidden; + } + """)); + + SecurityException error = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.developer())); + + assertTrue(error.getMessage().contains("SHARED_MEMORY")); + } + + @Test + void privateActorCannotLaunderSharedMemoryThroughOrdinaryHelperFunction() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + fnc build_shared(): void { + val shared = SharedMutex.new(1); + stdio.println(shared); + return; + } + + isoactor PrivateWorker { + pub fnc run(): void { + build_shared(); + return; + } + } + """)); + + SecurityException error = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.developer())); + + assertTrue(error.getMessage().contains("SHARED_MEMORY")); + } + + @Test + void privateActorCannotLaunderSharedMemoryThroughStaticClassHelper() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + define class Helpers as + pub static fnc build_shared(): void { + val shared = SharedMutex.new(1); + stdio.println(shared); + return; + } + end + + isoactor PrivateWorker { + pub fnc run(): void { + Helpers.build_shared(); + return; + } + } + """)); + + SecurityException error = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.developer())); + + assertTrue(error.getMessage().contains("SHARED_MEMORY")); + } + + @Test + void privateActorCannotCarryObjectWhoseInstanceMethodUsesSharedAuthority() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + define class Helper as + pub use_shared(): void { + val shared = process.share_readonly(arr[1, 2, 3]); + stdio.println(shared); + return; + } + end + + isoactor PrivateWorker { + let Helper helper; + } + """)); + + SecurityException error = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.developer())); + + assertTrue(error.getMessage().contains("ACTOR_SHARE_READONLY")); + } + + @Test + void sharedActorMayUseTransitiveSharedStateWhenParentPolicyAllowsIt() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + type SharedInt = SharedMutex; + + define class SharedBox as + let SharedInt value; + end + + shared actor SharedWorker { + let SharedBox state; + } + """)); + + assertDoesNotThrow(() -> + CapabilityChecker.check(program, IsolatePolicy.developer())); + } + + @Test + void transitiveCapabilityScanHandlesSelfReferentialStoredTypes() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + define class Node as + let Node next; + + pub identity(Node other): Node { + return other; + } + end + + isoactor PrivateWorker { + let Node root; + } + """)); + + assertDoesNotThrow(() -> + CapabilityChecker.check(program, IsolatePolicy.developer())); + } + + @Test + void actorLocalRuntimePolicyCannotBeBypassedByParentContextCapability() throws Exception { + IsolatePolicy developer = IsolatePolicy.developer(); + + try (ActorRuntime runtime = new ActorRuntime(developer)) { + var privateSharedMemory = runtime.spawnPrivate( + factoryContext -> (message, context) -> + OresContext.requireEffectiveCapability( + IsolatePolicy.developer(), + IsolatePolicy.Capability.SHARED_MEMORY, + "indirect-helper-shared-memory")); + + var privateReadonlyShare = runtime.spawnPrivate( + factoryContext -> (message, context) -> + OresContext.requireEffectiveCapability( + IsolatePolicy.developer(), + IsolatePolicy.Capability.ACTOR_SHARE_READONLY, + "indirect-helper-readonly-share")); + + var shared = runtime.spawnShared(factoryContext -> (message, context) -> { + OresContext.requireEffectiveCapability( + IsolatePolicy.developer(), + IsolatePolicy.Capability.SHARED_MEMORY, + "shared-actor-shared-memory"); + OresContext.requireEffectiveCapability( + IsolatePolicy.developer(), + IsolatePolicy.Capability.ACTOR_SHARE_READONLY, + "shared-actor-readonly-share"); + context.self().stop(); + }); + + privateSharedMemory.send("check"); + privateReadonlyShare.send("check"); + shared.send("check"); + + assertTrue(privateSharedMemory.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(privateReadonlyShare.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(shared.awaitTermination(2, TimeUnit.SECONDS)); + + assertInstanceOf(SecurityException.class, privateSharedMemory.failure().orElseThrow()); + assertInstanceOf(SecurityException.class, privateReadonlyShare.failure().orElseThrow()); + assertTrue(shared.failure().isEmpty()); + } + } + @Test + void privateActorCannotLaunderSharedMemoryThroughFunctionValue() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + fnc build_shared(): void { + val shared = SharedMutex.new(1); + stdio.println(shared); + return; + } + + isoactor PrivateWorker { + pub fnc run(): void { + val callback = build_shared; + callback(); + return; + } + } + """)); + + SecurityException error = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.developer())); + + assertTrue(error.getMessage().contains("SHARED_MEMORY")); + } + + @Test + void privateActorCannotLaunderReadonlyShareThroughQualifiedFunctionValue() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + define module helpers + pub fnc expose(): void { + val shared = process.share_readonly(arr[1, 2, 3]); + stdio.println(shared); + return; + } + end + + isoactor PrivateWorker { + pub fnc run(): void { + val callback = helpers.expose; + callback(); + return; + } + } + """)); + + SecurityException error = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.developer())); + + assertTrue(error.getMessage().contains("ACTOR_SHARE_READONLY")); + } + + + @Test + void privateActorCannotLaunderSharedMemoryThroughStaticMethodValue() { + Ast.Program program = TypeChecker.check(Parser.parse(""" + define class Helpers as + pub static fnc build_shared(): void { + val shared = SharedMutex.new(1); + stdio.println(shared); + return; + } + end + + isoactor PrivateWorker { + pub fnc run(): void { + val callback = Helpers.build_shared; + callback(); + return; + } + } + """)); + + SecurityException error = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.developer())); + + assertTrue(error.getMessage().contains("SHARED_MEMORY")); + } + + + @Test + void invalidOreslangPrivateActorSharingFixtureIsRejected() throws Exception { + String source = Files.readString(Path.of("examples/private-actor-sharing-invalid.ores")); + Ast.Program program = TypeChecker.check(Parser.parse(source)); + + SecurityException error = assertThrows( + SecurityException.class, + () -> CapabilityChecker.check(program, IsolatePolicy.developer())); + + assertTrue( + error.getMessage().contains("SHARED_MEMORY") + || error.getMessage().contains("ACTOR_SHARE_READONLY")); + } + + +} diff --git a/src/test/java/dev/oreslang/runtime/ActorRuntimeNativeCarrierTest.java b/src/test/java/dev/oreslang/runtime/ActorRuntimeNativeCarrierTest.java new file mode 100644 index 00000000..6b2e5638 --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/ActorRuntimeNativeCarrierTest.java @@ -0,0 +1,71 @@ +package dev.oreslang.runtime; + +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +final class ActorRuntimeNativeCarrierTest { + + @Test + void actorRuntimeUsesNativePthreadCarriersWhenRequired() throws Exception { + String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT); + assumeTrue(os.contains("linux") || os.contains("mac") || os.contains("darwin")); + + String previous = System.getProperty("ores.runtime.carriers"); + System.setProperty("ores.runtime.carriers", "native"); + try { + var config = new ActorRuntime.DispatcherConfig(1, 1, 8, 128); + try (ActorRuntime runtime = new ActorRuntime(IsolatePolicy.developer(), config)) { + assertEquals( + ActorRuntime.CarrierBackend.NATIVE_PTHREAD, + runtime.carrierBackend()); + + CountDownLatch delivered = new CountDownLatch(32); + Set nativeThreads = ConcurrentHashMap.newKeySet(); + + var ref = runtime.spawnShared(() -> (message, context) -> { + assertTrue(NativeCarrierExecutor.isNativeCarrierThread()); + assertFalse(Thread.currentThread().isVirtual()); + long pthread = NativeCarrierExecutor.currentNativeThreadId(); + assertNotEquals(0L, pthread); + nativeThreads.add(pthread); + delivered.countDown(); + if (message == 31) context.self().stop(); + }); + + for (int i = 0; i < 32; i++) ref.send(i); + + assertTrue(delivered.await(5, TimeUnit.SECONDS)); + assertTrue(ref.awaitTermination(5, TimeUnit.SECONDS)); + assertEquals( + 1, + nativeThreads.size(), + "one shared actor must multiplex its turns onto the configured native carrier"); + } + } finally { + if (previous == null) System.clearProperty("ores.runtime.carriers"); + else System.setProperty("ores.runtime.carriers", previous); + } + } + + @Test + void invalidCarrierBackendFailsClosed() { + String previous = System.getProperty("ores.runtime.carriers"); + System.setProperty("ores.runtime.carriers", "mystery"); + try { + assertThrows( + IllegalArgumentException.class, + () -> new ActorRuntime(IsolatePolicy.developer(), + new ActorRuntime.DispatcherConfig(1, 1, 8, 32))); + } finally { + if (previous == null) System.clearProperty("ores.runtime.carriers"); + else System.setProperty("ores.runtime.carriers", previous); + } + } +} diff --git a/src/test/java/dev/oreslang/runtime/ActorStructuredCancellationTest.java b/src/test/java/dev/oreslang/runtime/ActorStructuredCancellationTest.java new file mode 100644 index 00000000..4627cf87 --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/ActorStructuredCancellationTest.java @@ -0,0 +1,372 @@ +package dev.oreslang.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +final class ActorStructuredCancellationTest { + @Test + void actorSpawnedFromActorTurnIsAChildAndParentStopCancelsIt() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch childCreated = new CountDownLatch(1); + AtomicReference> childRef = new AtomicReference<>(); + + ActorRuntime.ActorRef parent = runtime.spawnShared(() -> (message, context) -> { + ActorRuntime.ActorRef child = + context.runtime().spawnPrivate( + factoryContext -> (childMessage, childContext) -> { }); + childRef.set(child); + childCreated.countDown(); + + assertEquals(context.self().id(), child.parentId().orElseThrow()); + assertTrue(context.self().childIds().contains(child.id())); + context.self().stop(); + }); + + parent.send("spawn"); + assertTrue(childCreated.await(2, TimeUnit.SECONDS)); + + ActorRuntime.ActorRef child = childRef.get(); + assertTrue(parent.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(child.awaitTermination(2, TimeUnit.SECONDS)); + assertFalse(parent.isAlive()); + assertFalse(child.isAlive()); + assertInstanceOf( + ActorRuntime.ActorCancelledException.class, + child.failure().orElseThrow()); + } + } + + @Test + void childActorRefDoesNotGrantAuthorityToCancelParent() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch denied = new CountDownLatch(1); + + ActorRuntime.ActorRef parent = + runtime.spawnShared(() -> (message, context) -> { + if ("spawn-child".equals(message)) { + ActorRuntime.ActorRef child = + context.runtime().spawnShared( + factoryContext -> (childMessage, childContext) -> { + if (childMessage instanceof ActorRuntime.ActorRef parentRef) { + try { + parentRef.cancel(); + } catch (SecurityException expected) { + @SuppressWarnings("unchecked") + ActorRuntime.ActorRef reply = + (ActorRuntime.ActorRef) parentRef; + reply.send("denied"); + childContext.self().stop(); + } + } + }); + child.send(context.self()); + return; + } + + if ("denied".equals(message)) { + denied.countDown(); + context.self().stop(); + } + }); + + parent.send("spawn-child"); + + assertTrue(denied.await(2, TimeUnit.SECONDS)); + assertTrue(parent.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(parent.failure().isEmpty()); + } + } + + @Test + void parentMayCancelStructuredChildWithoutBlockingCarrier() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch requested = new CountDownLatch(1); + AtomicReference> childRef = + new AtomicReference<>(); + + ActorRuntime.ActorRef parent = + runtime.spawnShared(() -> (message, context) -> { + ActorRuntime.ActorRef child = + context.runtime().spawnPrivate( + factoryContext -> (childMessage, childContext) -> { }); + childRef.set(child); + assertTrue(child.cancel()); + requested.countDown(); + context.self().stop(); + }); + + parent.send("cancel-child"); + + assertTrue(requested.await(2, TimeUnit.SECONDS)); + assertTrue(parent.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(childRef.get().awaitTermination(2, TimeUnit.SECONDS)); + assertInstanceOf( + ActorRuntime.ActorCancelledException.class, + childRef.get().failure().orElseThrow()); + } + } + + @Test + void explicitCancellationCascadesThroughChildTree() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch childCreated = new CountDownLatch(1); + AtomicReference> childRef = new AtomicReference<>(); + + ActorRuntime.ActorRef parent = runtime.spawnShared(() -> (message, context) -> { + ActorRuntime.ActorRef child = + context.runtime().spawnPrivate( + factoryContext -> (childMessage, childContext) -> { }); + childRef.set(child); + childCreated.countDown(); + }); + + parent.send("spawn"); + assertTrue(childCreated.await(2, TimeUnit.SECONDS)); + + assertTrue(parent.cancel()); + assertTrue(parent.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(childRef.get().awaitTermination(2, TimeUnit.SECONDS)); + assertInstanceOf( + ActorRuntime.ActorCancelledException.class, + parent.failure().orElseThrow()); + assertInstanceOf( + ActorRuntime.ActorCancelledException.class, + childRef.get().failure().orElseThrow()); + } + } + + @Test + void forceCancellationFailsClosedWithoutIsolationAuthority() { + try (ActorRuntime runtime = new ActorRuntime()) { + ActorRuntime.ActorRef actor = + runtime.spawnPrivate(() -> (message, context) -> { }); + + assertThrows(IllegalStateException.class, actor::forceCancel); + assertTrue(actor.isAlive(), "denied force cancellation must have no logical side effect"); + actor.stop(); + } + } + + @Test + void structuredCancellationCannotBeSwallowedAsOrdinaryRuntimeFailure() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch entered = new CountDownLatch(1); + AtomicBoolean swallowed = new AtomicBoolean(); + + ActorRuntime.ActorRef actor = runtime.spawnShared(() -> (message, context) -> { + entered.countDown(); + try { + while (true) { + context.runtime().schedulerSafepoint(); + } + } catch (RuntimeException ordinaryFailure) { + swallowed.set(true); + } + }); + + actor.send("run"); + assertTrue(entered.await(2, TimeUnit.SECONDS)); + assertTrue(actor.cancel()); + assertTrue(actor.awaitTermination(2, TimeUnit.SECONDS)); + assertFalse(swallowed.get(), "actor cancellation is control flow, not a catchable guest failure"); + assertInstanceOf( + ActorRuntime.ActorCancelledException.class, + actor.failure().orElseThrow()); + } + } + + @Test + void runtimeRejectsLocalChannelCapabilityAsMessagePayload() { + try (ActorRuntime runtime = new ActorRuntime()) { + ActorRuntime.ActorRef actor = + runtime.spawnShared(() -> (message, context) -> { }); + ChannelRuntime.Channel localChannel = + new ChannelRuntime.Channel<>(1); + + IllegalArgumentException rejected = assertThrows( + IllegalArgumentException.class, + () -> actor.send(localChannel)); + assertTrue(rejected.getMessage().contains("execution-domain local")); + actor.stop(); + } + } + + @Test + void actorTeardownCancelsOwnedNonBlockingChannelRegistration() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + ChannelRuntime.Channel channel = + new ChannelRuntime.Channel<>(0); + AtomicReference> pending = new AtomicReference<>(); + CountDownLatch registered = new CountDownLatch(1); + + ActorRuntime.ActorRef actor = + runtime.spawnShared(() -> (message, context) -> { + OresFuture read = context.runtime() + .ownCurrentActorFuture(channel.readAsync()); + pending.set(read); + registered.countDown(); + context.self().stop(); + }); + + actor.send("register"); + assertTrue(registered.await(2, TimeUnit.SECONDS)); + assertTrue(actor.awaitTermination(2, TimeUnit.SECONDS)); + + OresFuture read = pending.get(); + assertTrue( + read.isCancelled(), + "actor teardown must cancel an abandoned nb channel waiter"); + assertFalse( + channel.tryWrite("orphan"), + "dead actor's cancelled read must not remain registered"); + } + } + + @Test + void continuationHeadroomDoesNotReduceConfiguredUserMailboxCapacity() throws Exception { + IsolatePolicy base = IsolatePolicy.developer(); + IsolatePolicy tinyMailbox = new IsolatePolicy( + base.capabilities(), + base.maxHeapBytes(), + 2, + Duration.ofSeconds(5), + false); + + try (ActorRuntime runtime = new ActorRuntime(base)) { + CountDownLatch userMessagesAdmitted = new CountDownLatch(1); + CountDownLatch userMessagesDelivered = new CountDownLatch(2); + AtomicInteger delivered = new AtomicInteger(); + + ActorRuntime.ActorRef actor = + runtime.spawnShared(tinyMailbox, () -> (message, context) -> { + if (message.equals("seed")) { + ActorRuntime.ContinuationTarget target = + context.runtime().captureCurrentContinuationTarget(); + + // These complete immediately but their continuations + // must queue behind the current actor turn. + for (int i = 0; i < 3; i++) { + context.runtime().enqueueOnCompletion( + OresFuture.completed(i), + target, + (value, failure) -> { }); + } + + // User capacity remains exactly maxMailboxMessages=2 + // despite the already-queued runtime continuations. + context.self().send("u1"); + context.self().send("u2"); + userMessagesAdmitted.countDown(); + return; + } + + if (message.equals("u1") || message.equals("u2")) { + delivered.incrementAndGet(); + userMessagesDelivered.countDown(); + if (delivered.get() == 2) context.self().stop(); + } + }); + + actor.send("seed"); + + assertTrue( + userMessagesAdmitted.await(2, TimeUnit.SECONDS), + "runtime continuations must not steal user mailbox quota"); + assertTrue(userMessagesDelivered.await(2, TimeUnit.SECONDS)); + assertTrue(actor.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(actor.failure().isEmpty()); + } + } + + @Test + void carrierInterruptIsNotActorCancellationIdentity() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + CountDownLatch continuedAfterSafepoint = new CountDownLatch(1); + + ActorRuntime.ActorRef actor = + runtime.spawnShared(() -> (message, context) -> { + Thread.currentThread().interrupt(); + try { + context.runtime().schedulerSafepoint(); + continuedAfterSafepoint.countDown(); + } finally { + // Do not leak this host-side test interrupt into a + // pooled carrier after the actor turn completes. + Thread.interrupted(); + context.self().stop(); + } + }); + + actor.send("interrupt-carrier"); + + assertTrue( + continuedAfterSafepoint.await(2, TimeUnit.SECONDS), + "carrier interrupt must not be interpreted as actor cancellation"); + assertTrue(actor.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue( + actor.failure().isEmpty(), + "carrier interrupt must not record an actor failure/cancellation"); + } + } + + @Test + void ordinaryActorCannotInvokeForceCancellationAuthority() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + AtomicBoolean revokerInvoked = new AtomicBoolean(); + CountDownLatch denied = new CountDownLatch(1); + + runtime.setForceCancellationHook((actorId, executionDomain) -> { + revokerInvoked.set(true); + return true; + }); + + ActorRuntime.ActorRef actor = + runtime.spawnShared(() -> (message, context) -> { + try { + context.self().forceCancel(); + } catch (SecurityException expected) { + denied.countDown(); + context.self().stop(); + } + }); + + actor.send("attempt-force-cancel"); + + assertTrue(denied.await(2, TimeUnit.SECONDS)); + assertFalse( + revokerInvoked.get(), + "ordinary actor code must never reach host isolate-revocation authority"); + assertTrue(actor.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(actor.failure().isEmpty()); + } + } + + @Test + void forceCancellationDelegatesToOuterIsolationRevoker() throws Exception { + try (ActorRuntime runtime = new ActorRuntime()) { + AtomicBoolean invoked = new AtomicBoolean(); + runtime.setForceCancellationHook((actorId, executionDomain) -> { + invoked.set(true); + return true; + }); + + ActorRuntime.ActorRef actor = + runtime.spawnPrivate(() -> (message, context) -> { }); + + assertTrue(actor.forceCancel()); + assertTrue(invoked.get()); + assertTrue(actor.awaitTermination(2, TimeUnit.SECONDS)); + } + } +} diff --git a/src/test/java/dev/oreslang/runtime/AsyncRuntimeTest.java b/src/test/java/dev/oreslang/runtime/AsyncRuntimeTest.java new file mode 100644 index 00000000..05a80131 --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/AsyncRuntimeTest.java @@ -0,0 +1,76 @@ +package dev.oreslang.runtime; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +final class AsyncRuntimeTest { + @Test + void submitReturnsComposableFutureAndDoesNotUseActorDispatcher() throws Exception { + try (AsyncRuntime runtime = new AsyncRuntime()) { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + CompletableFuture future = runtime.submit(() -> { + assertTrue(AsyncRuntime.isAsyncCarrierThread()); + assertFalse(ActorRuntime.isActorCarrierThread()); + started.countDown(); + assertTrue(release.await(2, TimeUnit.SECONDS)); + return 42; + }); + + assertTrue(started.await(2, TimeUnit.SECONDS)); + assertFalse(future.isDone()); + release.countDown(); + assertEquals(42, AsyncRuntime.await(future)); + } + } + + @Test + void cancellationInterruptsBackingVirtualTask() throws Exception { + try (AsyncRuntime runtime = new AsyncRuntime()) { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + + CompletableFuture future = runtime.submit(() -> { + started.countDown(); + try { + Thread.sleep(TimeUnit.SECONDS.toMillis(30)); + return 1; + } catch (InterruptedException expected) { + interrupted.countDown(); + throw expected; + } + }); + + assertTrue(started.await(2, TimeUnit.SECONDS)); + assertTrue(future.cancel(true)); + assertTrue(interrupted.await(2, TimeUnit.SECONDS)); + assertTrue(future.isCancelled()); + } + } + + @Test + void awaitPropagatesOriginalRuntimeFailure() { + CompletableFuture future = new CompletableFuture<>(); + IllegalStateException original = new IllegalStateException("boom"); + future.completeExceptionally(original); + + IllegalStateException observed = + assertThrows(IllegalStateException.class, () -> AsyncRuntime.await(future)); + assertSame(original, observed); + } + + @Test + void closeRejectsNewTasks() { + AsyncRuntime runtime = new AsyncRuntime(); + runtime.close(); + assertThrows(java.util.concurrent.RejectedExecutionException.class, + () -> runtime.submit(() -> 1)); + } +} diff --git a/src/test/java/dev/oreslang/runtime/ChannelRuntimeTest.java b/src/test/java/dev/oreslang/runtime/ChannelRuntimeTest.java new file mode 100644 index 00000000..c55b20f5 --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/ChannelRuntimeTest.java @@ -0,0 +1,279 @@ +package dev.oreslang.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.concurrent.CancellationException; +import org.junit.jupiter.api.Test; + +final class ChannelRuntimeTest { + @Test + void bufferedChannelIsFifo() { + ChannelRuntime.Channel channel = new ChannelRuntime.Channel<>(2); + + assertTrue(channel.tryWrite("a")); + assertTrue(channel.tryWrite("b")); + assertFalse(channel.tryWrite("c")); + + assertEquals("a", channel.tryRead().orElseThrow()); + assertEquals("b", channel.tryRead().orElseThrow()); + assertTrue(channel.tryRead().isEmpty()); + } + + @Test + void cancelledReadDoesNotConsumeLaterValue() { + ChannelRuntime.Channel channel = new ChannelRuntime.Channel<>(1); + OresFuture pending = channel.readAsync(); + + assertTrue(pending.cancel(false)); + assertTrue(pending.isCancelled()); + assertThrows(CancellationException.class, pending::join); + + assertTrue(channel.tryWrite("kept")); + assertEquals("kept", channel.tryRead().orElseThrow()); + } + + @Test + void cancelledWriteDoesNotDeliverLater() { + ChannelRuntime.Channel channel = new ChannelRuntime.Channel<>(0); + OresFuture pending = channel.writeAsync("discarded"); + + assertTrue(pending.cancel(false)); + assertThrows(CancellationException.class, pending::join); + + OresFuture read = channel.readAsync(); + assertFalse(read.isDone()); + read.cancel(false); + } + + @Test + void prioritySelectAlwaysUsesLexicalOrderWhenMultipleCasesAreReady() { + ChannelRuntime.Channel first = new ChannelRuntime.Channel<>(1); + ChannelRuntime.Channel second = new ChannelRuntime.Channel<>(1); + first.tryWrite("one"); + second.tryWrite("two"); + + ChannelRuntime.SelectSet set = ChannelRuntime.SelectSet.of( + ChannelRuntime.read(first), + ChannelRuntime.read(second)); + + ChannelRuntime.SelectResult result = + set.selectAsync(ChannelRuntime.SelectPolicy.PRIORITY).join(); + + assertEquals(0, result.index()); + assertEquals(ChannelRuntime.SelectOperation.READ, result.operation()); + assertEquals("one", result.value()); + assertEquals("two", second.tryRead().orElseThrow()); + } + + @Test + void fairSelectRotatesOnAStableSelectSetWithoutRandomness() { + ChannelRuntime.Channel first = new ChannelRuntime.Channel<>(1); + ChannelRuntime.Channel second = new ChannelRuntime.Channel<>(1); + first.tryWrite("a1"); + second.tryWrite("b1"); + + ChannelRuntime.SelectSet set = ChannelRuntime.SelectSet.of( + ChannelRuntime.read(first), + ChannelRuntime.read(second)); + + ChannelRuntime.SelectResult firstResult = + set.selectAsync(ChannelRuntime.SelectPolicy.FAIR).join(); + assertEquals(0, firstResult.index()); + + first.tryWrite("a2"); + ChannelRuntime.SelectResult secondResult = + set.selectAsync(ChannelRuntime.SelectPolicy.FAIR).join(); + assertEquals(1, secondResult.index()); + assertEquals("b1", secondResult.value()); + } + + @Test + void nonBlockingSelectRegistrationReturnsPendingFutureAndCompletesLater() { + ChannelRuntime.Channel incoming = new ChannelRuntime.Channel<>(1); + ChannelRuntime.SelectSet set = ChannelRuntime.SelectSet.of( + ChannelRuntime.read(incoming)); + + OresFuture pending = set.selectAsync(); + assertFalse(pending.isDone()); + + incoming.writeAsync("later").join(); + + assertTrue(pending.isDone()); + assertEquals("later", pending.join().value()); + } + + @Test + void cancellingSelectCannotConsumeAfterCancellationWins() { + ChannelRuntime.Channel incoming = new ChannelRuntime.Channel<>(1); + ChannelRuntime.SelectSet set = ChannelRuntime.SelectSet.of( + ChannelRuntime.read(incoming)); + + OresFuture pending = set.selectAsync(); + assertTrue(pending.cancel(false)); + + incoming.writeAsync("still-there").join(); + + assertThrows(CancellationException.class, pending::join); + assertEquals("still-there", incoming.tryRead().orElseThrow()); + } + + @Test + void rendezvousChannelPairsSelectReadWithPendingWrite() { + ChannelRuntime.Channel channel = new ChannelRuntime.Channel<>(0); + ChannelRuntime.SelectSet set = ChannelRuntime.SelectSet.of( + ChannelRuntime.read(channel)); + + OresFuture selected = set.selectAsync(); + OresFuture write = channel.writeAsync("hand-off"); + + assertEquals("hand-off", selected.join().value()); + write.join(); + } + + @Test + void twoPendingSelectsCanRendezvousOnUnbufferedChannel() { + ChannelRuntime.Channel channel = + new ChannelRuntime.Channel<>(0); + + OresFuture writer = + ChannelRuntime.SelectSet.of( + ChannelRuntime.write(channel, 123)) + .selectAsync(ChannelRuntime.SelectPolicy.PRIORITY); + assertFalse(writer.isDone()); + + OresFuture reader = + ChannelRuntime.SelectSet.of( + ChannelRuntime.read(channel)) + .selectAsync(ChannelRuntime.SelectPolicy.PRIORITY); + + assertEquals(123, reader.join().value()); + assertEquals( + ChannelRuntime.SelectOperation.WRITE, + writer.join().operation()); + assertTrue(channel.tryRead().isEmpty()); + } + + @Test + void selectToSelectRendezvousCommitsExactlyOneArmPerSelection() { + ChannelRuntime.Channel rendezvous = + new ChannelRuntime.Channel<>(0); + ChannelRuntime.Channel alternate = + new ChannelRuntime.Channel<>(1); + alternate.tryWrite(77); + + ChannelRuntime.SelectSet readerSet = + ChannelRuntime.SelectSet.of( + ChannelRuntime.read(rendezvous), + ChannelRuntime.read(alternate)); + OresFuture reader = + readerSet.selectAsync(ChannelRuntime.SelectPolicy.PRIORITY); + + OresFuture writer = + ChannelRuntime.SelectSet.of( + ChannelRuntime.write(rendezvous, 99)) + .selectAsync(ChannelRuntime.SelectPolicy.PRIORITY); + + ChannelRuntime.SelectResult result = reader.join(); + if (result.index() == 0) { + assertEquals(99, result.value()); + assertEquals(77, alternate.tryRead().orElseThrow()); + assertEquals( + ChannelRuntime.SelectOperation.WRITE, + writer.join().operation()); + } else { + assertEquals(77, result.value()); + assertFalse(writer.isDone()); + assertTrue(writer.cancel(false)); + assertTrue(rendezvous.tryRead().isEmpty()); + } + } + + @Test + void dynamicSelectCanBeBuiltFromListsAndMaps() { + ChannelRuntime.Channel one = new ChannelRuntime.Channel<>(1); + ChannelRuntime.Channel two = new ChannelRuntime.Channel<>(1); + two.tryWrite("two"); + + ChannelRuntime.SelectSet listSet = ChannelRuntime.SelectSet.from(List.of( + ChannelRuntime.read(one), + ChannelRuntime.read(two))); + assertEquals(1, + listSet.selectAsync(ChannelRuntime.SelectPolicy.PRIORITY).join().index()); + + one.tryWrite("one"); + LinkedHashMap cases = new LinkedHashMap<>(); + cases.put("one", ChannelRuntime.read(one)); + cases.put("two", ChannelRuntime.read(two)); + ChannelRuntime.SelectSet mapSet = ChannelRuntime.SelectSet.fromMap(cases); + + assertEquals(0, + mapSet.selectAsync(ChannelRuntime.SelectPolicy.PRIORITY).join().index()); + } + + @Test + void immediateWriteRendezvousWithPendingSelectRead() { + ChannelRuntime.Channel channel = new ChannelRuntime.Channel<>(0); + OresFuture selected = + ChannelRuntime.SelectSet.of( + ChannelRuntime.read(channel)) + .selectAsync(ChannelRuntime.SelectPolicy.PRIORITY); + + assertTrue(channel.tryWrite("direct")); + assertEquals("direct", selected.join().value()); + } + + @Test + void immediateReadRendezvousWithPendingSelectWrite() { + ChannelRuntime.Channel channel = new ChannelRuntime.Channel<>(0); + OresFuture selected = + ChannelRuntime.SelectSet.of( + ChannelRuntime.write(channel, "direct")) + .selectAsync(ChannelRuntime.SelectPolicy.PRIORITY); + + assertEquals("direct", channel.tryRead().orElseThrow()); + assertEquals( + ChannelRuntime.SelectOperation.WRITE, + selected.join().operation()); + } + + @Test + void fairSelectRotatesPastClosedArmAfterTerminalFailure() { + ChannelRuntime.Channel closed = + new ChannelRuntime.Channel<>(1); + ChannelRuntime.Channel ready = + new ChannelRuntime.Channel<>(1); + closed.close(); + ready.tryWrite("value"); + + ChannelRuntime.SelectSet set = ChannelRuntime.SelectSet.of( + ChannelRuntime.read(closed), + ChannelRuntime.read(ready)); + + assertThrows( + RuntimeException.class, + () -> set.selectAsync(ChannelRuntime.SelectPolicy.FAIR).join()); + + ChannelRuntime.SelectResult next = + set.selectAsync(ChannelRuntime.SelectPolicy.FAIR).join(); + assertEquals(1, next.index()); + assertEquals("value", next.value()); + } + + @Test + void closeFailsPendingWaitersAndSelects() { + ChannelRuntime.Channel channel = new ChannelRuntime.Channel<>(0); + OresFuture read = channel.readAsync(); + OresFuture selected = + ChannelRuntime.SelectSet.of(ChannelRuntime.read(channel)).selectAsync(); + + channel.close(); + + assertThrows(RuntimeException.class, read::join); + assertThrows(RuntimeException.class, selected::join); + } +} diff --git a/src/test/java/dev/oreslang/runtime/HungryActorTest.java b/src/test/java/dev/oreslang/runtime/HungryActorTest.java new file mode 100644 index 00000000..705a0591 --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/HungryActorTest.java @@ -0,0 +1,136 @@ +package dev.oreslang.runtime; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +final class HungryActorTest { + @Test + void ownsOnePlatformThreadUntilBehaviorReleasesIt() throws Exception { + CountDownLatch started = new CountDownLatch(1); + AtomicReference firstThread = new AtomicReference<>(); + AtomicReference secondThread = new AtomicReference<>(); + + try (HungryActor actor = new HungryActor<>( + "cpu", + 8, + (message, context) -> { + if (message.equals("one")) { + firstThread.set(Thread.currentThread().getName()); + started.countDown(); + } else if (message.equals("two")) { + secondThread.set(Thread.currentThread().getName()); + context.release(); + } + })) { + assertFalse(actor.isVirtualCarrier(), "HungryActor must reserve a platform/OS carrier"); + assertTrue(actor.isAlive(), "dedicated pthread starts with actor lifetime"); + + actor.send("one"); + assertTrue(started.await(2, TimeUnit.SECONDS)); + assertTrue(actor.isNativeCarrier(), "HungryActor must execute on the JNI pthread backend"); + assertNotEquals(0L, actor.nativeThreadId()); + actor.send("two"); + + assertTrue(actor.awaitTermination(2, TimeUnit.SECONDS)); + assertEquals(firstThread.get(), secondThread.get()); + assertEquals(actor.threadName(), firstThread.get()); + assertTrue(actor.failure().isEmpty()); + } + } + + @Test + void cpuBoundHungryActorDoesNotStarveOrdinaryActorDispatcher() throws Exception { + CountDownLatch hungryStarted = new CountDownLatch(1); + AtomicBoolean releaseCpu = new AtomicBoolean(); + + try (HungryActor hungry = new HungryActor<>( + "hot-loop", + 2, + (message, context) -> { + hungryStarted.countDown(); + while (!releaseCpu.get()) { + context.schedulerSafepoint(); + } + context.release(); + }); + ActorRuntime runtime = new ActorRuntime( + IsolatePolicy.developer(), + new ActorRuntime.DispatcherConfig(1, 1, 8))) { + + hungry.send("spin"); + assertTrue(hungryStarted.await(2, TimeUnit.SECONDS)); + + CountDownLatch ordinaryRan = new CountDownLatch(1); + var ordinary = runtime.spawnPrivate(() -> (message, context) -> { + ordinaryRan.countDown(); + context.self().stop(); + }); + ordinary.send("ping"); + + assertTrue( + ordinaryRan.await(2, TimeUnit.SECONDS), + "dedicated CPU work must not consume the ordinary actor dispatcher"); + releaseCpu.set(true); + assertTrue(hungry.awaitTermination(2, TimeUnit.SECONDS)); + } + } + + @Test + void mailboxIsBoundedAndMessagesAreFrozen() throws Exception { + CountDownLatch received = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + + try (HungryActor> actor = new HungryActor<>( + "freeze", + 2, + (message, context) -> { + observed.set(message); + received.countDown(); + context.release(); + })) { + java.util.ArrayList mutable = + new java.util.ArrayList<>(java.util.List.of(1, 2)); + actor.send(mutable); + mutable.add(3); + + assertTrue(received.await(2, TimeUnit.SECONDS)); + assertEquals(java.util.List.of(1, 2), observed.get()); + } + } + + @Test + void mailboxCapacityIsEnforcedWithoutBlockingTheSender() throws Exception { + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + + try (HungryActor actor = new HungryActor<>( + "bounded", + 1, + (message, context) -> { + if (message.equals("first")) { + firstStarted.countDown(); + assertTrue(releaseFirst.await(2, TimeUnit.SECONDS)); + } else { + context.release(); + } + })) { + actor.send("first"); + assertTrue(firstStarted.await(2, TimeUnit.SECONDS)); + + actor.send("queued"); + IllegalStateException full = assertThrows( + IllegalStateException.class, + () -> actor.send("overflow")); + assertTrue(full.getMessage().contains("mailbox limit exceeded")); + + releaseFirst.countDown(); + assertTrue(actor.awaitTermination(2, TimeUnit.SECONDS)); + } + } +} diff --git a/src/test/java/dev/oreslang/runtime/NativeCarrierExecutorTest.java b/src/test/java/dev/oreslang/runtime/NativeCarrierExecutorTest.java new file mode 100644 index 00000000..5b7f3e9e --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/NativeCarrierExecutorTest.java @@ -0,0 +1,49 @@ +package dev.oreslang.runtime; + +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +final class NativeCarrierExecutorTest { + + @Test + void manyLogicalTurnsMultiplexOntoOnePthreadCarrier() throws Exception { + String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT); + assumeTrue(os.contains("linux") || os.contains("mac") || os.contains("darwin")); + try (NativeCarrierExecutor executor = + new NativeCarrierExecutor(1, 1, 64, "ores-native-test-")) { + CountDownLatch done = new CountDownLatch(32); + Set pthreadIds = ConcurrentHashMap.newKeySet(); + + for (int i = 0; i < 32; i++) { + executor.execute(() -> { + assertTrue(NativeCarrierExecutor.isNativeCarrierThread()); + assertFalse(Thread.currentThread().isVirtual()); + assertEquals(0, NativeCarrierExecutor.currentCarrierSlot()); + long nativeId = NativeCarrierExecutor.currentNativeThreadId(); + assertNotEquals(0L, nativeId); + pthreadIds.add(nativeId); + done.countDown(); + }); + } + + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertEquals(1, pthreadIds.size(), + "logical Ores turns must multiplex over the configured pthread carrier"); + + long accountingDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (executor.getCompletedTaskCount() != 32L + && System.nanoTime() < accountingDeadline) { + Thread.onSpinWait(); + } + assertEquals(32L, executor.getCompletedTaskCount(), + "completion accounting must publish after each carrier turn returns"); + } + } +} diff --git a/src/test/java/dev/oreslang/runtime/OresFutureCallbackTest.java b/src/test/java/dev/oreslang/runtime/OresFutureCallbackTest.java new file mode 100644 index 00000000..6fa04bb4 --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/OresFutureCallbackTest.java @@ -0,0 +1,179 @@ +package dev.oreslang.runtime; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +final class OresFutureCallbackTest { + + @Test + void fromCallbackMaySettleSynchronouslyButRemainsSingleShot() throws Exception { + AtomicReference> completion = new AtomicReference<>(); + + OresFuture future = OresFuture.fromCallback(callback -> { + completion.set(callback); + callback.resolve(41); + }); + + assertEquals(41, future.get(5, TimeUnit.SECONDS)); + assertTrue(completion.get().isDone()); + assertThrows( + OresFuture.AlreadySettledException.class, + () -> completion.get().resolve(42)); + assertEquals(41, future.get(5, TimeUnit.SECONDS)); + } + + @Test + void lateRuntimeWaiterOnSettledFutureIsDeliveredAndReleased() { + OresFuture future = OresFuture.completed(42); + AtomicReference observed = new AtomicReference<>(); + + future.whenCompleteRuntime((value, failure) -> { + assertNull(failure); + observed.set(value); + }); + + assertEquals(42, observed.get()); + assertEquals( + 0, + future.pendingRuntimeWaiterCount(), + "late terminal registrations must not remain retained in the waiter queue"); + } + + @Test + void consumerCancellationDropsFirstLateForeignCallbackButStillRejectsDuplicates() { + AtomicReference> completion = new AtomicReference<>(); + + OresFuture future = OresFuture.fromCallback(completion::set); + + assertTrue(future.cancel(false)); + assertTrue(future.isCancelled()); + assertTrue(completion.get().isDone()); + + assertDoesNotThrow(() -> completion.get().resolve(42)); + assertTrue(future.isCancelled()); + + assertThrows( + OresFuture.AlreadySettledException.class, + () -> completion.get().resolve(43)); + } + + @Test + void registrarThrowRejectsFutureWhenCallbackHasNotSettled() { + OresFuture future = OresFuture.fromCallback(callback -> { + throw new IllegalStateException("registration failed"); + }); + + var failure = assertThrows( + java.util.concurrent.ExecutionException.class, + () -> future.get(5, TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, failure.getCause()); + assertEquals("registration failed", failure.getCause().getMessage()); + } + + @Test + void attachedCallbackPreservesSharedSourceCancellation() throws Exception { + try (OresScheduler scheduler = new OresScheduler(1)) { + OresFuture source = new OresFuture<>(); + AtomicBoolean registrarCalled = new AtomicBoolean(); + + OresFuture chained = source.attachCallback( + scheduler, + (value, callback) -> { + registrarCalled.set(true); + callback.resolve(value + 1); + }); + + assertTrue(source.cancel(true)); + + assertThrows( + java.util.concurrent.CancellationException.class, + () -> chained.get(5, TimeUnit.SECONDS)); + assertTrue(chained.isCancelled()); + assertFalse(registrarCalled.get(), + "callback registrar must not run after its source was cancelled"); + } + } + + @Test + void attachedCallbackPreservesCallbackCancellation() throws Exception { + try (OresScheduler scheduler = new OresScheduler(1)) { + OresFuture chained = OresFuture.completed(40) + .attachCallback( + scheduler, + (value, callback) -> callback.cancel()); + + assertThrows( + java.util.concurrent.CancellationException.class, + () -> chained.get(5, TimeUnit.SECONDS)); + assertTrue(chained.isCancelled()); + } + } + + @Test + void cancellingAttachedChainDoesNotCancelSharedSourceAndDropsLateCallback() + throws Exception { + try (OresScheduler scheduler = new OresScheduler(1)) { + OresFuture source = OresFuture.completed(40); + AtomicReference> callbackRef = + new AtomicReference<>(); + + OresFuture chained = source.attachCallback( + scheduler, + (value, callback) -> callbackRef.set(callback)); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (callbackRef.get() == null && System.nanoTime() < deadline) { + Thread.onSpinWait(); + } + assertNotNull(callbackRef.get()); + + assertTrue(chained.cancel(true)); + assertTrue(chained.isCancelled()); + assertFalse(source.isCancelled(), + "cancelling a callback chain must not cancel its shared source"); + + OresFuture.Callback callback = callbackRef.get(); + assertDoesNotThrow(() -> callback.resolve(41), + "first late foreign callback after chain cancellation is dropped"); + assertThrows( + OresFuture.AlreadySettledException.class, + () -> callback.resolve(42), + "a true duplicate callback remains a programming error"); + } + } + + @Test + void synchronousAttachedCallbackCannotCompleteChainOnRegistrarStack() throws Exception { + try (OresScheduler scheduler = new OresScheduler(1)) { + AtomicBoolean insideRegistrar = new AtomicBoolean(); + AtomicBoolean completedInsideRegistrar = new AtomicBoolean(); + + OresFuture source = OresFuture.completed(40); + OresFuture chained = source.attachCallback( + scheduler, + (value, callback) -> { + insideRegistrar.set(true); + try { + callback.resolve(value + 2); + } finally { + insideRegistrar.set(false); + } + }); + + chained.whenCompleteRuntime( + (value, failure) -> + completedInsideRegistrar.set(insideRegistrar.get())); + + assertEquals(42, chained.get(5, TimeUnit.SECONDS)); + assertFalse( + completedInsideRegistrar.get(), + "synchronous callback settlement must not recursively resume " + + "the dependent Future on the registrar stack"); + } + } +} diff --git a/src/test/java/dev/oreslang/runtime/OresFuturesTest.java b/src/test/java/dev/oreslang/runtime/OresFuturesTest.java new file mode 100644 index 00000000..7135aeee --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/OresFuturesTest.java @@ -0,0 +1,253 @@ +package dev.oreslang.runtime; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static org.junit.jupiter.api.Assertions.*; + +final class OresFuturesTest { + + @Test + void allPreservesInputOrderIndependentOfCompletionOrder() { + CompletableFuture first = new CompletableFuture<>(); + CompletableFuture second = new CompletableFuture<>(); + CompletableFuture third = new CompletableFuture<>(); + + OresFuture> all = OresFutures.all(List.of(first, second, third)); + third.complete(3); + first.complete(1); + assertFalse(all.isDone()); + + second.complete(2); + assertEquals(List.of(1, 2, 3), all.join()); + } + + @Test + void completionStageCancellationNormalizesToCancelledOresFuture() { + CompletableFuture host = new CompletableFuture<>(); + OresFuture ores = OresFuture.from(host); + + assertTrue(host.cancel(true)); + + assertTrue(ores.isCancelled()); + assertThrows(CancellationException.class, ores::join); + } + + @Test + void allPreservesChildCancellationIdentity() { + CompletableFuture first = new CompletableFuture<>(); + CompletableFuture second = new CompletableFuture<>(); + OresFuture> all = OresFutures.all(List.of(first, second)); + + first.complete(1); + assertTrue(second.cancel(true)); + + assertTrue(all.isCancelled()); + assertThrows(CancellationException.class, all::join); + } + + @Test + void racePreservesFirstCancellationIdentity() { + CompletableFuture cancelled = new CompletableFuture<>(); + CompletableFuture later = new CompletableFuture<>(); + OresFuture race = OresFutures.race(List.of(cancelled, later)); + + assertTrue(cancelled.cancel(true)); + + assertTrue(race.isCancelled()); + assertThrows(CancellationException.class, race::join); + later.complete(9); + assertTrue(race.isCancelled()); + } + + @Test + void allPropagatesFailure() { + CompletableFuture ok = new CompletableFuture<>(); + CompletableFuture bad = new CompletableFuture<>(); + + OresFuture> all = OresFutures.all(List.of(ok, bad)); + ok.complete(1); + bad.completeExceptionally(new IllegalStateException("boom")); + + CompletionException failure = assertThrows(CompletionException.class, all::join); + assertInstanceOf(IllegalStateException.class, failure.getCause()); + } + + @Test + void allFailureDetachesOtherChildWaitersWithoutCancellingSharedChildren() { + OresFuture pending = new OresFuture<>(); + OresFuture failed = new OresFuture<>(); + + OresFuture> all = OresFutures.all(List.of(pending, failed)); + assertEquals(1, pending.pendingRuntimeWaiterCount()); + assertEquals(1, failed.pendingRuntimeWaiterCount()); + + failed.failFromRuntime(new IllegalStateException("boom")); + + CompletionException failure = + assertThrows(CompletionException.class, all::join); + assertInstanceOf(IllegalStateException.class, failure.getCause()); + assertEquals(0, pending.pendingRuntimeWaiterCount(), + "terminal all() must detach from losing pending children"); + assertEquals(0, failed.pendingRuntimeWaiterCount()); + assertFalse(pending.isCancelled(), + "child failure must not cancel unrelated shared children"); + } + + @Test + void allChildCancellationCancelsSiblingsAndDetachesAllWaiters() { + OresFuture pending = new OresFuture<>(); + OresFuture cancelled = new OresFuture<>(); + + OresFuture> all = + OresFutures.all(List.of(pending, cancelled)); + assertTrue(cancelled.cancel(false)); + + assertTrue(all.isCancelled()); + assertThrows(CancellationException.class, all::join); + assertEquals(0, pending.pendingRuntimeWaiterCount()); + assertEquals(0, cancelled.pendingRuntimeWaiterCount()); + assertTrue(pending.isCancelled(), + "all() owns its child set: aggregate cancellation cancels remaining siblings"); + } + + @Test + void raceDetachesLosingWaitersWithoutCancellingSharedChildren() { + OresFuture slow = new OresFuture<>(); + OresFuture fast = new OresFuture<>(); + + OresFuture race = OresFutures.race(List.of(slow, fast)); + assertEquals(1, slow.pendingRuntimeWaiterCount()); + assertEquals(1, fast.pendingRuntimeWaiterCount()); + + fast.completeFromRuntime(7); + + assertEquals(7, race.join()); + assertEquals(0, slow.pendingRuntimeWaiterCount(), + "race winner must detach callbacks from losing children"); + assertEquals(0, fast.pendingRuntimeWaiterCount()); + assertFalse(slow.isCancelled(), + "race completion must not cancel a shared losing child"); + } + + @Test + void cancellingAggregateCancelsChildren() { + CompletableFuture first = new CompletableFuture<>(); + CompletableFuture second = new CompletableFuture<>(); + + OresFuture> all = OresFutures.all(List.of(first, second)); + assertTrue(all.cancel(true)); + + assertTrue(first.isCancelled()); + assertTrue(second.isCancelled()); + } + + @Test + void guestCannotForgeFutureCompletion() { + OresFuture future = new OresFuture<>(); + + assertThrows(UnsupportedOperationException.class, () -> future.complete(99)); + assertThrows( + UnsupportedOperationException.class, + () -> future.completeExceptionally(new IllegalStateException("forged"))); + assertThrows( + UnsupportedOperationException.class, + () -> future.completeOnTimeout(99, 1, java.util.concurrent.TimeUnit.MILLISECONDS)); + assertThrows( + UnsupportedOperationException.class, + () -> future.orTimeout(1, java.util.concurrent.TimeUnit.MILLISECONDS)); + assertFalse(future.isDone()); + } + + @Test + void oresFutureDoesNotExposeCompletionStageCallbackSurface() { + assertFalse( + java.util.concurrent.CompletionStage.class.isAssignableFrom( + OresFuture.class)); + assertFalse( + java.util.Arrays.stream(OresFuture.class.getMethods()) + .anyMatch(method -> method.getName().equals("thenApply") + || method.getName().equals("thenAccept") + || method.getName().equals("thenRun")), + "guest-visible OresFuture must not inherit producer-thread callback APIs"); + } + + @Test + void runtimeWaiterIsDeliveredExactlyOnceWhenRegistrationRacesCompletion() + throws Exception { + for (int attempt = 0; attempt < 200; attempt++) { + OresFuture future = new OresFuture<>(); + java.util.concurrent.CountDownLatch start = + new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.atomic.AtomicInteger callbacks = + new java.util.concurrent.atomic.AtomicInteger(); + + Thread registrar = Thread.ofVirtual().start(() -> { + try { + start.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + future.whenCompleteRuntime((value, failure) -> { + assertNull(failure); + assertEquals(7, value); + callbacks.incrementAndGet(); + }); + }); + Thread completer = Thread.ofVirtual().start(() -> { + try { + start.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + future.completeFromRuntime(7); + }); + + start.countDown(); + registrar.join(); + completer.join(); + + assertEquals(7, future.get(1, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals(1, callbacks.get(), + "completion/registration race must not duplicate a waiter"); + } + } + + @Test + void terminalFutureDoesNotRetainLateRuntimeWaiters() { + OresFuture future = OresFuture.completed(7); + java.util.concurrent.atomic.AtomicInteger callbacks = + new java.util.concurrent.atomic.AtomicInteger(); + + for (int i = 0; i < 1_000; i++) { + future.whenCompleteRuntime((value, failure) -> { + assertNull(failure); + assertEquals(7, value); + callbacks.incrementAndGet(); + }); + } + + assertEquals(1_000, callbacks.get()); + assertEquals( + 0, + future.pendingRuntimeWaiterCount(), + "already-terminal Futures must not retain delivered runtime waiters"); + } + + @Test + void raceCompletesWithFirstCompletion() { + CompletableFuture slow = new CompletableFuture<>(); + CompletableFuture fast = new CompletableFuture<>(); + + OresFuture race = OresFutures.race(List.of(slow, fast)); + fast.complete(7); + + assertEquals(7, race.join()); + } +} diff --git a/src/test/java/dev/oreslang/runtime/OresSchedulerTest.java b/src/test/java/dev/oreslang/runtime/OresSchedulerTest.java new file mode 100644 index 00000000..bb137940 --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/OresSchedulerTest.java @@ -0,0 +1,340 @@ +package dev.oreslang.runtime; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +final class OresSchedulerTest { + + @Test + void awaitResumesOnlyOnOwningSchedulerNotProducerThread() throws Exception { + try (OresScheduler scheduler = new OresScheduler(2)) { + OresFuture source = new OresFuture<>(); + AtomicReference producer = new AtomicReference<>(); + AtomicInteger state = new AtomicInteger(); + + OresFuture result = scheduler.start(resume -> { + int pc = state.getAndIncrement(); + assertSame(scheduler, OresScheduler.current()); + + if (pc == 0) { + assertTrue(resume.initial()); + return OresScheduler.await(source); + } + + assertFalse(resume.initial()); + assertNull(resume.failure()); + assertEquals(41, resume.value()); + assertNotSame(producer.get(), Thread.currentThread(), + "producer/completion thread must never execute guest continuation"); + return OresScheduler.done(42); + }); + + Thread completionThread = Thread.ofPlatform().start(() -> { + producer.set(Thread.currentThread()); + source.completeFromRuntime(41); + }); + completionThread.join(); + + assertEquals(42, result.get(5, TimeUnit.SECONDS)); + assertEquals(2, state.get()); + } + } + + @Test + void alreadyCompletedFutureStillCreatesLaterSchedulerTurn() throws Exception { + try (OresScheduler scheduler = new OresScheduler(2)) { + OresFuture completed = OresFuture.completed(7); + AtomicInteger state = new AtomicInteger(); + AtomicBoolean insideFirstResume = new AtomicBoolean(); + + OresFuture result = scheduler.start(resume -> { + int pc = state.getAndIncrement(); + if (pc == 0) { + insideFirstResume.set(true); + try { + return OresScheduler.await(completed); + } finally { + insideFirstResume.set(false); + } + } + + assertFalse(insideFirstResume.get(), + "await continuation must not resume inline in the suspending turn"); + assertEquals(7, resume.value()); + return OresScheduler.done(8); + }); + + assertEquals(8, result.get(5, TimeUnit.SECONDS)); + assertEquals(2, state.get()); + assertEquals( + 0, + completed.pendingRuntimeWaiterCount(), + "awaiting an already-settled Future must not retain a claimed continuation waiter"); + } + } + + @Test + void completedAwaitMayReuseSameCarrierButAlwaysGetsFreshDispatch() throws Exception { + try (OresScheduler scheduler = new OresScheduler(1)) { + OresFuture completed = OresFuture.completed(7); + AtomicInteger pc = new AtomicInteger(); + AtomicReference firstCarrier = new AtomicReference<>(); + AtomicReference firstDispatch = new AtomicReference<>(); + + OresFuture result = scheduler.start(resume -> { + if (pc.getAndIncrement() == 0) { + firstCarrier.set(Thread.currentThread()); + firstDispatch.set(OresScheduler.currentDispatchId()); + assertNotEquals(0L, firstDispatch.get().longValue()); + return OresScheduler.await(completed); + } + + // A one-thread pool guarantees physical carrier reuse. The + // logical scheduler dispatch must nevertheless be new. + assertSame(firstCarrier.get(), Thread.currentThread()); + assertNotEquals( + firstDispatch.get().longValue(), + OresScheduler.currentDispatchId(), + "await must unwind and re-enter through a fresh scheduler dispatch"); + assertEquals(7, resume.value()); + return OresScheduler.done(8); + }); + + assertEquals(8, result.get(5, TimeUnit.SECONDS)); + assertEquals(2, pc.get()); + } + } + + @Test + void manyCompletedAwaitsStayStacklessAndRequireFreshDispatches() throws Exception { + final int awaits = 512; + try (OresScheduler scheduler = new OresScheduler(1)) { + AtomicInteger state = new AtomicInteger(); + AtomicLong previousDispatch = new AtomicLong(); + + OresFuture result = scheduler.start(resume -> { + long dispatch = OresScheduler.currentDispatchId(); + assertNotEquals(0L, dispatch); + long prior = previousDispatch.getAndSet(dispatch); + if (prior != 0L) { + assertNotEquals( + prior, + dispatch, + "every await must re-enter through a fresh scheduler dispatch"); + } + + int step = state.getAndIncrement(); + if (step < awaits) { + OresFuture completed = OresFuture.completed(step); + assertEquals(0, completed.pendingRuntimeWaiterCount()); + return OresScheduler.await(completed); + } + + assertEquals(awaits - 1, resume.value()); + return OresScheduler.done(step); + }); + + assertEquals(awaits, result.get(10, TimeUnit.SECONDS)); + assertEquals(awaits + 1, state.get()); + } + } + + @Test + void runtimeOwnedCompletionPublishesOnlyAfterGuestTurnAdmissionExits() throws Exception { + ExecutorService carrier = Executors.newSingleThreadExecutor(); + AtomicBoolean insideGuestTurn = new AtomicBoolean(); + CountDownLatch completionObserved = new CountDownLatch(1); + AtomicBoolean completionPublishedInsideGuestTurn = new AtomicBoolean(); + + try (OresScheduler scheduler = OresScheduler.runtimeOwned( + "test-runtime-owned", + 1, + carrier, + turn -> { + assertFalse( + insideGuestTurn.get(), + "runtime scheduler guest turns must not nest context admission"); + insideGuestTurn.set(true); + try { + turn.run(); + } finally { + insideGuestTurn.set(false); + } + })) { + OresFuture result = + scheduler.start(resume -> OresScheduler.done(42)); + + result.whenCompleteRuntime((value, failure) -> { + completionPublishedInsideGuestTurn.set(insideGuestTurn.get()); + completionObserved.countDown(); + }); + + assertEquals(42, result.get(5, TimeUnit.SECONDS)); + assertTrue(completionObserved.await(5, TimeUnit.SECONDS)); + assertFalse( + completionPublishedInsideGuestTurn.get(), + "terminal Future publication must happen only after guest/context exit"); + } finally { + carrier.shutdownNow(); + assertTrue(carrier.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void oneFutureMayResumeWaitersOnDifferentSchedulers() throws Exception { + try (OresScheduler left = new OresScheduler(1); + OresScheduler right = new OresScheduler(1)) { + + OresFuture shared = new OresFuture<>(); + AtomicInteger leftPc = new AtomicInteger(); + AtomicInteger rightPc = new AtomicInteger(); + + OresFuture leftResult = left.start(resume -> { + if (leftPc.getAndIncrement() == 0) { + assertSame(left, OresScheduler.current()); + return OresScheduler.await(shared); + } + assertSame(left, OresScheduler.current()); + return OresScheduler.done("left:" + resume.value()); + }); + + OresFuture rightResult = right.start(resume -> { + if (rightPc.getAndIncrement() == 0) { + assertSame(right, OresScheduler.current()); + return OresScheduler.await(shared); + } + assertSame(right, OresScheduler.current()); + return OresScheduler.done("right:" + resume.value()); + }); + + shared.completeFromRuntime(9); + + assertEquals("left:9", leftResult.get(5, TimeUnit.SECONDS)); + assertEquals("right:9", rightResult.get(5, TimeUnit.SECONDS)); + } + } + + @Test + void awaitFailureIsDeliveredBackToOwningTask() throws Exception { + try (OresScheduler scheduler = new OresScheduler(1)) { + OresFuture source = + OresFuture.failed(new IllegalStateException("boom")); + AtomicInteger pc = new AtomicInteger(); + + OresFuture result = scheduler.start(resume -> { + if (pc.getAndIncrement() == 0) { + return OresScheduler.await(source); + } + assertInstanceOf(IllegalStateException.class, resume.failure()); + assertEquals("boom", resume.failure().getMessage()); + return OresScheduler.done("handled"); + }); + + assertEquals("handled", result.get(5, TimeUnit.SECONDS)); + } + } + + @Test + void synchronousTaskIsStillSchedulerBound() throws Exception { + try (OresScheduler scheduler = new OresScheduler(1)) { + OresFuture result = + scheduler.startSync(() -> OresScheduler.current() == scheduler); + assertTrue(result.get(5, TimeUnit.SECONDS)); + } + } + @Test + void logicalTaskDomainSurvivesAwaitButIsDistinctPerTask() throws Exception { + try (OresScheduler scheduler = new OresScheduler(2)) { + OresFuture gate = new OresFuture<>(); + AtomicReference firstDomain = new AtomicReference<>(); + AtomicReference resumedDomain = new AtomicReference<>(); + AtomicReference secondTaskDomain = new AtomicReference<>(); + AtomicInteger firstPc = new AtomicInteger(); + + OresFuture first = scheduler.start(resume -> { + if (firstPc.getAndIncrement() == 0) { + firstDomain.set(OresScheduler.currentTaskDomain()); + assertNotNull(firstDomain.get()); + return OresScheduler.await(gate); + } + resumedDomain.set(OresScheduler.currentTaskDomain()); + return OresScheduler.done(1); + }); + + OresFuture second = scheduler.start(resume -> { + secondTaskDomain.set(OresScheduler.currentTaskDomain()); + return OresScheduler.done(2); + }); + + assertEquals(2, second.get(5, TimeUnit.SECONDS)); + gate.completeFromRuntime(0); + assertEquals(1, first.get(5, TimeUnit.SECONDS)); + + assertSame(firstDomain.get(), resumedDomain.get(), + "await/resume must preserve the logical task execution domain"); + assertNotSame(firstDomain.get(), secondTaskDomain.get(), + "two tasks on one scheduler must not share mutex/borrow ownership"); + } + } + + @Test + void schedulerCannotCloseItselfFromOwnTaskTurn() throws Exception { + OresScheduler scheduler = new OresScheduler(1); + try { + OresFuture result = scheduler.startSync(() -> { + IllegalStateException failure = + assertThrows(IllegalStateException.class, scheduler::close); + return failure.getMessage().contains("outside/root"); + }); + + assertTrue(result.get(5, TimeUnit.SECONDS)); + assertFalse(scheduler.isClosed(), + "failed self-close must leave scheduler usable"); + } finally { + scheduler.close(); + } + } + + @Test + void closingSchedulerDetachesSuspendedWaiterWithoutCancellingSharedFuture() throws Exception { + OresFuture shared = new OresFuture<>(); + OresScheduler scheduler = new OresScheduler(1); + AtomicInteger pc = new AtomicInteger(); + + OresFuture task = scheduler.start(resume -> { + if (pc.getAndIncrement() == 0) { + return OresScheduler.await(shared); + } + return OresScheduler.done((Integer) resume.value()); + }); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (shared.pendingRuntimeWaiterCount() != 1 + && System.nanoTime() < deadline) { + Thread.sleep(1); + } + assertEquals(1, shared.pendingRuntimeWaiterCount()); + + scheduler.close(); + + assertTrue(task.isCancelled()); + assertEquals(0, shared.pendingRuntimeWaiterCount(), + "closing the scheduler must detach its continuation waiter"); + assertFalse(shared.isDone(), + "detaching a waiter must not cancel the shared producer Future"); + + assertTrue(shared.completeFromRuntime(9)); + assertEquals(1, pc.get(), + "detached continuation must never resume after producer completion"); + } +} diff --git a/src/test/java/dev/oreslang/runtime/RuntimeGarbageCollectorTest.java b/src/test/java/dev/oreslang/runtime/RuntimeGarbageCollectorTest.java new file mode 100644 index 00000000..5ab58e5e --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/RuntimeGarbageCollectorTest.java @@ -0,0 +1,188 @@ +package dev.oreslang.runtime; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +final class RuntimeGarbageCollectorTest { + @Test + void processCollectionRequestsJvmGcAtMostOncePerThrottleWindow() { + AtomicInteger gcRequests = new AtomicInteger(); + Object owner = new Object(); + try (RuntimeGarbageCollector gc = new RuntimeGarbageCollector( + gcRequests::incrementAndGet, Duration.ofHours(1), Duration.ofHours(1), 16)) { + gc.track(owner, () -> fail("live owner must not be cleaned")); + var first = gc.collectProcess(); + var second = gc.collectProcess(); + assertTrue(first.jvmGcRequested()); + assertFalse(second.jvmGcRequested()); + assertEquals(1, gcRequests.get()); + assertEquals(1, first.trackedAfter()); + assertNotNull(owner); + } + } + + @Test + void cleanupHandleIsDeterministicAndIdempotent() { + AtomicInteger cleanups = new AtomicInteger(); + try (RuntimeGarbageCollector gc = new RuntimeGarbageCollector(() -> {}, Duration.ofHours(1))) { + var handle = gc.track(new Object(), cleanups::incrementAndGet); + handle.close(); + handle.close(); + assertEquals(1, cleanups.get()); + } + } + + @Test + void failedCleanupRemainsRetryable() { + AtomicInteger attempts = new AtomicInteger(); + try (RuntimeGarbageCollector gc = new RuntimeGarbageCollector(() -> {}, Duration.ofHours(1))) { + var handle = gc.track(new Object(), () -> { + if (attempts.incrementAndGet() == 1) throw new IllegalStateException("transient"); + }); + assertThrows(IllegalStateException.class, handle::close); + assertDoesNotThrow(handle::close); + assertEquals(2, attempts.get()); + } + } + + @Test + void registryGrowthIsBounded() { + try (RuntimeGarbageCollector gc = new RuntimeGarbageCollector( + () -> {}, Duration.ofHours(1), Duration.ofSeconds(1), 1)) { + Object owner = new Object(); + gc.track(owner, () -> {}); + assertThrows(IllegalStateException.class, () -> gc.track(new Object(), () -> {})); + assertNotNull(owner); + } + } + + @Test + void actorCollectionIsDomainLocalAndNeverRequestsJvmGc() throws Exception { + AtomicInteger gcRequests = new AtomicInteger(); + AtomicReference report = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + try (RuntimeGarbageCollector gc = new RuntimeGarbageCollector(gcRequests::incrementAndGet, Duration.ofHours(1)); + ActorRuntime runtime = new ActorRuntime()) { + var ref = runtime.spawn(() -> (message, context) -> { + report.set(gc.collectCurrentActor()); + done.countDown(); + }); + ref.send("gc"); + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertEquals("actor", report.get().scope()); + assertFalse(report.get().jvmGcRequested()); + assertEquals(0, gcRequests.get()); + } + } + + + @Test + void actorCollectionReportsOnlyItsOwnIndexedDomain() throws Exception { + AtomicReference firstReport = new AtomicReference<>(); + AtomicReference secondReport = new AtomicReference<>(); + CountDownLatch registered = new CountDownLatch(2); + CountDownLatch done = new CountDownLatch(2); + + try (RuntimeGarbageCollector gc = new RuntimeGarbageCollector(() -> {}, Duration.ofHours(1)); + ActorRuntime runtime = new ActorRuntime()) { + var first = runtime.spawn(() -> (message, context) -> { + Object owner = new Object(); + gc.track(owner, () -> {}); + registered.countDown(); + assertTrue(registered.await(2, TimeUnit.SECONDS)); + firstReport.set(gc.collectCurrentActor()); + assertNotNull(owner); + done.countDown(); + }); + var second = runtime.spawn(() -> (message, context) -> { + Object owner = new Object(); + gc.track(owner, () -> {}); + registered.countDown(); + assertTrue(registered.await(2, TimeUnit.SECONDS)); + secondReport.set(gc.collectCurrentActor()); + assertNotNull(owner); + done.countDown(); + }); + + first.send("gc"); + second.send("gc"); + + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertEquals(1, firstReport.get().trackedBefore()); + assertEquals(1, secondReport.get().trackedBefore()); + assertEquals(1, firstReport.get().inspected()); + assertEquals(1, secondReport.get().inspected()); + } + } + + @Test + void actorCollectionHasABoundedInspectionQuantum() throws Exception { + AtomicReference report = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + + try (RuntimeGarbageCollector gc = new RuntimeGarbageCollector(() -> {}, Duration.ofHours(1)); + ActorRuntime runtime = new ActorRuntime()) { + var ref = runtime.spawn(() -> (message, context) -> { + ArrayList owners = new ArrayList<>(); + for (int i = 0; i < 300; i++) { + Object owner = new Object(); + owners.add(owner); + gc.track(owner, () -> {}); + } + report.set(gc.collectCurrentActor()); + assertEquals(300, owners.size()); + done.countDown(); + }); + + ref.send("gc"); + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertEquals(300, report.get().trackedBefore()); + assertEquals(256, report.get().inspected()); + assertEquals(300, report.get().trackedAfter()); + assertFalse(report.get().jvmGcRequested()); + } + } + + + @Test + void actorExitDeterministicallyRetiresItsCleanupDomain() throws Exception { + AtomicInteger cleanups = new AtomicInteger(); + AtomicReference leakedOwner = new AtomicReference<>(); + CountDownLatch registered = new CountDownLatch(1); + + try (RuntimeGarbageCollector gc = new RuntimeGarbageCollector(() -> {}, Duration.ofHours(1)); + ActorRuntime runtime = new ActorRuntime()) { + runtime.setActorExitHook(gc::retireActorDomain); + var ref = runtime.spawn(() -> (message, context) -> { + Object owner = new Object(); + leakedOwner.set(owner); // deliberately keep a stale host reference alive + gc.track(owner, cleanups::incrementAndGet); + registered.countDown(); + context.self().stop(); + }); + + ref.send("stop"); + assertTrue(registered.await(2, TimeUnit.SECONDS)); + assertTrue(ref.awaitTermination(2, TimeUnit.SECONDS)); + assertNotNull(leakedOwner.get()); + assertEquals(1, cleanups.get(), + "actor termination must retire actor-local runtime resources even with a stale owner reference"); + } + } + + @Test + void actorCollectionOutsideActorIsRejected() { + try (RuntimeGarbageCollector gc = new RuntimeGarbageCollector(() -> {}, Duration.ofHours(1))) { + var error = assertThrows(IllegalStateException.class, gc::collectCurrentActor); + assertTrue(error.getMessage().contains("actor.gc() requires execution inside an actor")); + } + } +} diff --git a/src/test/java/dev/oreslang/runtime/SharedMutexPublicationTest.java b/src/test/java/dev/oreslang/runtime/SharedMutexPublicationTest.java new file mode 100644 index 00000000..e5f29046 --- /dev/null +++ b/src/test/java/dev/oreslang/runtime/SharedMutexPublicationTest.java @@ -0,0 +1,57 @@ +package dev.oreslang.runtime; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +final class SharedMutexPublicationTest { + @Test + void abortedFirstPublicationDoesNotPermanentlyClaimRuntime() { + try (ActorRuntime runtimeA = new ActorRuntime(); + ActorRuntime runtimeB = new ActorRuntime()) { + var shared = OresMutex.shared(new int[]{0}); + + assertTrue(shared.reserveRuntimePublication(runtimeA)); + shared.abortRuntimePublication(runtimeA); + + assertTrue( + shared.bindToRuntime(runtimeB), + "a failed mailbox admission must leave an unpublished SharedMutex available to another runtime"); + assertFalse(shared.bindToRuntime(runtimeA)); + } + } + + @Test + void committedPublicationSurvivesAnotherAbortedReservation() { + try (ActorRuntime runtimeA = new ActorRuntime(); + ActorRuntime runtimeB = new ActorRuntime()) { + var shared = OresMutex.shared(new int[]{0}); + + assertTrue(shared.reserveRuntimePublication(runtimeA)); + assertTrue(shared.reserveRuntimePublication(runtimeA)); + + shared.commitRuntimePublication(runtimeA); + shared.abortRuntimePublication(runtimeA); + + assertTrue(shared.bindToRuntime(runtimeA)); + assertFalse( + shared.bindToRuntime(runtimeB), + "once any admitted message publishes a SharedMutex, later failed sends must not unbind it"); + } + } + + @Test + void foreignRuntimeCannotReserveWhilePublicationIsPending() { + try (ActorRuntime runtimeA = new ActorRuntime(); + ActorRuntime runtimeB = new ActorRuntime()) { + var shared = OresMutex.shared(new int[]{0}); + + assertTrue(shared.reserveRuntimePublication(runtimeA)); + assertFalse(shared.reserveRuntimePublication(runtimeB)); + + shared.abortRuntimePublication(runtimeA); + assertTrue(shared.reserveRuntimePublication(runtimeB)); + shared.abortRuntimePublication(runtimeB); + } + } +} diff --git a/vendor/litegraph-gpu-host/.github/workflows/ci.yml b/vendor/litegraph-gpu-host/.github/workflows/ci.yml deleted file mode 100644 index 93b16988..00000000 --- a/vendor/litegraph-gpu-host/.github/workflows/ci.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: ci - -on: - pull_request: - push: - branches: [main] - -permissions: - contents: read - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - rust: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 - with: - persist-credentials: false - - run: rustc --version && cargo --version - - run: cargo fmt --all -- --check - - run: cargo clippy --all-targets --all-features -- -D warnings - - run: cargo test --all-features diff --git a/vendor/litegraph-gpu-host/.gitignore b/vendor/litegraph-gpu-host/.gitignore deleted file mode 100644 index 3e2da82c..00000000 --- a/vendor/litegraph-gpu-host/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -/target -Cargo.lock - -# Fleet-local scratch/worktrees and agent-policy link -.ores/ -tmp/ -temp/ diff --git a/vendor/litegraph-gpu-host/.ores-otel.toml b/vendor/litegraph-gpu-host/.ores-otel.toml deleted file mode 100644 index bcb64fe3..00000000 --- a/vendor/litegraph-gpu-host/.ores-otel.toml +++ /dev/null @@ -1,2 +0,0 @@ -service_name = "litegraph-gpu-host" -json_stdio = true diff --git a/vendor/litegraph-gpu-host/AGENTS.md b/vendor/litegraph-gpu-host/AGENTS.md deleted file mode 100644 index e4940cf9..00000000 --- a/vendor/litegraph-gpu-host/AGENTS.md +++ /dev/null @@ -1,22 +0,0 @@ -# Repository agent instructions - -Follow the canonical ORESoftware fleet policy before changing this repository. - - - -## Canonical agent instructions - -Read `.ores/agents/AGENTS.md`. It is a machine-local symlink to -`~/codes/oresoftware/my-ai/AGENTS.md`; canonical source: -. - -The symlink is intentionally untracked. Use the my-ai `scripts/link-repo-agents.sh` -installer/checker when local setup is missing. Do not copy the canonical policy into this -repository and do not treat a missing symlink as permission to skip it. - - - -## Repository-specific rule - -The README's "owns / does not own" boundary is normative for local changes. Establish new -fleet-wide concepts in the canonical LiteGraph interface/contract authority first. diff --git a/vendor/litegraph-gpu-host/Cargo.toml b/vendor/litegraph-gpu-host/Cargo.toml deleted file mode 100644 index e56cc0da..00000000 --- a/vendor/litegraph-gpu-host/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "litegraph-gpu-host" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" - -[features] -default = [] -cuda = [] -rocm = [] -metal = [] -vulkan = [] - -[dependencies] -async-trait = "0.1" -serde = { version = "1", features = ["derive"] } -thiserror = "2" -tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } diff --git a/vendor/litegraph-gpu-host/README.md b/vendor/litegraph-gpu-host/README.md deleted file mode 100644 index 88899ba0..00000000 --- a/vendor/litegraph-gpu-host/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# litegraph-gpu-host - -Trusted lowest-level accelerator host and device/lane authority. - -LiteGraph deliberately separates host actors from accelerator execution. Actor mailboxes, supervision, networking and ordinary OS capabilities run on CPUs; kernels/models can execute on GPUs through trusted capabilities. One box may therefore have one or more CPU sockets and many GPUs, and both processor types can be active concurrently. - -## This repository owns - -- device enumeration and health. -- lane/stream/context/partition ownership. -- VRAM and scratch-memory accounting. -- CUDA/ROCm/Metal/Vulkan backends behind AcceleratorBackend. - -## This repository does not own - -- tenant-facing raw device pointers. -- cluster placement. -- control-plane CRUD. - -## Runtime relationship - -```text -runtime/modeld → opaque accelerator request → lane actor → backend/device → completion -``` - -This boundary should remain true even as CUDA, ROCm, Metal, Vulkan/WebGPU or CPU implementations evolve. - -## Heterogeneous execution model - -Resource requests describe CPU/RAM independently from accelerator count/VRAM/features. `cpu` requires host execution, `gpu` requires a compatible accelerator path, and `auto` permits a validated fallback/selection policy. Logical function/model identity remains stable across target variants. - -Portable compute is intentionally constrained. Filesystem access, sockets, process creation and other host syscalls are host capabilities; they are not silently translated into GPU kernels. - -## Safety and multi-tenancy - -- Validate tenant-controlled sizes and identifiers before allocation. -- Keep native driver handles and pointers behind trusted process/capability boundaries. -- Bound queues and propagate backpressure. -- Release reservations on cancellation, timeout, process death and device reset. -- Keep immutable artifacts content-addressed and verify digests before use. -- Never include credentials in examples, manifests, logs or artifact metadata. - -Where isolation is configurable, use the shared `shared`, `sandbox`, `partitioned`, and `dedicated` vocabulary. - -## Shared contracts - -Do not fork platform types locally. `litegraph-interfaces` owns canonical semantics and `litegraph-contracts` owns cross-language wire schemas. TypeSpec and JSON Schema Draft 2020-12 are peer authored authorities; parity failures stop promotion. Generated outputs are read-only evidence. - -## Tooling and configuration - -Fleet engineering policy lives in `ORESoftware/my-ai` `AGENTS.md` and `SHARED.md`. Durable scripts, code generators, validators, audits and CI gates are Rust-first; Python is not used for those responsibilities. - -Any executable CLI surface uses root `.cli-flags.toml` plus the canonical `flags-2-env` argv boundary. Credentials are secret-store/environment inputs, not command-line flags. - -## Testing - -Pure policy and accounting logic should be deterministic and hardware-independent. Use fake backends where the test is about lifecycle/placement rather than hardware. Claims about CUDA/ROCm/Metal/Vulkan behavior require real compatible runners. Cross-service behavior belongs in `litegraph-test`; performance claims belong in `litegraph-benchmarks`. - -Always distinguish source/test failure from CI admission failure: a job with zero executed steps is not a passing test. - -## Related components - -- `litegraph-api-server.rs`: tenant control plane. -- `litegraph-router.rs`: invocation hot path. -- `litegraph-scheduler`: placement authority. -- `litegraph-node`: per-machine capacity/health. -- `litegraph-runtime`: InvocationActor lifecycle. -- `litegraph-gpu-host`: accelerator device/lane execution. -- `litegraph-modeld`: resident ModelActors. -- `litegraph-compiler`: deterministic target variants. -- function/model registries: immutable artifact authorities. -- desktop repositories: local supervision and UX. - -## Contribution rule - -Preserve these ownership boundaries. If a change introduces a new cross-repository concept, establish it in the canonical interfaces/contracts first and add integration tests rather than letting two services develop competing definitions. diff --git a/vendor/litegraph-gpu-host/rust-toolchain.toml b/vendor/litegraph-gpu-host/rust-toolchain.toml deleted file mode 100644 index e6300496..00000000 --- a/vendor/litegraph-gpu-host/rust-toolchain.toml +++ /dev/null @@ -1,4 +0,0 @@ -[toolchain] -channel = "1.98.1" -profile = "minimal" -components = ["clippy", "rustfmt"] diff --git a/vendor/litegraph-gpu-host/src/lib.rs b/vendor/litegraph-gpu-host/src/lib.rs deleted file mode 100644 index 6a64310d..00000000 --- a/vendor/litegraph-gpu-host/src/lib.rs +++ /dev/null @@ -1,613 +0,0 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::{ - collections::{HashMap, HashSet}, - sync::{ - atomic::{compiler_fence, AtomicU64, Ordering}, - Arc, - }, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; -use tokio::{ - sync::{oneshot, OwnedSemaphorePermit, Semaphore}, - time, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BackendKind { - Cuda, - Rocm, - Metal, - Vulkan, - Mock, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DeviceDescriptor { - pub device_id: String, - pub backend: BackendKind, - pub total_vram_bytes: u64, - pub lane_count: u32, - pub supports_partitioning: bool, - pub healthy: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct GpuCommand { - pub executable: String, - pub inputs: Vec>, - pub output_capacity: usize, - pub deadline_unix_ms: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExecutionReceipt { - pub device_id: String, - pub lane_id: u64, - pub output: Vec, - pub scratch_bytes: u64, -} - -#[derive(Debug, Clone)] -pub struct HostLimits { - pub max_inputs: usize, - pub max_input_bytes: usize, - pub max_output_bytes: usize, - pub max_scratch_bytes: u64, - pub max_executable_len: usize, -} - -impl Default for HostLimits { - fn default() -> Self { - Self { - max_inputs: 64, - max_input_bytes: 64 * 1024 * 1024, - max_output_bytes: 64 * 1024 * 1024, - max_scratch_bytes: 64 * 1024 * 1024 * 1024, - max_executable_len: 256, - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum HostError { - #[error("invalid host configuration: {0}")] - InvalidConfiguration(String), - #[error("invalid GPU command: {0}")] - InvalidCommand(String), - #[error("device not found: {0}")] - DeviceNotFound(String), - #[error("device unhealthy: {0}")] - DeviceUnhealthy(String), - #[error("vram admission failed: requested={requested} free={free}")] - VramAdmission { requested: u64, free: u64 }, - #[error("output exceeded declared capacity: capacity={capacity} actual={actual}")] - OutputTooLarge { capacity: usize, actual: usize }, - #[error("execution deadline exceeded")] - DeadlineExceeded, - #[error("backend execution failed: {0}")] - Backend(String), - #[error("lane pool closed")] - LanePoolClosed, -} - -/// Native backends must preserve resource safety if this future is dropped because a -/// caller deadline expires. In-flight device work must retain its buffers/context until -/// the backend observes completion; cancellation must never recycle memory early. -#[async_trait] -pub trait AcceleratorBackend: Send + Sync + 'static { - fn devices(&self) -> Vec; - async fn execute( - &self, - device_id: &str, - lane_id: u64, - command: &GpuCommand, - ) -> Result, HostError>; -} - -struct DeviceState { - descriptor: DeviceDescriptor, - lanes: Arc, - next_lane: AtomicU64, - reserved_vram: AtomicU64, -} - -impl DeviceState { - fn free_vram(&self) -> u64 { - self.descriptor - .total_vram_bytes - .saturating_sub(self.reserved_vram.load(Ordering::Acquire)) - } - - fn reserve(self: &Arc, bytes: u64) -> Result { - loop { - let current = self.reserved_vram.load(Ordering::Acquire); - let free = self.descriptor.total_vram_bytes.saturating_sub(current); - if bytes > free { - return Err(HostError::VramAdmission { - requested: bytes, - free, - }); - } - let Some(next) = current.checked_add(bytes) else { - return Err(HostError::VramAdmission { - requested: bytes, - free, - }); - }; - if self - .reserved_vram - .compare_exchange(current, next, Ordering::AcqRel, Ordering::Acquire) - .is_ok() - { - return Ok(VramLease { - state: self.clone(), - bytes, - }); - } - } - } -} - -struct VramLease { - state: Arc, - bytes: u64, -} - -impl Drop for VramLease { - fn drop(&mut self) { - self.state - .reserved_vram - .fetch_sub(self.bytes, Ordering::AcqRel); - } -} - -pub struct ScratchBuffer { - bytes: Vec, - secure_clear: bool, -} - -impl ScratchBuffer { - pub fn new(size: usize, secure_clear: bool) -> Self { - Self { - bytes: vec![0; size], - secure_clear, - } - } - - pub fn as_mut_slice(&mut self) -> &mut [u8] { - &mut self.bytes - } -} - -impl Drop for ScratchBuffer { - fn drop(&mut self) { - if self.secure_clear { - // Volatile writes plus a compiler fence prevent the clear from being optimized - // away. Native GPU backends must provide the equivalent guarantee for VRAM. - for byte in &mut self.bytes { - // SAFETY: `byte` is a valid uniquely borrowed u8 for the duration of the write. - unsafe { std::ptr::write_volatile(byte, 0) }; - } - compiler_fence(Ordering::SeqCst); - } - } -} - -pub struct GpuHost { - backend: Arc, - devices: HashMap>, - limits: HostLimits, -} - -impl GpuHost { - pub fn new(backend: B) -> Result { - Self::with_limits(backend, HostLimits::default()) - } - - pub fn with_limits(backend: B, limits: HostLimits) -> Result { - if limits.max_inputs == 0 - || limits.max_input_bytes == 0 - || limits.max_output_bytes == 0 - || limits.max_scratch_bytes == 0 - || limits.max_executable_len == 0 - { - return Err(HostError::InvalidConfiguration( - "host limits must all be non-zero".into(), - )); - } - - let backend = Arc::new(backend); - let descriptors = backend.devices(); - if descriptors.is_empty() { - return Err(HostError::InvalidConfiguration( - "backend reported no devices".into(), - )); - } - - let mut seen = HashSet::new(); - let mut devices = HashMap::new(); - for descriptor in descriptors { - if descriptor.device_id.is_empty() - || descriptor.device_id.len() > 256 - || descriptor.total_vram_bytes == 0 - || descriptor.lane_count == 0 - { - return Err(HostError::InvalidConfiguration(format!( - "invalid device descriptor for {:?}", - descriptor.device_id - ))); - } - if !seen.insert(descriptor.device_id.clone()) { - return Err(HostError::InvalidConfiguration(format!( - "duplicate device id {}", - descriptor.device_id - ))); - } - let id = descriptor.device_id.clone(); - devices.insert( - id, - Arc::new(DeviceState { - lanes: Arc::new(Semaphore::new(descriptor.lane_count as usize)), - descriptor, - next_lane: AtomicU64::new(0), - reserved_vram: AtomicU64::new(0), - }), - ); - } - - Ok(Self { - backend, - devices, - limits, - }) - } - - pub fn snapshots(&self) -> Vec<(DeviceDescriptor, u64, usize)> { - let mut snapshots: Vec<_> = self - .devices - .values() - .map(|device| { - ( - device.descriptor.clone(), - device.free_vram(), - device.lanes.available_permits(), - ) - }) - .collect(); - snapshots.sort_by(|a, b| a.0.device_id.cmp(&b.0.device_id)); - snapshots - } - - fn validate_command(&self, scratch_bytes: u64, command: &GpuCommand) -> Result<(), HostError> { - if command.executable.is_empty() - || command.executable.len() > self.limits.max_executable_len - || command.executable == "." - || command.executable == ".." - || !command - .executable - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b':')) - { - return Err(HostError::InvalidCommand( - "invalid executable identifier".into(), - )); - } - if command.inputs.len() > self.limits.max_inputs { - return Err(HostError::InvalidCommand("too many input buffers".into())); - } - let total_input = command.inputs.iter().try_fold(0usize, |total, input| { - total - .checked_add(input.len()) - .ok_or_else(|| HostError::InvalidCommand("input byte count overflow".into())) - })?; - if total_input > self.limits.max_input_bytes { - return Err(HostError::InvalidCommand( - "input byte limit exceeded".into(), - )); - } - if command.output_capacity > self.limits.max_output_bytes { - return Err(HostError::InvalidCommand( - "output capacity limit exceeded".into(), - )); - } - if scratch_bytes > self.limits.max_scratch_bytes { - return Err(HostError::InvalidCommand( - "scratch byte limit exceeded".into(), - )); - } - if let Some(deadline) = command.deadline_unix_ms { - if deadline <= unix_ms() { - return Err(HostError::DeadlineExceeded); - } - } - Ok(()) - } - - pub async fn execute( - &self, - device_id: &str, - scratch_bytes: u64, - command: GpuCommand, - ) -> Result { - self.validate_command(scratch_bytes, &command)?; - let state = self - .devices - .get(device_id) - .ok_or_else(|| HostError::DeviceNotFound(device_id.into()))? - .clone(); - if !state.descriptor.healthy { - return Err(HostError::DeviceUnhealthy(device_id.into())); - } - - let vram = state.reserve(scratch_bytes)?; - let lane_future = state.lanes.clone().acquire_owned(); - let lane: OwnedSemaphorePermit = match remaining(command.deadline_unix_ms)? { - Some(duration) => time::timeout(duration, lane_future) - .await - .map_err(|_| HostError::DeadlineExceeded)? - .map_err(|_| HostError::LanePoolClosed)?, - None => lane_future.await.map_err(|_| HostError::LanePoolClosed)?, - }; - - let lane_id = state.next_lane.fetch_add(1, Ordering::Relaxed); - let backend = self.backend.clone(); - let backend_device_id = device_id.to_owned(); - let output_capacity = command.output_capacity; - let deadline_unix_ms = command.deadline_unix_ms; - let (completion_tx, completion_rx) = oneshot::channel(); - - // The caller deadline is not allowed to recycle host accounting while native - // device work may still be in flight. The detached task owns both admission - // leases until the backend future actually completes. - tokio::spawn(async move { - let _vram = vram; - let _lane = lane; - let result = backend.execute(&backend_device_id, lane_id, &command).await; - let _ = completion_tx.send(result); - }); - - let output = match remaining(deadline_unix_ms)? { - Some(duration) => time::timeout(duration, completion_rx) - .await - .map_err(|_| HostError::DeadlineExceeded)? - .map_err(|_| HostError::Backend("backend completion task stopped".into()))??, - None => completion_rx - .await - .map_err(|_| HostError::Backend("backend completion task stopped".into()))??, - }; - - if output.len() > output_capacity || output.len() > self.limits.max_output_bytes { - return Err(HostError::OutputTooLarge { - capacity: output_capacity.min(self.limits.max_output_bytes), - actual: output.len(), - }); - } - - Ok(ExecutionReceipt { - device_id: device_id.into(), - lane_id, - output, - scratch_bytes, - }) - } -} - -fn unix_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or(Duration::ZERO) - .as_millis() - .try_into() - .unwrap_or(u64::MAX) -} - -fn remaining(deadline_unix_ms: Option) -> Result, HostError> { - let Some(deadline) = deadline_unix_ms else { - return Ok(None); - }; - let now = unix_ms(); - let millis = deadline - .checked_sub(now) - .ok_or(HostError::DeadlineExceeded)?; - if millis == 0 { - return Err(HostError::DeadlineExceeded); - } - Ok(Some(Duration::from_millis(millis))) -} - -#[derive(Clone)] -pub struct MockBackend { - pub descriptors: Vec, -} - -#[async_trait] -impl AcceleratorBackend for MockBackend { - fn devices(&self) -> Vec { - self.descriptors.clone() - } - - async fn execute( - &self, - _device_id: &str, - _lane_id: u64, - command: &GpuCommand, - ) -> Result, HostError> { - Ok(command.inputs.first().cloned().unwrap_or_default()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn host() -> GpuHost { - GpuHost::new(MockBackend { - descriptors: vec![DeviceDescriptor { - device_id: "mock0".into(), - backend: BackendKind::Mock, - total_vram_bytes: 1024, - lane_count: 2, - supports_partitioning: false, - healthy: true, - }], - }) - .unwrap() - } - - #[tokio::test] - async fn executes_through_a_bounded_lane() { - let receipt = host() - .execute( - "mock0", - 64, - GpuCommand { - executable: "echo".into(), - inputs: vec![b"hello".to_vec()], - output_capacity: 64, - deadline_unix_ms: None, - }, - ) - .await - .unwrap(); - assert_eq!(receipt.output, b"hello"); - } - - #[tokio::test] - async fn rejects_path_like_executable_identifiers() { - let error = host() - .execute( - "mock0", - 1, - GpuCommand { - executable: "../engine".into(), - inputs: vec![], - output_capacity: 0, - deadline_unix_ms: None, - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, HostError::InvalidCommand(_))); - } - - #[tokio::test] - async fn rejects_vram_overcommit() { - let error = host() - .execute( - "mock0", - 2048, - GpuCommand { - executable: "echo".into(), - inputs: vec![], - output_capacity: 0, - deadline_unix_ms: None, - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, HostError::VramAdmission { .. })); - } - - #[derive(Clone)] - struct SlowBackend { - descriptor: DeviceDescriptor, - delay: Duration, - } - - #[async_trait] - impl AcceleratorBackend for SlowBackend { - fn devices(&self) -> Vec { - vec![self.descriptor.clone()] - } - - async fn execute( - &self, - _device_id: &str, - _lane_id: u64, - command: &GpuCommand, - ) -> Result, HostError> { - time::sleep(self.delay).await; - Ok(command.inputs.first().cloned().unwrap_or_default()) - } - } - - #[tokio::test] - async fn timeout_keeps_lane_and_vram_reserved_until_backend_finishes() { - let host = GpuHost::new(SlowBackend { - descriptor: DeviceDescriptor { - device_id: "slow0".into(), - backend: BackendKind::Mock, - total_vram_bytes: 1024, - lane_count: 1, - supports_partitioning: false, - healthy: true, - }, - delay: Duration::from_millis(100), - }) - .unwrap(); - - let error = host - .execute( - "slow0", - 512, - GpuCommand { - executable: "slow".into(), - inputs: vec![b"hello".to_vec()], - output_capacity: 64, - deadline_unix_ms: Some(unix_ms() + 20), - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, HostError::DeadlineExceeded)); - - let snapshots = host.snapshots(); - assert_eq!(snapshots[0].1, 512); - assert_eq!(snapshots[0].2, 0); - - time::sleep(Duration::from_millis(120)).await; - let snapshots = host.snapshots(); - assert_eq!(snapshots[0].1, 1024); - assert_eq!(snapshots[0].2, 1); - } - - #[tokio::test] - async fn rejects_oversized_output_capacity_before_backend_execution() { - let limits = HostLimits { - max_output_bytes: 4, - ..HostLimits::default() - }; - let host = GpuHost::with_limits( - MockBackend { - descriptors: vec![DeviceDescriptor { - device_id: "mock0".into(), - backend: BackendKind::Mock, - total_vram_bytes: 1024, - lane_count: 1, - supports_partitioning: false, - healthy: true, - }], - }, - limits, - ) - .unwrap(); - let error = host - .execute( - "mock0", - 1, - GpuCommand { - executable: "echo".into(), - inputs: vec![b"hello".to_vec()], - output_capacity: 5, - deadline_unix_ms: None, - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, HostError::InvalidCommand(_))); - } -} diff --git a/vendor/litegraph-modeld/.github/workflows/ci.yml b/vendor/litegraph-modeld/.github/workflows/ci.yml deleted file mode 100644 index 93b16988..00000000 --- a/vendor/litegraph-modeld/.github/workflows/ci.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: ci - -on: - pull_request: - push: - branches: [main] - -permissions: - contents: read - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - rust: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 - with: - persist-credentials: false - - run: rustc --version && cargo --version - - run: cargo fmt --all -- --check - - run: cargo clippy --all-targets --all-features -- -D warnings - - run: cargo test --all-features diff --git a/vendor/litegraph-modeld/.gitignore b/vendor/litegraph-modeld/.gitignore deleted file mode 100644 index 3e2da82c..00000000 --- a/vendor/litegraph-modeld/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -/target -Cargo.lock - -# Fleet-local scratch/worktrees and agent-policy link -.ores/ -tmp/ -temp/ diff --git a/vendor/litegraph-modeld/.ores-otel.toml b/vendor/litegraph-modeld/.ores-otel.toml deleted file mode 100644 index ddb47388..00000000 --- a/vendor/litegraph-modeld/.ores-otel.toml +++ /dev/null @@ -1,2 +0,0 @@ -service_name = "litegraph-modeld" -json_stdio = true diff --git a/vendor/litegraph-modeld/AGENTS.md b/vendor/litegraph-modeld/AGENTS.md deleted file mode 100644 index a347a361..00000000 --- a/vendor/litegraph-modeld/AGENTS.md +++ /dev/null @@ -1,26 +0,0 @@ -# Repository agent instructions - -This repository follows the shared ORESoftware fleet policy. - - - -## Canonical agent instructions - -Before doing anything else in this repository, also read: - - .ores/agents/AGENTS.md - -That path is a symlink to `~/codes/oresoftware/my-ai/AGENTS.md`, whose canonical copy is -. - -The symlink is deliberately not committed. If it is missing locally, run -`~/codes/oresoftware/my-ai/scripts/link-repo-agents.sh` or fetch the canonical policy above. -A missing local symlink is a setup gap, never permission to skip the policy. - - - -## Repository-specific rule - -Preserve the ownership boundaries documented in this repository's README. Cross-repository -LiteGraph semantics belong in the canonical interfaces/contracts repositories rather than -being independently redefined here. diff --git a/vendor/litegraph-modeld/Cargo.toml b/vendor/litegraph-modeld/Cargo.toml deleted file mode 100644 index ba54e64f..00000000 --- a/vendor/litegraph-modeld/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "litegraph-modeld" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" - -[dependencies] -async-trait = "0.1" -serde = { version = "1", features = ["derive"] } -thiserror = "2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } diff --git a/vendor/litegraph-modeld/README.md b/vendor/litegraph-modeld/README.md deleted file mode 100644 index 9c0c9dd9..00000000 --- a/vendor/litegraph-modeld/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# litegraph-modeld - -Resident-artifact daemon specializing ResidentActor as ModelActor. - -LiteGraph is a heterogeneous compute actor platform: CPU code owns control, networking, actor supervision and ordinary OS capabilities; suitable numerical work may be dispatched to one or more GPUs. A machine is therefore not classified as simply "CPU" or "GPU"—CPU, RAM, accelerator devices and VRAM are independently schedulable resources. - -## Responsibilities - -- immutable model revision residency. -- dynamic batching and result demultiplexing. -- CPU/GPU variant residency state. -- eviction, warm/cold/hot state and multi-device model placement. - -## Explicit non-responsibilities - -- global scheduling. -- model artifact publication. -- direct customer device pointers. - -Keeping these boundaries explicit is important: moving policy into a lower-level component makes local execution harder to reason about and creates competing authorities. - -## Place in the system - -```text -runtime request → ModelActor → compatible batch → gpu-host/CPU backend → demultiplexed result -``` - -Shared invariants across the platform: - -- invocation actors are ephemeral; -- resident artifacts and compiled variants are immutable and revisioned; -- guest/customer code receives capabilities, never raw accelerator pointers; -- mutable accelerator state belongs to trusted lane/device actors; -- CPU and GPU resources are accounted independently; -- `cpu`, `gpu`, and `auto` describe execution requirements/preferences without changing logical function identity; -- backpressure and cancellation must propagate rather than creating unbounded queues. - -## Contracts and compatibility - -Wire-visible names use `snake_case`. Cross-language contracts belong in `litegraph-contracts`: authored TypeSpec and JSON Schema Draft 2020-12 are peer authorities, and generated files are evidence rather than a third authored schema. Contract mismatches must fail closed before promotion. - -Public/shared semantic types belong in `litegraph-interfaces` or `litegraph-pub-lib-core`; this repository should not create a subtly different copy of an existing concept. - -## Security and isolation - -Treat all tenant input and artifacts as untrusted. Validate sizes, identifiers and capability requests before allocating expensive resources. Never expose native accelerator pointers/driver handles across the tenant boundary, never place credentials in manifests or examples, and keep secrets in approved runtime secret channels. - -Isolation policy uses the platform classes `shared`, `sandbox`, `partitioned`, and `dedicated` where applicable. Resource release on cancellation, timeout and failure is part of correctness. - -## Development expectations - -Follow the fleet policy in `ORESoftware/my-ai` (`AGENTS.md` plus `SHARED.md`) when changing this repository. Durable systems tooling, validators, code generation and CI helpers should be Rust-first. Do not add Python for repository scripts, validators, codegen or CI gates. - -When this repository exposes an executable with command-line configuration, its public option contract belongs in root `.cli-flags.toml` and the argv boundary should use the canonical `flags-2-env` integration rather than maintaining a second independent flag schema. - -Tests should cover both success and fail-closed behavior. Hardware-independent logic should run with deterministic fakes/simulators; hardware-specific certification belongs on real accelerator runners. A hosted workflow that starts zero test steps is not evidence of a passing build. - -## Integration map - -- `litegraph-contracts` — wire schemas. -- `litegraph-interfaces` — canonical shared semantics. -- `litegraph-scheduler` — cluster placement. -- `litegraph-node` — machine inventory and local supervision. -- `litegraph-runtime` — invocation lifecycle. -- `litegraph-gpu-host` — trusted accelerator execution. -- `litegraph-modeld` — resident model actors. -- `litegraph-compiler` — deterministic multi-target build artifacts. -- registries — immutable function/model artifact storage. -- `litegraph-router.rs` — invocation forwarding and backpressure. - -## Documentation rule - -Keep this README specific to this repository. Architectural decisions that affect multiple repositories should be recorded in the canonical interface/contracts layer and linked here rather than copied into divergent local specifications. diff --git a/vendor/litegraph-modeld/rust-toolchain.toml b/vendor/litegraph-modeld/rust-toolchain.toml deleted file mode 100644 index e6300496..00000000 --- a/vendor/litegraph-modeld/rust-toolchain.toml +++ /dev/null @@ -1,4 +0,0 @@ -[toolchain] -channel = "1.98.1" -profile = "minimal" -components = ["clippy", "rustfmt"] diff --git a/vendor/litegraph-modeld/src/lib.rs b/vendor/litegraph-modeld/src/lib.rs deleted file mode 100644 index d1b0413e..00000000 --- a/vendor/litegraph-modeld/src/lib.rs +++ /dev/null @@ -1,509 +0,0 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::{ - collections::{HashMap, VecDeque}, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, - time::Duration, -}; -use tokio::{ - sync::{mpsc, oneshot, RwLock}, - time, -}; - -const MAX_MODEL_BYTES: u64 = 512 * 1024 * 1024 * 1024; -const MAX_BATCH: usize = 4096; -const MAX_QUEUE: usize = 65_536; -const MAX_INPUT_BYTES: usize = 256 * 1024 * 1024; -const MAX_BATCH_DELAY_US: u64 = 1_000_000; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct ModelManifest { - pub name: String, - pub version: String, - pub digest: String, - pub weight_bytes: u64, - pub workspace_bytes: u64, - pub max_batch: usize, - pub max_delay_us: u64, - pub max_input_bytes: usize, - pub queue_capacity: usize, - pub allow_cross_tenant_batching: bool, -} - -impl ModelManifest { - pub fn key(&self) -> String { - format!("{}:{}", self.name, self.version) - } - - pub fn validate(&self) -> Result<(), ModelError> { - validate_token(&self.name, "name")?; - validate_token(&self.version, "version")?; - validate_digest(&self.digest)?; - if self.weight_bytes == 0 || self.weight_bytes > MAX_MODEL_BYTES { - return Err(ModelError::InvalidManifest( - "weight_bytes out of range".into(), - )); - } - if self.workspace_bytes > MAX_MODEL_BYTES - || self.weight_bytes.saturating_add(self.workspace_bytes) > MAX_MODEL_BYTES - { - return Err(ModelError::InvalidManifest( - "model + workspace exceed maximum resident bytes".into(), - )); - } - if !(1..=MAX_BATCH).contains(&self.max_batch) { - return Err(ModelError::InvalidManifest("max_batch out of range".into())); - } - if self.max_delay_us > MAX_BATCH_DELAY_US { - return Err(ModelError::InvalidManifest( - "max_delay_us out of range".into(), - )); - } - if !(1..=MAX_INPUT_BYTES).contains(&self.max_input_bytes) { - return Err(ModelError::InvalidManifest( - "max_input_bytes out of range".into(), - )); - } - if !(1..=MAX_QUEUE).contains(&self.queue_capacity) { - return Err(ModelError::InvalidManifest( - "queue_capacity out of range".into(), - )); - } - Ok(()) - } -} - -fn validate_token(value: &str, field: &str) -> Result<(), ModelError> { - if value.is_empty() - || value.len() > 128 - || !value - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) - { - return Err(ModelError::InvalidManifest(format!( - "invalid {field}: {value:?}" - ))); - } - Ok(()) -} - -fn validate_digest(value: &str) -> Result<(), ModelError> { - if value.len() != 71 - || !value.starts_with("sha256:") - || !value[7..] - .bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) - { - return Err(ModelError::InvalidManifest( - "digest must be lowercase sha256:<64 hex>".into(), - )); - } - Ok(()) -} - -#[derive(Debug, thiserror::Error, Clone)] -pub enum ModelError { - #[error("invalid model manifest: {0}")] - InvalidManifest(String), - #[error("model version conflicts with already resident digest: {0}")] - Conflict(String), - #[error("model not found: {0}")] - NotFound(String), - #[error("model handle revoked")] - Revoked, - #[error("model actor stopped")] - Stopped, - #[error("input exceeds model limit: max={max} actual={actual}")] - InputTooLarge { max: usize, actual: usize }, - #[error("invalid batch key")] - InvalidBatchKey, - #[error("execution failed: {0}")] - Execution(String), -} - -#[async_trait] -pub trait BatchExecutor: Send + Sync + 'static { - async fn execute_batch( - &self, - model: &ModelManifest, - inputs: Vec>, - ) -> Vec, ModelError>>; -} - -struct Request { - invocation_id: String, - tenant_id: String, - batch_key: String, - input: Vec, - reply: oneshot::Sender, ModelError>>, -} - -#[derive(Clone)] -pub struct ModelHandle { - manifest: ModelManifest, - tx: mpsc::Sender, - revoked: Arc, -} - -impl ModelHandle { - pub fn manifest(&self) -> &ModelManifest { - &self.manifest - } - - /// Safe default: each invocation gets a unique batch key, so independent calls are - /// not coalesced unless the caller opts into a compatibility class explicitly. - pub async fn infer( - &self, - invocation_id: impl Into, - tenant_id: impl Into, - input: Vec, - ) -> Result, ModelError> { - let invocation_id = invocation_id.into(); - let tenant_id = tenant_id.into(); - let batch_key = format!("invocation-{invocation_id}"); - self.infer_batched(invocation_id, tenant_id, batch_key, input) - .await - } - - pub async fn infer_batched( - &self, - invocation_id: impl Into, - tenant_id: impl Into, - batch_key: impl Into, - input: Vec, - ) -> Result, ModelError> { - if self.revoked.load(Ordering::Acquire) { - return Err(ModelError::Revoked); - } - if input.len() > self.manifest.max_input_bytes { - return Err(ModelError::InputTooLarge { - max: self.manifest.max_input_bytes, - actual: input.len(), - }); - } - let invocation_id = invocation_id.into(); - let tenant_id = tenant_id.into(); - let batch_key = batch_key.into(); - if invocation_id.is_empty() - || invocation_id.len() > 256 - || invocation_id.bytes().any(|b| b.is_ascii_control()) - || tenant_id.is_empty() - || tenant_id.len() > 256 - || tenant_id.bytes().any(|b| b.is_ascii_control()) - || batch_key.is_empty() - || batch_key.len() > 256 - || batch_key.bytes().any(|b| b.is_ascii_control()) - { - return Err(ModelError::InvalidBatchKey); - } - - let (reply, rx) = oneshot::channel(); - self.tx - .send(Request { - invocation_id, - tenant_id, - batch_key, - input, - reply, - }) - .await - .map_err(|_| ModelError::Stopped)?; - rx.await.map_err(|_| ModelError::Stopped)? - } -} - -pub struct ModelDaemon { - executor: Arc, - models: Arc>>, -} - -impl ModelDaemon { - pub fn new(executor: E) -> Self { - Self { - executor: Arc::new(executor), - models: Arc::new(RwLock::new(HashMap::new())), - } - } - - pub async fn load(&self, manifest: ModelManifest) -> Result { - manifest.validate()?; - let key = manifest.key(); - let mut models = self.models.write().await; - if let Some(existing) = models.get(&key) { - if existing.manifest == manifest { - return Ok(existing.clone()); - } - return Err(ModelError::Conflict(key)); - } - - let (tx, rx) = mpsc::channel(manifest.queue_capacity); - let revoked = Arc::new(AtomicBool::new(false)); - let handle = ModelHandle { - manifest: manifest.clone(), - tx, - revoked: revoked.clone(), - }; - models.insert(key, handle.clone()); - tokio::spawn(run_model_actor( - manifest, - self.executor.clone(), - rx, - revoked, - )); - Ok(handle) - } - - pub async fn get(&self, key: &str) -> Result { - self.models - .read() - .await - .get(key) - .cloned() - .ok_or_else(|| ModelError::NotFound(key.into())) - } - - pub async fn unload(&self, key: &str) -> bool { - let removed = self.models.write().await.remove(key); - if let Some(handle) = removed { - handle.revoked.store(true, Ordering::Release); - true - } else { - false - } - } - - pub async fn resident_models(&self) -> Vec { - let mut models: Vec<_> = self - .models - .read() - .await - .values() - .map(|handle| handle.manifest.clone()) - .collect(); - models.sort_by_key(ModelManifest::key); - models - } -} - -fn compatible(manifest: &ModelManifest, first: &Request, next: &Request) -> bool { - first.batch_key == next.batch_key - && (manifest.allow_cross_tenant_batching || first.tenant_id == next.tenant_id) -} - -async fn run_model_actor( - manifest: ModelManifest, - executor: Arc, - mut rx: mpsc::Receiver, - revoked: Arc, -) { - let mut backlog = VecDeque::new(); - - loop { - backlog.retain(|request: &Request| !request.reply.is_closed()); - - let first = if let Some(request) = backlog.pop_front() { - request - } else { - match rx.recv().await { - Some(request) => request, - None => break, - } - }; - - if first.reply.is_closed() { - continue; - } - - if revoked.load(Ordering::Acquire) { - let _ = first.reply.send(Err(ModelError::Revoked)); - while let Some(request) = backlog.pop_front() { - let _ = request.reply.send(Err(ModelError::Revoked)); - } - while let Some(request) = rx.recv().await { - let _ = request.reply.send(Err(ModelError::Revoked)); - } - break; - } - - let mut batch = vec![first]; - let deadline = time::Instant::now() + Duration::from_micros(manifest.max_delay_us); - - while batch.len() < manifest.max_batch { - if let Some(position) = backlog - .iter() - .position(|request| compatible(&manifest, &batch[0], request)) - { - if let Some(request) = backlog.remove(position) { - batch.push(request); - continue; - } - } - - if time::Instant::now() >= deadline || backlog.len() >= manifest.queue_capacity { - break; - } - - match time::timeout_at(deadline, rx.recv()).await { - Ok(Some(request)) if request.reply.is_closed() => {} - Ok(Some(request)) if compatible(&manifest, &batch[0], &request) => { - batch.push(request) - } - Ok(Some(request)) => backlog.push_back(request), - _ => break, - } - } - - batch.retain(|request| !request.reply.is_closed()); - if batch.is_empty() { - continue; - } - - let inputs = batch - .iter_mut() - .map(|request| std::mem::take(&mut request.input)) - .collect(); - let outputs = executor.execute_batch(&manifest, inputs).await; - if outputs.len() != batch.len() { - let error = ModelError::Execution(format!( - "executor result cardinality mismatch: expected {} got {}", - batch.len(), - outputs.len() - )); - for request in batch { - let _ = request.reply.send(Err(error.clone())); - } - continue; - } - - for (request, result) in batch.into_iter().zip(outputs) { - let _ownership = (&request.invocation_id, &request.tenant_id); - let _ = request.reply.send(result); - } - } -} - -#[derive(Default)] -pub struct EchoExecutor; - -#[async_trait] -impl BatchExecutor for EchoExecutor { - async fn execute_batch( - &self, - _model: &ModelManifest, - inputs: Vec>, - ) -> Vec, ModelError>> { - inputs.into_iter().map(Ok).collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn manifest() -> ModelManifest { - ModelManifest { - name: "embed".into(), - version: "1".into(), - digest: format!("sha256:{}", "a".repeat(64)), - weight_bytes: 100, - workspace_bytes: 10, - max_batch: 8, - max_delay_us: 100, - max_input_bytes: 1024, - queue_capacity: 32, - allow_cross_tenant_batching: false, - } - } - - #[tokio::test] - async fn model_actor_routes_results_to_callers() { - let daemon = ModelDaemon::new(EchoExecutor); - let model = daemon.load(manifest()).await.unwrap(); - let output = model - .infer("inv-1", "tenant-a", b"hello".to_vec()) - .await - .unwrap(); - assert_eq!(output, b"hello"); - } - - #[tokio::test] - async fn rejects_control_characters_in_actor_identifiers() { - let daemon = ModelDaemon::new(EchoExecutor); - let model = daemon.load(manifest()).await.unwrap(); - assert!(matches!( - model.infer("inv\n1", "tenant", vec![1]).await, - Err(ModelError::InvalidBatchKey) - )); - assert!(matches!( - model.infer("inv", "tenant\n1", vec![1]).await, - Err(ModelError::InvalidBatchKey) - )); - } - - #[tokio::test] - async fn same_name_version_cannot_change_digest() { - let daemon = ModelDaemon::new(EchoExecutor); - daemon.load(manifest()).await.unwrap(); - let mut conflicting = manifest(); - conflicting.digest = format!("sha256:{}", "b".repeat(64)); - assert!(matches!( - daemon.load(conflicting).await, - Err(ModelError::Conflict(_)) - )); - } - - struct CountingExecutor { - calls: Arc, - } - - #[async_trait] - impl BatchExecutor for CountingExecutor { - async fn execute_batch( - &self, - _model: &ModelManifest, - inputs: Vec>, - ) -> Vec, ModelError>> { - self.calls.fetch_add(1, Ordering::SeqCst); - inputs.into_iter().map(Ok).collect() - } - } - - #[tokio::test] - async fn cancelled_callers_are_not_executed_when_observed_before_batch() { - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let mut delayed = manifest(); - delayed.max_delay_us = 50_000; - let daemon = ModelDaemon::new(CountingExecutor { - calls: calls.clone(), - }); - let model = daemon.load(delayed).await.unwrap(); - - let task = tokio::spawn({ - let model = model.clone(); - async move { - model - .infer_batched("inv-cancel", "tenant-a", "same", vec![1]) - .await - } - }); - task.abort(); - - time::sleep(Duration::from_millis(75)).await; - assert_eq!(calls.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn unload_revokes_existing_handles() { - let daemon = ModelDaemon::new(EchoExecutor); - let model = daemon.load(manifest()).await.unwrap(); - assert!(daemon.unload("embed:1").await); - assert!(matches!( - model.infer("inv", "tenant", vec![1]).await, - Err(ModelError::Revoked) - )); - } -} diff --git a/vendor/litegraph-runtime/.github/workflows/ci.yml b/vendor/litegraph-runtime/.github/workflows/ci.yml deleted file mode 100644 index 93b16988..00000000 --- a/vendor/litegraph-runtime/.github/workflows/ci.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: ci - -on: - pull_request: - push: - branches: [main] - -permissions: - contents: read - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - rust: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 - with: - persist-credentials: false - - run: rustc --version && cargo --version - - run: cargo fmt --all -- --check - - run: cargo clippy --all-targets --all-features -- -D warnings - - run: cargo test --all-features diff --git a/vendor/litegraph-runtime/.gitignore b/vendor/litegraph-runtime/.gitignore deleted file mode 100644 index 3e2da82c..00000000 --- a/vendor/litegraph-runtime/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -/target -Cargo.lock - -# Fleet-local scratch/worktrees and agent-policy link -.ores/ -tmp/ -temp/ diff --git a/vendor/litegraph-runtime/.ores-otel.toml b/vendor/litegraph-runtime/.ores-otel.toml deleted file mode 100644 index 08eeb9d5..00000000 --- a/vendor/litegraph-runtime/.ores-otel.toml +++ /dev/null @@ -1,2 +0,0 @@ -service_name = "litegraph-runtime" -json_stdio = true diff --git a/vendor/litegraph-runtime/AGENTS.md b/vendor/litegraph-runtime/AGENTS.md deleted file mode 100644 index a347a361..00000000 --- a/vendor/litegraph-runtime/AGENTS.md +++ /dev/null @@ -1,26 +0,0 @@ -# Repository agent instructions - -This repository follows the shared ORESoftware fleet policy. - - - -## Canonical agent instructions - -Before doing anything else in this repository, also read: - - .ores/agents/AGENTS.md - -That path is a symlink to `~/codes/oresoftware/my-ai/AGENTS.md`, whose canonical copy is -. - -The symlink is deliberately not committed. If it is missing locally, run -`~/codes/oresoftware/my-ai/scripts/link-repo-agents.sh` or fetch the canonical policy above. -A missing local symlink is a setup gap, never permission to skip the policy. - - - -## Repository-specific rule - -Preserve the ownership boundaries documented in this repository's README. Cross-repository -LiteGraph semantics belong in the canonical interfaces/contracts repositories rather than -being independently redefined here. diff --git a/vendor/litegraph-runtime/Cargo.toml b/vendor/litegraph-runtime/Cargo.toml deleted file mode 100644 index 030e89d2..00000000 --- a/vendor/litegraph-runtime/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "litegraph-runtime" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" - -[features] -default = [] -test-utils = [] - -[dependencies] -async-trait = "0.1" -serde = { version = "1", features = ["derive"] } -thiserror = "2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } diff --git a/vendor/litegraph-runtime/README.md b/vendor/litegraph-runtime/README.md deleted file mode 100644 index e5b01823..00000000 --- a/vendor/litegraph-runtime/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# litegraph-runtime - -Per-invocation execution runtime and capability boundary. - -LiteGraph is a heterogeneous compute actor platform: CPU code owns control, networking, actor supervision and ordinary OS capabilities; suitable numerical work may be dispatched to one or more GPUs. A machine is therefore not classified as simply "CPU" or "GPU"—CPU, RAM, accelerator devices and VRAM are independently schedulable resources. - -## Responsibilities - -- InvocationActor lifecycle, deadlines and cancellation. -- request-local memory and output ownership. -- guest-engine adapters and opaque host capabilities. -- CPU host phases and accelerator dispatch through trusted clients. - -## Explicit non-responsibilities - -- cluster placement policy. -- raw GPU driver ownership. -- resident model lifecycle. - -Keeping these boundaries explicit is important: moving policy into a lower-level component makes local execution harder to reason about and creates competing authorities. - -## Place in the system - -```text -router → node → runtime/InvocationActor → CPU host work and/or gpu-host/modeld → result -``` - -Shared invariants across the platform: - -- invocation actors are ephemeral; -- resident artifacts and compiled variants are immutable and revisioned; -- guest/customer code receives capabilities, never raw accelerator pointers; -- mutable accelerator state belongs to trusted lane/device actors; -- CPU and GPU resources are accounted independently; -- `cpu`, `gpu`, and `auto` describe execution requirements/preferences without changing logical function identity; -- backpressure and cancellation must propagate rather than creating unbounded queues. - -## Contracts and compatibility - -Wire-visible names use `snake_case`. Cross-language contracts belong in `litegraph-contracts`: authored TypeSpec and JSON Schema Draft 2020-12 are peer authorities, and generated files are evidence rather than a third authored schema. Contract mismatches must fail closed before promotion. - -Public/shared semantic types belong in `litegraph-interfaces` or `litegraph-pub-lib-core`; this repository should not create a subtly different copy of an existing concept. - -## Security and isolation - -Treat all tenant input and artifacts as untrusted. Validate sizes, identifiers and capability requests before allocating expensive resources. Never expose native accelerator pointers/driver handles across the tenant boundary, never place credentials in manifests or examples, and keep secrets in approved runtime secret channels. - -Isolation policy uses the platform classes `shared`, `sandbox`, `partitioned`, and `dedicated` where applicable. Resource release on cancellation, timeout and failure is part of correctness. - -## Development expectations - -Follow the fleet policy in `ORESoftware/my-ai` (`AGENTS.md` plus `SHARED.md`) when changing this repository. Durable systems tooling, validators, code generation and CI helpers should be Rust-first. Do not add Python for repository scripts, validators, codegen or CI gates. - -When this repository exposes an executable with command-line configuration, its public option contract belongs in root `.cli-flags.toml` and the argv boundary should use the canonical `flags-2-env` integration rather than maintaining a second independent flag schema. - -Tests should cover both success and fail-closed behavior. Hardware-independent logic should run with deterministic fakes/simulators; hardware-specific certification belongs on real accelerator runners. A hosted workflow that starts zero test steps is not evidence of a passing build. - -## Integration map - -- `litegraph-contracts` — wire schemas. -- `litegraph-interfaces` — canonical shared semantics. -- `litegraph-scheduler` — cluster placement. -- `litegraph-node` — machine inventory and local supervision. -- `litegraph-runtime` — invocation lifecycle. -- `litegraph-gpu-host` — trusted accelerator execution. -- `litegraph-modeld` — resident model actors. -- `litegraph-compiler` — deterministic multi-target build artifacts. -- registries — immutable function/model artifact storage. -- `litegraph-router.rs` — invocation forwarding and backpressure. - -## Documentation rule - -Keep this README specific to this repository. Architectural decisions that affect multiple repositories should be recorded in the canonical interface/contracts layer and linked here rather than copied into divergent local specifications. diff --git a/vendor/litegraph-runtime/rust-toolchain.toml b/vendor/litegraph-runtime/rust-toolchain.toml deleted file mode 100644 index e6300496..00000000 --- a/vendor/litegraph-runtime/rust-toolchain.toml +++ /dev/null @@ -1,4 +0,0 @@ -[toolchain] -channel = "1.98.1" -profile = "minimal" -components = ["clippy", "rustfmt"] diff --git a/vendor/litegraph-runtime/src/lib.rs b/vendor/litegraph-runtime/src/lib.rs deleted file mode 100644 index c6919721..00000000 --- a/vendor/litegraph-runtime/src/lib.rs +++ /dev/null @@ -1,453 +0,0 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - sync::{ - atomic::{compiler_fence, AtomicBool, AtomicU64, Ordering}, - Arc, - }, - time::Duration, -}; -use tokio::sync::Mutex; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct CapabilityHandle(pub u64); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum Capability { - Model { key: String }, - Tensor { bytes: Vec }, - Buffer { bytes: Vec }, - OutputStream, -} - -impl Capability { - fn resident_bytes(&self) -> usize { - match self { - Self::Model { key } => key.len(), - Self::Tensor { bytes } | Self::Buffer { bytes } => bytes.len(), - Self::OutputStream => 0, - } - } - - fn secure_clear(&mut self) { - match self { - Self::Tensor { bytes } | Self::Buffer { bytes } => secure_zero(bytes), - Self::Model { key } => { - // Model keys are not secrets, but clearing avoids retaining tenant-scoped handles. - unsafe { key.as_bytes_mut() }.fill(0); - } - Self::OutputStream => {} - } - } -} - -fn secure_zero(bytes: &mut [u8]) { - for byte in bytes { - // SAFETY: `byte` is uniquely borrowed and valid for a volatile u8 write. - unsafe { std::ptr::write_volatile(byte, 0) }; - } - compiler_fence(Ordering::SeqCst); -} - -#[derive(Debug, Clone)] -pub struct RuntimeLimits { - pub max_payload_bytes: usize, - pub max_response_bytes: usize, - pub max_accelerator_input_bytes: usize, - pub max_capabilities: usize, - pub max_capability_bytes: usize, - pub default_deadline_ms: u64, - pub max_deadline_ms: u64, - pub max_model_name_len: usize, -} - -impl Default for RuntimeLimits { - fn default() -> Self { - Self { - max_payload_bytes: 64 * 1024 * 1024, - max_response_bytes: 64 * 1024 * 1024, - max_accelerator_input_bytes: 64 * 1024 * 1024, - max_capabilities: 1024, - max_capability_bytes: 128 * 1024 * 1024, - default_deadline_ms: 30_000, - max_deadline_ms: 300_000, - max_model_name_len: 256, - } - } -} - -struct CapabilityState { - entries: HashMap, - resident_bytes: usize, -} - -pub struct CapabilityTable { - next: AtomicU64, - state: Mutex, - max_entries: usize, - max_bytes: usize, -} - -impl CapabilityTable { - fn new(max_entries: usize, max_bytes: usize) -> Self { - Self { - next: AtomicU64::new(1), - state: Mutex::new(CapabilityState { - entries: HashMap::new(), - resident_bytes: 0, - }), - max_entries, - max_bytes, - } - } - - pub async fn insert(&self, capability: Capability) -> Result { - let bytes = capability.resident_bytes(); - let mut state = self.state.lock().await; - if state.entries.len() >= self.max_entries { - return Err(RuntimeError::CapabilityLimitExceeded); - } - let Some(next_bytes) = state.resident_bytes.checked_add(bytes) else { - return Err(RuntimeError::CapabilityMemoryExceeded); - }; - if next_bytes > self.max_bytes { - return Err(RuntimeError::CapabilityMemoryExceeded); - } - let raw = self.next.fetch_add(1, Ordering::Relaxed); - if raw == u64::MAX { - return Err(RuntimeError::CapabilityLimitExceeded); - } - let handle = CapabilityHandle(raw); - state.resident_bytes = next_bytes; - state.entries.insert(handle, capability); - Ok(handle) - } - - pub async fn model_key(&self, handle: CapabilityHandle) -> Option { - let state = self.state.lock().await; - match state.entries.get(&handle) { - Some(Capability::Model { key }) => Some(key.clone()), - _ => None, - } - } - - pub async fn revoke_all(&self) { - let mut state = self.state.lock().await; - for capability in state.entries.values_mut() { - capability.secure_clear(); - } - state.entries.clear(); - state.resident_bytes = 0; - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct InvocationRequest { - pub invocation_id: String, - pub tenant_id: String, - pub payload: Vec, - pub deadline_ms_from_now: Option, -} - -impl Drop for InvocationRequest { - fn drop(&mut self) { - secure_zero(&mut self.payload); - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InvocationResponse { - pub payload: Vec, -} - -impl Drop for InvocationResponse { - fn drop(&mut self) { - secure_zero(&mut self.payload); - } -} - -#[derive(Debug, thiserror::Error)] -pub enum RuntimeError { - #[error("invalid invocation: {0}")] - InvalidInvocation(String), - #[error("invocation payload too large: max={max} actual={actual}")] - PayloadTooLarge { max: usize, actual: usize }, - #[error("invocation response too large: max={max} actual={actual}")] - ResponseTooLarge { max: usize, actual: usize }, - #[error("accelerator input too large: max={max} actual={actual}")] - AcceleratorInputTooLarge { max: usize, actual: usize }, - #[error("capability count limit exceeded")] - CapabilityLimitExceeded, - #[error("capability memory limit exceeded")] - CapabilityMemoryExceeded, - #[error("invocation cancelled")] - Cancelled, - #[error("deadline exceeded")] - DeadlineExceeded, - #[error("invalid capability {0:?}")] - InvalidCapability(CapabilityHandle), - #[error("accelerator error: {0}")] - Accelerator(String), - #[error("guest error: {0}")] - Guest(String), -} - -#[async_trait] -pub trait GpuClient: Send + Sync + 'static { - async fn open_model(&self, tenant_id: &str, model: &str) -> Result; - async fn infer( - &self, - tenant_id: &str, - model_key: &str, - input: Vec, - ) -> Result, RuntimeError>; -} - -pub struct HostApi { - tenant_id: String, - gpu: Arc, - capabilities: Arc, - cancelled: Arc, - limits: Arc, -} - -impl HostApi { - pub async fn open_model(&self, name: &str) -> Result { - self.check_cancelled()?; - if name.is_empty() - || name.len() > self.limits.max_model_name_len - || name.bytes().any(|b| b.is_ascii_control()) - { - return Err(RuntimeError::InvalidInvocation("invalid model name".into())); - } - let key = self.gpu.open_model(&self.tenant_id, name).await?; - self.check_cancelled()?; - self.capabilities.insert(Capability::Model { key }).await - } - - pub async fn infer( - &self, - model: CapabilityHandle, - input: Vec, - ) -> Result, RuntimeError> { - self.check_cancelled()?; - if input.len() > self.limits.max_accelerator_input_bytes { - return Err(RuntimeError::AcceleratorInputTooLarge { - max: self.limits.max_accelerator_input_bytes, - actual: input.len(), - }); - } - let key = self - .capabilities - .model_key(model) - .await - .ok_or(RuntimeError::InvalidCapability(model))?; - let output = self.gpu.infer(&self.tenant_id, &key, input).await?; - self.check_cancelled()?; - if output.len() > self.limits.max_response_bytes { - return Err(RuntimeError::ResponseTooLarge { - max: self.limits.max_response_bytes, - actual: output.len(), - }); - } - Ok(output) - } - - fn check_cancelled(&self) -> Result<(), RuntimeError> { - if self.cancelled.load(Ordering::Acquire) { - Err(RuntimeError::Cancelled) - } else { - Ok(()) - } - } -} - -#[async_trait] -pub trait GuestEngine: Send + Sync + 'static { - async fn execute( - &self, - request: &InvocationRequest, - host: &HostApi, - ) -> Result; -} - -pub struct InvocationRuntime> { - gpu: Arc, - engine: Arc, - limits: Arc, -} - -impl> InvocationRuntime { - pub fn new(gpu: G, engine: E) -> Self { - Self::with_limits(gpu, engine, RuntimeLimits::default()) - } - - pub fn with_limits(gpu: G, engine: E, limits: RuntimeLimits) -> Self { - Self { - gpu: Arc::new(gpu), - engine: Arc::new(engine), - limits: Arc::new(limits), - } - } - - fn validate_request(&self, request: &InvocationRequest) -> Result { - if request.invocation_id.is_empty() - || request.invocation_id.len() > 256 - || request.tenant_id.is_empty() - || request.tenant_id.len() > 256 - || request - .invocation_id - .bytes() - .chain(request.tenant_id.bytes()) - .any(|b| b.is_ascii_control()) - { - return Err(RuntimeError::InvalidInvocation( - "invalid invocation_id or tenant_id".into(), - )); - } - if request.payload.len() > self.limits.max_payload_bytes { - return Err(RuntimeError::PayloadTooLarge { - max: self.limits.max_payload_bytes, - actual: request.payload.len(), - }); - } - let deadline = request - .deadline_ms_from_now - .unwrap_or(self.limits.default_deadline_ms); - if deadline == 0 || deadline > self.limits.max_deadline_ms { - return Err(RuntimeError::InvalidInvocation(format!( - "deadline must be within 1..={}ms", - self.limits.max_deadline_ms - ))); - } - Ok(deadline) - } - - pub async fn invoke( - &self, - request: InvocationRequest, - ) -> Result { - let deadline_ms = self.validate_request(&request)?; - let capabilities = Arc::new(CapabilityTable::new( - self.limits.max_capabilities, - self.limits.max_capability_bytes, - )); - let cancelled = Arc::new(AtomicBool::new(false)); - let host = HostApi { - tenant_id: request.tenant_id.clone(), - gpu: self.gpu.clone(), - capabilities: capabilities.clone(), - cancelled: cancelled.clone(), - limits: self.limits.clone(), - }; - let run = self.engine.execute(&request, &host); - - let result = match tokio::time::timeout(Duration::from_millis(deadline_ms), run).await { - Ok(result) => result, - Err(_) => { - cancelled.store(true, Ordering::Release); - Err(RuntimeError::DeadlineExceeded) - } - }; - - let result = match result { - Ok(response) if response.payload.len() > self.limits.max_response_bytes => { - Err(RuntimeError::ResponseTooLarge { - max: self.limits.max_response_bytes, - actual: response.payload.len(), - }) - } - other => other, - }; - - capabilities.revoke_all().await; - result - } -} - -#[cfg(any(test, feature = "test-utils"))] -#[derive(Default)] -pub struct EchoGpu; - -#[cfg(any(test, feature = "test-utils"))] -#[async_trait] -impl GpuClient for EchoGpu { - async fn open_model(&self, _tenant_id: &str, model: &str) -> Result { - Ok(model.to_owned()) - } - - async fn infer( - &self, - _tenant_id: &str, - _model_key: &str, - input: Vec, - ) -> Result, RuntimeError> { - Ok(input) - } -} - -#[cfg(any(test, feature = "test-utils"))] -#[derive(Default)] -pub struct ExampleGuest; - -#[cfg(any(test, feature = "test-utils"))] -#[async_trait] -impl GuestEngine for ExampleGuest { - async fn execute( - &self, - request: &InvocationRequest, - host: &HostApi, - ) -> Result { - let model = host.open_model("default").await?; - let payload = host.infer(model, request.payload.clone()).await?; - Ok(InvocationResponse { payload }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn capabilities_are_request_scoped() { - let runtime = InvocationRuntime::new(EchoGpu, ExampleGuest); - let response = runtime - .invoke(InvocationRequest { - invocation_id: "inv-1".into(), - tenant_id: "tenant-a".into(), - payload: b"abc".to_vec(), - deadline_ms_from_now: Some(1000), - }) - .await - .unwrap(); - assert_eq!(response.payload, b"abc"); - } - - #[test] - fn secure_zero_clears_request_bytes() { - let mut bytes = b"sensitive-request".to_vec(); - secure_zero(&mut bytes); - assert!(bytes.iter().all(|byte| *byte == 0)); - } - - #[tokio::test] - async fn rejects_oversized_payloads_before_guest_execution() { - let limits = RuntimeLimits { - max_payload_bytes: 2, - ..RuntimeLimits::default() - }; - let runtime = InvocationRuntime::with_limits(EchoGpu, ExampleGuest, limits); - let error = runtime - .invoke(InvocationRequest { - invocation_id: "inv-1".into(), - tenant_id: "tenant-a".into(), - payload: vec![1, 2, 3], - deadline_ms_from_now: Some(1000), - }) - .await - .unwrap_err(); - assert!(matches!(error, RuntimeError::PayloadTooLarge { .. })); - } -}