diff --git a/AGENTS.md b/AGENTS.md index fe09f53..4b939cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,44 @@ Runa (short for **Rune Actors**) is an experimental Rust project whose long-term goal is a persistent, live programming world built around isolated Rune actors. -The current workspace contains a minimal Rust executable and a Rune adapter -scaffold; Rune integration and the actor runtime are not implemented yet. +The current workspace contains Rune 0.14.2 feasibility experiments and a minimal +single-worker actor runtime. Verified capabilities and unresolved limitations +are in [docs/rune-feasibility.md](docs/rune-feasibility.md) and +[docs/runtime.md](docs/runtime.md). The architecture, design goals, constraints, and implementation roadmap are documented in [docs/architecture.md](docs/architecture.md). Treat proposed capabilities as plans, not existing behavior. +## Agent rules + +- Remove placeholders that do not apply. For project details you cannot + determine reliably, ask the user rather than guessing; if they are + unavailable, leave the placeholders clearly marked. +- Never push changes unless explicitly told to. +- After changing files, run the project's formatter using its configured + settings. Do not override project formatting configuration or invoke a + formatter directly on individual files unless project guidance says to. +- Use conventional commits with the types used by this project. Use `!` and a + `BREAKING CHANGE:` footer for breaking API or behavior changes. +- End commit messages with a co-author trailer appropriate to the agent and + product. For example: + `Co-Authored-By: `. +- Inform the user about commits and other Git changes, including commit + messages. +- Commit changes after each step unless the user says not to or a project + no-commit marker such as `.NOCOMMIT` exists in the repository root. + - Changes affecting compilation or tests may only be committed after the + relevant build, lint, and test checks pass. + - Documentation, CI, editor, and deployment configuration changes may not + require code checks; follow the project's validation requirements. + - Check the current branch before every commit. +- Commit formatting-only changes separately when formatting reformats code + unrelated to the functional changes. +- Add user-visible changes to the project's `Unreleased` changelog section + using its established format, creating that section if needed. + + ## Architecture and constraints - Keep actor identity, mailbox, private state, behavior implementation, and @@ -30,7 +61,9 @@ proposed capabilities as plans, not existing behavior. - `Cargo.toml`: Rust workspace definition. - `crates/runa/`: current executable crate and entry point. -- `crates/runa-rune/`: Phase 0 Rune compilation and execution adapter scaffold. +- `crates/runa-rune/`: Phase 0 Rune compilation and execution experiments. +- `crates/runa-actor/`: actor value and lifecycle contracts. +- `crates/runa-runtime/`: single-worker scheduling and in-memory transitions. - `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario. - `docs/decisions/`: architectural decisions. - `runa-std/`: default Rune scripts for an image. @@ -55,7 +88,7 @@ mise run pre-commit failure. Formatting uses the repository's Tombi and rustfmt configurations. Clippy checks all workspace targets and treats warnings as errors. -The current project is a minimal executable and may not yet have tests. +The workspace tests cover execution probes and actor runtime invariants. ## Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd371d..503100b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ All notable changes to this project will be documented in this file. ### Added +- Typed, instruction-sliced Rune invocations with preserved async execution, + wake-driven resumption, and cancellation tests. +- Minimal single-worker actor runtime with bounded FIFO mailboxes, private + integer state, structured messages, immutable implementation IDs, explicit + message effects, inspection, lifecycle events, stop, and restart. +- Runnable two-actor counter demonstration and tests for runaway fairness, + pending-message isolation, failures, backpressure, and all-or-nothing effects. +- Execution-model decision and Phase 1 runtime documentation. + +- Runnable Phase 0 experiment pinned to Rune 0.14.2, covering script execution, + native calls, async suspension, instruction budgets, frame inspection, + stepping, and compiled-code sharing across isolated VMs and threads. +- Tests for feasibility results, invalid source, runtime errors, independent + stacks, and cancellation by dropping suspended execution. +- Rune feasibility report with measured timings, shallow object sizes, + limitations, and a proposed Phase 1 execution model. + - Initial Rust workspace with the `runa` binary and a `runa-rune` library scaffold for Phase 0. Rune integration is not implemented yet. - Project README and agent guidelines. diff --git a/Cargo.lock b/Cargo.lock index d4507a6..41f49db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,10 +2,735 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1be81b31c9ce4caf7f3178eeb90662bb1864d3ad363ec6bdada3201adff19c37" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "musli" +version = "0.0.124" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b310b280353d9e1c92861820321f8742b02666acaf984a29cd8946965444384" +dependencies = [ + "loom", + "musli-core", + "serde", + "simdutf8", +] + +[[package]] +name = "musli-core" +version = "0.0.124" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00e227a374e92550ce2eb5002ae116e02a43926d7243c95997138406ae4e157" +dependencies = [ + "musli-macros", +] + +[[package]] +name = "musli-macros" +version = "0.0.124" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7427c9aa85c882cd4dbe712d2fcdc511db05d595f7787e6747c90cd7d67efc4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "runa" version = "0.0.0" +dependencies = [ + "runa-actor", + "runa-rune", + "runa-runtime", +] + +[[package]] +name = "runa-actor" +version = "0.0.0" [[package]] name = "runa-rune" version = "0.0.0" +dependencies = [ + "rune", +] + +[[package]] +name = "runa-runtime" +version = "0.0.0" +dependencies = [ + "runa-actor", + "runa-rune", + "rune", +] + +[[package]] +name = "rune" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a577a0dc4b3e4cfe89247325b14b5f30096ddc82fa51fffb1d823fbc85b410" +dependencies = [ + "anyhow", + "codespan-reporting", + "futures-core", + "futures-util", + "itoa", + "memchr", + "musli", + "num", + "once_cell", + "pin-project", + "rune-alloc", + "rune-core", + "rune-macros", + "rune-tracing", + "ryu", + "serde", + "syntree", + "unicode-ident", +] + +[[package]] +name = "rune-alloc" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702abcb136e7e4ff61c29006f018ab535728388b950bfbd50a0a66d7a2db1ff6" +dependencies = [ + "ahash", + "pin-project", + "rune-alloc-macros", + "serde", +] + +[[package]] +name = "rune-alloc-macros" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fa2ddc4ebb390a257f05659081e5f4a1419ca65f0c7a3f4b02547860dd93f4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rune-core" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fab02420e2189ad2d8d9efceca5319782873eb547c61b68732746cc161547aa" +dependencies = [ + "musli", + "rune-alloc", + "serde", + "twox-hash", +] + +[[package]] +name = "rune-macros" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d314f34d8b30569189d90ac0f96eeedf629492c10d18146b40cfb76bd3fd2d52" +dependencies = [ + "proc-macro2", + "quote", + "rune-core", + "syn 2.0.119", +] + +[[package]] +name = "rune-tracing" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5b903935dfcb0ab9eec395f4c2e04b174448bb8d1fd35ef5181ad67140239a9" +dependencies = [ + "rune-tracing-macros", +] + +[[package]] +name = "rune-tracing-macros" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75193c702bddd8a5b74b1f9a5a992cef3c6b2df2909d6f0bb0f4b0f3ec8a2f66" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syntree" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c99c9cda412afe293a6b962af651b4594161ba88c1affe7ef66459ea040a06" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml index 7c6228c..b288b4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,5 +2,7 @@ resolver = "3" members = [ "crates/runa", + "crates/runa-actor", "crates/runa-rune", + "crates/runa-runtime", ] diff --git a/README.md b/README.md index efaefca..c730372 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,15 @@ image. ## Status -Runa is at an early prototype stage. The current repository contains a minimal -Rust executable and a scaffold for the Rune adapter library; the actor runtime, -Rune integration, graphical world, and -development API described in the architecture spec are planned, not yet -implemented. +Runa is at an early prototype stage. The executable runs a Phase 0 feasibility +experiment using Rune 0.14.2: script execution, native calls, async suspension, +instruction budgets, stepping, and compiled-code sharing. It also runs a minimal +single-worker actor demonstration with bounded mailboxes and explicit message +effects. Persistence, the graphical world, and development API are not +implemented yet. -The first milestone is to evaluate Rune's embedding, async execution, -interruptibility, and debugging capabilities before building the actor kernel. +The prototype uses worker-local invocation stacks and cooperative instruction +slices. Native callbacks are not preemptible. ## Design goals @@ -39,7 +40,9 @@ Run the current executable from the repository root: cargo run -p runa ``` -At this stage, it prints `Hello, world!`. +It prints the feasibility results and local compilation/VM creation measurements. +See [the feasibility report](docs/rune-feasibility.md) for verified capabilities, +limitations, and [the runtime guide](docs/runtime.md) for the two-actor demo. ## Development @@ -68,7 +71,9 @@ test task runs `cargo test --workspace`. ├── Cargo.toml ├── crates/ │ ├── runa/ # Main binary -│ └── runa-rune/ # Phase 0 Rune adapter scaffold +│ ├── runa-actor/ # Actor value contracts +│ ├── runa-rune/ # Phase 0 Rune experiments +│ └── runa-runtime/ # Single-worker actor prototype ├── docs/ │ ├── architecture.md │ └── decisions/ @@ -89,5 +94,5 @@ is reserved for system and end-to-end tests; it is not automatically discovered by Cargo. Add an explicit runner when those tests are introduced, keeping Rust crate integration tests within each crate. -Only the Phase 0 crates are scaffolded. The remaining crates in the spec will +Only the Phase 0 and minimal Phase 1 crates are present. Remaining crates will be added as their functionality is implemented. diff --git a/crates/runa-actor/Cargo.toml b/crates/runa-actor/Cargo.toml new file mode 100644 index 0000000..463ace2 --- /dev/null +++ b/crates/runa-actor/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "runa-actor" +version = "0.0.0" +edition = "2024" + +[dependencies] diff --git a/crates/runa-actor/src/lib.rs b/crates/runa-actor/src/lib.rs new file mode 100644 index 0000000..0545d08 --- /dev/null +++ b/crates/runa-actor/src/lib.rs @@ -0,0 +1,46 @@ +//! Value contracts for the single-worker actor prototype. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct ActorId(pub u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct ImplementationId(pub u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MailboxId(pub u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Message { + pub kind: i64, + pub value: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Status { + Idle, + Running, + Awaiting, + Stopped, + Failed(String), +} + +#[derive(Debug, Clone)] +pub struct ActorSnapshot { + pub id: ActorId, + pub mailbox: MailboxId, + pub implementation: ImplementationId, + pub incarnation: u64, + pub state: i64, + pub status: Status, + pub queued: Vec, + pub reserved: Option, +} + +#[derive(Debug, Clone)] +pub enum Event { + Spawned(ActorId), + Committed(ActorId), + Failed(ActorId, String), + Stopped(ActorId), + Restarted(ActorId), +} diff --git a/crates/runa-rune/Cargo.toml b/crates/runa-rune/Cargo.toml index 31924e5..d2e7f6c 100644 --- a/crates/runa-rune/Cargo.toml +++ b/crates/runa-rune/Cargo.toml @@ -4,3 +4,4 @@ version = "0.0.0" edition = "2024" [dependencies] +rune = "=0.14.2" diff --git a/crates/runa-rune/src/execution.rs b/crates/runa-rune/src/execution.rs new file mode 100644 index 0000000..1a2b8e1 --- /dev/null +++ b/crates/runa-rune/src/execution.rs @@ -0,0 +1,215 @@ +use std::cell::Cell; +use std::future::{Future, poll_fn}; +use std::num::NonZeroUsize; +use std::pin::Pin; +use std::rc::Rc; +use std::task::{Context, Poll}; + +use rune::runtime::{Args, VmError}; +use rune::{FromValue, Vm}; + +use crate::ExperimentResult; + +/// A worker-local execution slice; native callbacks must remain nonblocking. +#[derive(Debug)] +pub enum Slice { + Completed(T), + Awaiting, + BudgetExhausted, + Failed(Box), +} + +type ExecutionFuture = Pin>>>; + +pub struct Invocation { + future: Option>, + stepped: Rc>, +} + +impl Invocation { + pub fn start(mut vm: Vm, name: &str, args: impl Args) -> Result { + let mut execution = vm.execute([name], args)?.into_owned(); + let stepped = Rc::new(Cell::new(false)); + let marker = Rc::clone(&stepped); + let future = Box::pin(async move { + loop { + if let Some(value) = execution.async_step().await.into_result()? { + return Ok(rune::from_value(value)?); + } + marker.set(true); + let mut yielded = false; + poll_fn(|cx| { + if yielded { + Poll::Ready(()) + } else { + yielded = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + }) + .await; + } + }); + Ok(Self { + future: Some(future), + stepped, + }) + } + + /// Bound VM steps per call, preserving the exact pending async step. + pub fn poll_slice(&mut self, cx: &mut Context<'_>, budget: NonZeroUsize) -> Slice { + let Some(future) = self.future.as_mut() else { + return Slice::Failed("invocation has already finished".into()); + }; + for _ in 0..budget.get() { + self.stepped.set(false); + match future.as_mut().poll(cx) { + Poll::Ready(result) => { + self.future = None; + return match result { + Ok(value) => Slice::Completed(value), + Err(error) => Slice::Failed(error), + }; + } + Poll::Pending if !self.stepped.get() => return Slice::Awaiting, + Poll::Pending => {} + } + } + Slice::BudgetExhausted + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::CompiledProgram; + use rune::Module; + use std::sync::{Arc, Mutex}; + use std::task::{Wake, Waker}; + + use std::sync::atomic::{AtomicBool, Ordering}; + + struct Signal { + waker: Mutex>, + ready: AtomicBool, + dropped: AtomicBool, + } + struct PendingOperation(Arc); + + impl Future for PendingOperation { + type Output = i64; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if self.0.ready.load(Ordering::SeqCst) { + Poll::Ready(7) + } else { + *self.0.waker.lock().unwrap() = Some(cx.waker().clone()); + Poll::Pending + } + } + } + + impl Drop for PendingOperation { + fn drop(&mut self) { + self.0.dropped.store(true, Ordering::SeqCst); + } + } + + struct WakeCounter(std::sync::atomic::AtomicUsize); + impl Wake for WakeCounter { + fn wake(self: Arc) { + self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } + + #[test] + fn budgets_await_wake_resume_and_cancel() -> ExperimentResult<()> { + let signal = Arc::new(Signal { + waker: Mutex::new(None), + ready: AtomicBool::new(false), + dropped: AtomicBool::new(false), + }); + let mut module = Module::new(); + let native_signal = Arc::clone(&signal); + module + .function("wait", move || PendingOperation(Arc::clone(&native_signal))) + .build()?; + let program = CompiledProgram::compile_with_module( + "pub async fn handle() { let n = 0; while n < 10 { n += 1; } let x = wait().await; while n < 20 { n += 1; } n + x }", + module, + )?; + let counter = Arc::new(WakeCounter(std::sync::atomic::AtomicUsize::new(0))); + let waker = Waker::from(Arc::clone(&counter)); + let mut cx = Context::from_waker(&waker); + let budget = NonZeroUsize::new(1).unwrap(); + let mut invocation = Invocation::::start(program.vm(), "handle", ())?; + assert!(matches!( + invocation.poll_slice(&mut cx, budget), + Slice::BudgetExhausted + )); + for _ in 0..1_000 { + if matches!(invocation.poll_slice(&mut cx, budget), Slice::Awaiting) { + break; + } + } + let registered = signal + .waker + .lock() + .unwrap() + .take() + .expect("await registered waker"); + assert!(matches!( + invocation.poll_slice(&mut cx, budget), + Slice::Awaiting + )); + signal.ready.store(true, Ordering::SeqCst); + registered.wake(); + assert!(counter.0.load(std::sync::atomic::Ordering::Relaxed) > 0); + let mut completed = false; + let mut exhausted_after_await = false; + for _ in 0..1_000 { + match invocation.poll_slice(&mut cx, budget) { + Slice::Completed(value) => { + assert_eq!(value, 27); + completed = true; + break; + } + Slice::Failed(error) => return Err(error), + Slice::BudgetExhausted => exhausted_after_await = true, + Slice::Awaiting => return Err("ready operation remained pending".into()), + } + } + assert!(completed); + assert!(exhausted_after_await); + + signal.ready.store(false, Ordering::SeqCst); + signal.dropped.store(false, Ordering::SeqCst); + let mut cancelled = Invocation::::start(program.vm(), "handle", ())?; + for _ in 0..1_000 { + if matches!(cancelled.poll_slice(&mut cx, budget), Slice::Awaiting) { + break; + } + } + assert!(!signal.dropped.load(Ordering::SeqCst)); + drop(cancelled); + assert!(signal.dropped.load(Ordering::SeqCst)); + Ok(()) + } + + #[test] + fn errors_and_finished_invocations_have_typed_outcomes() -> ExperimentResult<()> { + let program = CompiledProgram::compile("pub fn handle() { 1 / 0 }")?; + let mut invocation = Invocation::::start(program.vm(), "handle", ())?; + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + invocation.poll_slice(&mut cx, NonZeroUsize::new(100).unwrap()), + Slice::Failed(_) + )); + assert!(matches!( + invocation.poll_slice(&mut cx, NonZeroUsize::new(100).unwrap()), + Slice::Failed(_) + )); + assert!(Invocation::::start(program.vm(), "missing", ()).is_err()); + Ok(()) + } +} diff --git a/crates/runa-rune/src/lib.rs b/crates/runa-rune/src/lib.rs index ff56a89..0cdca27 100644 --- a/crates/runa-rune/src/lib.rs +++ b/crates/runa-rune/src/lib.rs @@ -1,3 +1,267 @@ -//! Rune compilation and execution adapter for Runa. -//! -//! This crate is a scaffold; Rune integration is not implemented yet. +//! Phase 0 experiments against the pinned Rune runtime, not an actor runtime. + +use std::error::Error; +use std::future::{Future, poll_fn}; +use std::mem::size_of; +use std::pin::pin; +use std::sync::Arc; +use std::task::{Context as TaskContext, Poll, Waker}; +use std::time::{Duration, Instant}; + +use rune::runtime::{RuntimeContext, Unit, budget}; +use rune::termcolor::{ColorChoice, StandardStream}; +use rune::{Context, Diagnostics, Module, Source, Sources, Vm}; + +mod execution; +pub use execution::{Invocation, Slice}; + +/// Errors remain visible to the caller; compilation also emits source diagnostics. +pub type ExperimentResult = Result>; + +const SOURCE: &str = include_str!("../../../examples/feasibility.rn"); + +fn native_double(value: i64) -> i64 { + value * 2 +} + +async fn native_suspend(value: i64) -> i64 { + let mut suspended = false; + poll_fn(|cx| { + if suspended { + Poll::Ready(value) + } else { + suspended = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + }) + .await +} + +/// A compiled unit and native context shared by independent execution stacks. +pub struct CompiledProgram { + runtime: Arc, + unit: Arc, +} + +impl CompiledProgram { + pub fn compile(source: &str) -> ExperimentResult { + let mut module = Module::new(); + module.function("native_double", native_double).build()?; + module.function("native_suspend", native_suspend).build()?; + Self::compile_with_module(source, module) + } + + pub fn compile_with_module(source: &str, module: Module) -> ExperimentResult { + let mut context = Context::with_default_modules()?; + context.install(module)?; + + let mut sources = Sources::new(); + sources.insert(Source::memory(source)?)?; + let mut diagnostics = Diagnostics::new(); + let result = rune::prepare(&mut sources) + .with_context(&context) + .with_diagnostics(&mut diagnostics) + .build(); + if !diagnostics.is_empty() { + diagnostics.emit(&mut StandardStream::stderr(ColorChoice::Never), &sources)?; + } + + Ok(Self { + runtime: Arc::new(context.runtime()?), + unit: Arc::new(result?), + }) + } + + pub fn vm(&self) -> Vm { + Vm::new(Arc::clone(&self.runtime), Arc::clone(&self.unit)) + } +} + +#[derive(Debug)] +pub struct FeasibilityReport { + pub rune_result: i64, + pub native_result: i64, + pub async_result: i64, + pub async_suspended: bool, + pub budget_exhausted: bool, + pub resumed_result: i64, + pub inspected_frames: usize, + pub shared_unit: bool, + pub cross_thread_result: i64, + pub compile_time: Duration, + pub vm_creation_time: Duration, + pub vm_shallow_bytes: usize, + pub context_shallow_bytes: usize, + pub unit_shallow_bytes: usize, +} + +/// Run a bounded experiment. No CPU-bound script is executed without a budget. +pub fn run_feasibility() -> ExperimentResult { + let start = Instant::now(); + let program = CompiledProgram::compile(SOURCE)?; + let compile_time = start.elapsed(); + let start = Instant::now(); + let mut vm = program.vm(); + let vm_creation_time = start.elapsed(); + let rune_result = rune::from_value(vm.call(["add"], (20_i64, 22_i64))?)?; + let native_result = rune::from_value(vm.call(["native"], (21_i64,))?)?; + + let mut async_execution = vm.execute(["suspend"], (42_i64,))?; + let mut cx = TaskContext::from_waker(Waker::noop()); + let (async_suspended, async_result) = { + let mut future = pin!(async_execution.async_complete()); + let suspended = future.as_mut().poll(&mut cx).is_pending(); + let value = match future.as_mut().poll(&mut cx) { + Poll::Ready(result) => result.into_result()?, + Poll::Pending => { + return Err("native suspension did not resume on the second poll".into()); + } + }; + (suspended, rune::from_value(value)?) + }; + drop(async_execution); + + let mut execution = vm.execute(["count"], (100_i64,))?; + let error = budget::with(10, || execution.complete()) + .call() + .into_result() + .err() + .ok_or("the finite loop unexpectedly completed within ten instructions")?; + // Rune 0.14.2 does not expose a typed budget-halt discriminator. + let budget_exhausted = error.to_string() == "Halted for unexpected reason `limited`"; + if !budget_exhausted { + return Err(error.into()); + } + let resumed_result = rune::from_value( + budget::with(10_000, || execution.complete()) + .call() + .into_result()?, + )?; + drop(execution); + + let mut inspection = vm.execute(["count"], (3_i64,))?; + let mut inspected_frames = 0; + let mut completed = false; + for _ in 0..1_000 { + inspected_frames = inspected_frames.max(inspection.vm().call_frames().len()); + if inspection.step().into_result()?.is_some() { + completed = true; + break; + } + } + if !completed { + return Err("frame-inspection probe exceeded 1,000 steps".into()); + } + drop(inspection); + + let mut runaway = vm.execute(["runaway"], ())?; + let error = budget::with(100, || runaway.complete()) + .call() + .into_result() + .err() + .ok_or("the infinite loop unexpectedly completed")?; + if error.to_string() != "Halted for unexpected reason `limited`" { + return Err(error.into()); + } + drop(runaway); + + let other = program.vm(); + let shared_unit = Arc::ptr_eq(vm.unit(), other.unit()); + let runtime = Arc::clone(&program.runtime); + let unit = Arc::clone(&program.unit); + let cross_thread_result = std::thread::spawn(move || { + let mut vm = Vm::new(runtime, unit); + vm.call(["add"], (1_i64, 2_i64)) + .map_err(|error| error.to_string()) + .and_then(|value| rune::from_value::(value).map_err(|error| error.to_string())) + }) + .join() + .map_err(|_| "Rune execution worker panicked")? + .map_err(|error| format!("Rune execution worker failed: {error}"))?; + + Ok(FeasibilityReport { + rune_result, + native_result, + async_result, + async_suspended, + budget_exhausted, + resumed_result, + inspected_frames, + shared_unit, + cross_thread_result, + compile_time, + vm_creation_time, + vm_shallow_bytes: size_of::(), + context_shallow_bytes: size_of::(), + unit_shallow_bytes: size_of::(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn feasibility_probes() -> ExperimentResult<()> { + let report = run_feasibility()?; + assert_eq!(report.rune_result, 42); + assert_eq!(report.native_result, 42); + assert!(report.async_suspended); + assert_eq!(report.async_result, 42); + assert!(report.budget_exhausted); + assert_eq!(report.resumed_result, 100); + assert!(report.inspected_frames > 0); + assert!(report.shared_unit); + assert_eq!(report.cross_thread_result, 3); + Ok(()) + } + + #[test] + fn invalid_source_fails() { + assert!(CompiledProgram::compile("pub fn broken( {").is_err()); + } + + #[test] + fn independent_stacks_and_stepping() -> ExperimentResult<()> { + let program = CompiledProgram::compile(SOURCE)?; + let mut first = program.vm(); + let mut second = program.vm(); + let mut execution = first.execute(["add"], (10_i64, 20_i64))?; + assert_eq!( + rune::from_value::(second.call(["add"], (1_i64, 2_i64))?)?, + 3 + ); + for _ in 0..100 { + if let Some(value) = execution.step().into_result()? { + assert_eq!(rune::from_value::(value)?, 30); + return Ok(()); + } + } + Err("addition did not complete within 100 steps".into()) + } + + #[test] + fn dropping_suspended_execution_allows_vm_reuse() -> ExperimentResult<()> { + let program = CompiledProgram::compile(SOURCE)?; + let mut vm = program.vm(); + { + let mut execution = vm.execute(["suspend"], (42_i64,))?; + let mut cx = TaskContext::from_waker(Waker::noop()); + let mut future = pin!(execution.async_complete()); + assert!(future.as_mut().poll(&mut cx).is_pending()); + } + assert_eq!( + rune::from_value::(vm.call(["add"], (2_i64, 3_i64))?)?, + 5 + ); + Ok(()) + } + + #[test] + fn runtime_errors_propagate() -> ExperimentResult<()> { + let program = CompiledProgram::compile("pub fn divide(value) { 1 / value }")?; + assert!(program.vm().call(["divide"], (0_i64,)).is_err()); + Ok(()) + } +} diff --git a/crates/runa-runtime/Cargo.toml b/crates/runa-runtime/Cargo.toml new file mode 100644 index 0000000..f49ffa9 --- /dev/null +++ b/crates/runa-runtime/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "runa-runtime" +version = "0.0.0" +edition = "2024" + +[dependencies] +runa-actor = { path = "../runa-actor" } +runa-rune = { path = "../runa-rune" } + +[dev-dependencies] +rune = "=0.14.2" diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs new file mode 100644 index 0000000..5bb89c2 --- /dev/null +++ b/crates/runa-runtime/src/lib.rs @@ -0,0 +1,486 @@ +//! Single-worker, in-memory actor runtime. No durability or preemption guarantees. + +use std::collections::{BTreeMap, VecDeque}; +use std::num::NonZeroUsize; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::task::{Context, Wake, Waker}; + +use runa_actor::{ActorId, ActorSnapshot, Event, ImplementationId, MailboxId, Message, Status}; +use runa_rune::{CompiledProgram, ExperimentResult, Invocation, Slice}; + +type WireTransition = (i64, Vec<(u64, i64, i64)>); + +struct WakeFlag(AtomicBool); +impl Wake for WakeFlag { + fn wake(self: Arc) { + self.0.store(true, Ordering::SeqCst); + } + + fn wake_by_ref(self: &Arc) { + self.0.store(true, Ordering::SeqCst); + } +} + +struct Actor { + snapshot: ActorSnapshot, + mailbox: VecDeque, + capacity: NonZeroUsize, + active: Option>, + wake: Arc, +} + +#[derive(Default)] +pub struct Runtime { + actors: BTreeMap, + implementations: BTreeMap, + next_actor: u64, + next_implementation: u64, + events: Vec, +} + +impl Runtime { + pub fn publish(&mut self, program: CompiledProgram) -> ExperimentResult { + self.next_implementation = self + .next_implementation + .checked_add(1) + .ok_or("implementation IDs exhausted")?; + let id = ImplementationId(self.next_implementation); + self.implementations.insert(id, program); + Ok(id) + } + + pub fn spawn( + &mut self, + implementation: ImplementationId, + state: i64, + capacity: NonZeroUsize, + ) -> ExperimentResult { + if !self.implementations.contains_key(&implementation) { + return Err("unknown implementation".into()); + } + self.next_actor = self + .next_actor + .checked_add(1) + .ok_or("actor IDs exhausted")?; + let id = ActorId(self.next_actor); + self.actors.insert( + id, + Actor { + snapshot: ActorSnapshot { + id, + mailbox: MailboxId(id.0), + implementation, + incarnation: 1, + state, + status: Status::Idle, + queued: Vec::new(), + reserved: None, + }, + mailbox: VecDeque::new(), + capacity, + active: None, + wake: Arc::new(WakeFlag(AtomicBool::new(true))), + }, + ); + self.events.push(Event::Spawned(id)); + Ok(id) + } + + pub fn send(&mut self, id: ActorId, message: Message) -> ExperimentResult<()> { + let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; + if matches!(actor.snapshot.status, Status::Stopped | Status::Failed(_)) { + return Err("actor is not accepting messages".into()); + } + if actor.mailbox.len() + usize::from(actor.snapshot.reserved.is_some()) + >= actor.capacity.get() + { + return Err("mailbox full".into()); + } + actor.mailbox.push_back(message); + Ok(()) + } + + pub fn inspect(&self, id: ActorId) -> ExperimentResult { + let actor = self.actors.get(&id).ok_or("unknown actor")?; + let mut snapshot = actor.snapshot.clone(); + snapshot.queued = actor.mailbox.iter().copied().collect(); + Ok(snapshot) + } + + pub fn stop(&mut self, id: ActorId) -> ExperimentResult<()> { + let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; + actor.active = None; + actor.snapshot.status = Status::Stopped; + self.events.push(Event::Stopped(id)); + Ok(()) + } + + pub fn restart(&mut self, id: ActorId) -> ExperimentResult<()> { + let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; + if !matches!(actor.snapshot.status, Status::Stopped | Status::Failed(_)) { + return Err("stop the actor before restarting".into()); + } + actor.snapshot.incarnation = actor + .snapshot + .incarnation + .checked_add(1) + .ok_or("incarnation exhausted")?; + if let Some(message) = actor.snapshot.reserved.take() { + actor.mailbox.push_front(message); + } + actor.snapshot.status = Status::Idle; + self.events.push(Event::Restarted(id)); + Ok(()) + } + + pub fn drain_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + fn fail(&mut self, id: ActorId, error: String) { + let actor = self.actors.get_mut(&id).expect("scheduled actor exists"); + actor.active = None; + actor.snapshot.status = Status::Failed(error.clone()); + self.events.push(Event::Failed(id, error)); + } + + fn commit(&mut self, id: ActorId, transition: WireTransition) -> ExperimentResult<()> { + let (state, effects) = transition; + let mut counts = BTreeMap::::new(); + for &(target, _, _) in &effects { + let target = ActorId(target); + let actor = self + .actors + .get(&target) + .ok_or("effect targets unknown actor")?; + if matches!(actor.snapshot.status, Status::Stopped | Status::Failed(_)) { + return Err("effect targets unavailable actor".into()); + } + let count = counts.entry(target).or_default(); + *count += 1; + let retained = usize::from(target != id && actor.snapshot.reserved.is_some()); + if actor.mailbox.len() + retained + *count > actor.capacity.get() { + return Err("effect exceeds mailbox capacity".into()); + } + } + let actor = self.actors.get_mut(&id).expect("scheduled actor exists"); + actor.snapshot.state = state; + actor.snapshot.reserved = None; + actor.snapshot.status = Status::Idle; + actor.active = None; + for (target, kind, value) in effects { + self.actors + .get_mut(&ActorId(target)) + .expect("validated target") + .mailbox + .push_back(Message { kind, value }); + } + self.events.push(Event::Committed(id)); + Ok(()) + } + + /// Give each runnable actor one bounded slice. Awaiting actors require a wake. + pub fn tick(&mut self, budget: NonZeroUsize) { + let ids: Vec<_> = self.actors.keys().copied().collect(); + for id in ids { + let actor = self.actors.get_mut(&id).expect("scheduled actor exists"); + if matches!(actor.snapshot.status, Status::Stopped | Status::Failed(_)) { + continue; + } + if actor.active.is_none() { + let Some(message) = actor.mailbox.pop_front() else { + continue; + }; + actor.snapshot.reserved = Some(message); + let program = &self.implementations[&actor.snapshot.implementation]; + match Invocation::start( + program.vm(), + "handle", + (actor.snapshot.state, message.kind, message.value), + ) { + Ok(invocation) => actor.active = Some(invocation), + Err(error) => { + self.fail(id, error.to_string()); + continue; + } + } + actor.snapshot.status = Status::Running; + } + if actor.snapshot.status == Status::Awaiting && !actor.wake.0.load(Ordering::SeqCst) { + continue; + } + actor.wake.0.store(false, Ordering::SeqCst); + let waker = Waker::from(Arc::clone(&actor.wake)); + let mut cx = Context::from_waker(&waker); + let outcome = actor + .active + .as_mut() + .expect("active invocation") + .poll_slice(&mut cx, budget); + match outcome { + Slice::Completed(transition) => { + if let Err(error) = self.commit(id, transition) { + self.fail(id, error.to_string()); + } + } + Slice::Awaiting => actor.snapshot.status = Status::Awaiting, + Slice::BudgetExhausted => actor.snapshot.status = Status::Running, + Slice::Failed(error) => self.fail(id, error.to_string()), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capacity(n: usize) -> NonZeroUsize { + NonZeroUsize::new(n).unwrap() + } + + #[test] + fn two_actors_isolation_and_effects() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let code = runtime.publish(CompiledProgram::compile( + "pub fn handle(state, kind, value) { if kind == 1 { (state + 1, [(value, 0, 5)]) } else { (state + value, []) } }", + )?)?; + let first = runtime.spawn(code, 0, capacity(4))?; + let second = runtime.spawn(code, 10, capacity(4))?; + runtime.send( + first, + Message { + kind: 1, + value: i64::try_from(second.0)?, + }, + )?; + for _ in 0..100 { + runtime.tick(capacity(10)); + } + assert_eq!(runtime.inspect(first)?.state, 1); + assert_eq!(runtime.inspect(second)?.state, 15); + assert!(runtime.inspect(first)?.reserved.is_none()); + assert!( + runtime + .drain_events() + .iter() + .any(|e| matches!(e, Event::Committed(id) if *id == second)) + ); + Ok(()) + } + + #[test] + fn runaway_does_not_starve_healthy_actor() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let runaway = runtime.publish(CompiledProgram::compile( + "pub fn handle(state, kind, value) { loop {} }", + )?)?; + let healthy = runtime.publish(CompiledProgram::compile( + "pub fn handle(state, kind, value) { (state + value, []) }", + )?)?; + let first = runtime.spawn(runaway, 0, capacity(1))?; + let second = runtime.spawn(healthy, 0, capacity(2))?; + runtime.send(first, Message { kind: 0, value: 1 })?; + runtime.send(second, Message { kind: 0, value: 42 })?; + for _ in 0..100 { + runtime.tick(capacity(5)); + } + assert_eq!(runtime.inspect(first)?.status, Status::Running); + assert_eq!(runtime.inspect(first)?.state, 0); + assert_eq!(runtime.inspect(second)?.state, 42); + assert!(runtime.send(first, Message { kind: 0, value: 2 }).is_err()); + runtime.stop(first)?; + assert_eq!(runtime.inspect(first)?.status, Status::Stopped); + runtime.restart(first)?; + assert_eq!(runtime.inspect(first)?.incarnation, 2); + assert_eq!(runtime.inspect(first)?.queued.len(), 1); + Ok(()) + } + + #[test] + fn invalid_effect_does_not_commit_state_or_partial_sends() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let code = runtime.publish(CompiledProgram::compile( + "pub fn handle(state, kind, value) { (99, [(value, 0, 1), (999, 0, 2)]) }", + )?)?; + let actor = runtime.spawn(code, 5, capacity(4))?; + runtime.send( + actor, + Message { + kind: 0, + value: i64::try_from(actor.0)?, + }, + )?; + for _ in 0..100 { + runtime.tick(capacity(10)); + } + let snapshot = runtime.inspect(actor)?; + assert_eq!(snapshot.state, 5); + assert!(matches!(snapshot.status, Status::Failed(_))); + assert!(snapshot.queued.is_empty()); + assert!(snapshot.reserved.is_some()); + Ok(()) + } + + #[test] + fn awaiting_retains_message_and_state_until_woken() -> ExperimentResult<()> { + use std::future::Future; + use std::pin::Pin; + use std::sync::Mutex; + use std::task::Poll; + + #[derive(Default)] + struct Gate { + ready: AtomicBool, + dropped: AtomicBool, + polls: std::sync::atomic::AtomicUsize, + waker: Mutex>, + } + struct Wait(Arc); + impl Future for Wait { + type Output = i64; + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.0.polls.fetch_add(1, Ordering::SeqCst); + if self.0.ready.load(Ordering::SeqCst) { + Poll::Ready(7) + } else { + *self.0.waker.lock().unwrap() = Some(cx.waker().clone()); + Poll::Pending + } + } + } + impl Drop for Wait { + fn drop(&mut self) { + self.0.dropped.store(true, Ordering::SeqCst); + } + } + + let gate = Arc::new(Gate::default()); + let native_gate = Arc::clone(&gate); + let mut module = rune::Module::new(); + module + .function("wait", move || Wait(Arc::clone(&native_gate))) + .build()?; + let mut runtime = Runtime::default(); + let code = runtime.publish(CompiledProgram::compile_with_module( + "pub async fn handle(state, kind, value) { if kind == 1 { let x = wait().await; (state + x, []) } else { (state + value, []) } }", + module, + )?)?; + let actor = runtime.spawn(code, 10, capacity(2))?; + runtime.send(actor, Message { kind: 1, value: 0 })?; + runtime.send(actor, Message { kind: 0, value: 3 })?; + for _ in 0..100 { + runtime.tick(capacity(5)); + } + let pending = runtime.inspect(actor)?; + assert_eq!(pending.status, Status::Awaiting); + assert_eq!(pending.state, 10); + assert_eq!(pending.queued.len(), 1); + assert_eq!(pending.reserved, Some(Message { kind: 1, value: 0 })); + let polls = gate.polls.load(Ordering::SeqCst); + for _ in 0..10 { + runtime.tick(capacity(5)); + } + assert_eq!(gate.polls.load(Ordering::SeqCst), polls); + gate.ready.store(true, Ordering::SeqCst); + gate.waker + .lock() + .unwrap() + .take() + .expect("registered wake") + .wake(); + for _ in 0..100 { + runtime.tick(capacity(5)); + } + let completed = runtime.inspect(actor)?; + assert_eq!(completed.state, 20); + assert_eq!(completed.status, Status::Idle); + assert!(completed.reserved.is_none()); + assert!(completed.queued.is_empty()); + + gate.ready.store(false, Ordering::SeqCst); + gate.dropped.store(false, Ordering::SeqCst); + runtime.send(actor, Message { kind: 1, value: 0 })?; + for _ in 0..100 { + runtime.tick(capacity(5)); + } + assert_eq!(runtime.inspect(actor)?.status, Status::Awaiting); + runtime.stop(actor)?; + assert!(gate.dropped.load(Ordering::SeqCst)); + runtime.restart(actor)?; + let restarted = runtime.inspect(actor)?; + assert_eq!(restarted.mailbox, pending.mailbox); + assert_eq!(restarted.state, 20); + assert_eq!(restarted.incarnation, 2); + assert_eq!(restarted.queued.len(), 1); + Ok(()) + } + + #[test] + fn mailbox_capacity_and_runtime_failures_are_explicit() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let code = runtime.publish(CompiledProgram::compile( + "pub fn handle(state, kind, value) { (state / value, []) }", + )?)?; + let actor = runtime.spawn(code, 5, capacity(1))?; + runtime.send(actor, Message { kind: 0, value: 0 })?; + assert!(runtime.send(actor, Message { kind: 0, value: 1 }).is_err()); + assert!(runtime.inspect(ActorId(999)).is_err()); + for _ in 0..100 { + runtime.tick(capacity(5)); + } + assert!(matches!(runtime.inspect(actor)?.status, Status::Failed(_))); + assert_eq!(runtime.inspect(actor)?.state, 5); + assert!( + runtime + .drain_events() + .iter() + .any(|e| matches!(e, Event::Failed(id, _) if *id == actor)) + ); + Ok(()) + } + + #[test] + fn aggregate_self_sends_respect_capacity_without_partial_commit() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let code = runtime.publish(CompiledProgram::compile( + "pub fn handle(state, kind, value) { (99, [(value, 0, 1), (value, 0, 2)]) }", + )?)?; + let actor = runtime.spawn(code, 5, capacity(1))?; + runtime.send( + actor, + Message { + kind: 0, + value: i64::try_from(actor.0)?, + }, + )?; + for _ in 0..100 { + runtime.tick(capacity(5)); + } + let snapshot = runtime.inspect(actor)?; + assert!(matches!(snapshot.status, Status::Failed(_))); + assert_eq!(snapshot.state, 5); + assert!(snapshot.queued.is_empty()); + assert!(snapshot.reserved.is_some()); + Ok(()) + } + + #[test] + fn invalid_transition_shape_fails_without_committing() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let code = runtime.publish(CompiledProgram::compile( + "pub fn handle(state, kind, value) { 99 }", + )?)?; + let actor = runtime.spawn(code, 5, capacity(1))?; + runtime.send(actor, Message { kind: 0, value: 1 })?; + for _ in 0..100 { + runtime.tick(capacity(5)); + } + let snapshot = runtime.inspect(actor)?; + assert!(matches!(snapshot.status, Status::Failed(_))); + assert_eq!(snapshot.state, 5); + assert!(snapshot.reserved.is_some()); + Ok(()) + } +} diff --git a/crates/runa/Cargo.toml b/crates/runa/Cargo.toml index 0db6b02..a4f6ce1 100644 --- a/crates/runa/Cargo.toml +++ b/crates/runa/Cargo.toml @@ -4,3 +4,6 @@ version = "0.0.0" edition = "2024" [dependencies] +runa-actor = { path = "../runa-actor" } +runa-rune = { path = "../runa-rune" } +runa-runtime = { path = "../runa-runtime" } diff --git a/crates/runa/src/main.rs b/crates/runa/src/main.rs index e7a11a9..8cc2743 100644 --- a/crates/runa/src/main.rs +++ b/crates/runa/src/main.rs @@ -1,3 +1,37 @@ -fn main() { - println!("Hello, world!"); +use std::num::NonZeroUsize; + +use runa_actor::Message; +use runa_rune::CompiledProgram; +use runa_runtime::Runtime; + +fn main() -> runa_rune::ExperimentResult<()> { + let report = runa_rune::run_feasibility()?; + println!("Runa Phase 0 — Rune 0.14.2 feasibility"); + println!("{report:#?}"); + let mut runtime = Runtime::default(); + let implementation = runtime.publish(CompiledProgram::compile(include_str!( + "../../../runa-std/actors/counter.rn" + ))?)?; + let capacity = NonZeroUsize::new(8).expect("nonzero mailbox capacity"); + let sender = runtime.spawn(implementation, 0, capacity)?; + let receiver = runtime.spawn(implementation, 10, capacity)?; + runtime.send( + sender, + Message { + kind: 1, + value: i64::try_from(receiver.0)?, + }, + )?; + for _ in 0..100 { + runtime.tick(NonZeroUsize::new(16).expect("nonzero slice budget")); + } + let sender = runtime.inspect(sender)?; + let receiver = runtime.inspect(receiver)?; + if sender.state != 1 || receiver.state != 15 { + return Err("two-actor demonstration did not complete as expected".into()); + } + println!("Runa Phase 1 — two-actor message exchange"); + println!("Sender: {sender:#?}"); + println!("Receiver: {receiver:#?}"); + Ok(()) } diff --git a/docs/decisions/0001-worker-local-execution.md b/docs/decisions/0001-worker-local-execution.md new file mode 100644 index 0000000..da93c5d --- /dev/null +++ b/docs/decisions/0001-worker-local-execution.md @@ -0,0 +1,51 @@ +# 0001: Worker-local execution with instruction slices + +Status: Accepted for the Phase 1 prototype + +## Decision + +Share immutable Rune 0.14.2 compiled units and native contexts, but create an +independent VM stack for each invocation. Keep invocations and Rune values on +one worker. No actor-specific state lives in a shared context or native closure. + +Use `VmExecution::async_step` inside an owned, pinned invocation future. +Preserve that future across awaits; cancelling an individual `async_step` +future and creating another could lose the pending await. + +Yield to the host after each completed step. `Invocation::poll_slice` executes +at most its configured number of step polls and returns one of: + +- `Completed`: the converted result is ready. +- `Awaiting`: a native async operation is pending. +- `BudgetExhausted`: the host slice is consumed; the invocation is resumable. +- `Failed`: execution or result conversion failed. + +This boundary does not inspect Rune error text. The older Phase 0 budget probe +still checks the pinned budget diagnostic as a compatibility experiment. + +The single-worker runtime visits each runnable actor once per tick. Each actor +has at most one reserved message and one invocation. Awaiting actors are +repolled after a wake, not continually busy-polled. + +## Alternatives + +Rune's aggregate instruction budget is cheaper than one-step scheduling but +its typed exhaustion discriminator is private in 0.14.2. Parsing its display +text is not suitable for runtime policy. An upstream typed slicing API could +replace stepping later, after measurement. + +Moving active executions between workers adds thread-safety obligations not +needed by this prototype. No execution-adapter trait or shared core crate is +introduced without a concrete consumer. + +## Consequences + +This is bounded cooperative VM scheduling, not wall-clock preemption. +One Rune instruction can call native code; blocking callbacks or expensive +native operations can still monopolize a worker. Native callbacks must be +reviewed for nonblocking behavior and cancellation. + +Tests establish fairness for pure Rune runaway loops, budgets before and after +awaiting, wake-driven resumption, and local-future cleanup on cancellation. +They do not establish safe interruption of arbitrary external I/O, heap limits, +source debugging, or fairness across real worker threads. diff --git a/docs/rune-feasibility.md b/docs/rune-feasibility.md new file mode 100644 index 0000000..7283bc1 --- /dev/null +++ b/docs/rune-feasibility.md @@ -0,0 +1,109 @@ +# Rune feasibility: Phase 0 + +## Scope and reproduction + +This experiment pins Rune to **0.14.2**. It is not an actor runtime or a +production scheduler. Run from the repository root: + +```sh +cargo run -p runa +mise run pre-commit +``` + +The binary compiles [feasibility.rn](../examples/feasibility.rn) once and reports +the results. Tests live in [runa-rune](../crates/runa-rune/src/lib.rs). +Compilation emits source diagnostics; compilation and runtime failures reach +the caller and cause the binary to exit unsuccessfully. + +## Verified results + +| Probe | Result | +| --- | --- | +| Rune function | `add(20, 22)` returns 42 | +| Native Rust function | Rune calls `native_double(21)` and returns 42 | +| Async suspension | The first poll returns `Pending`; the second returns 42 | +| Finite instruction budget | Counting to 100 exceeds 10 instructions; the same execution resumes with a larger budget and returns 100 | +| CPU-bound runaway | `loop {}` returns control after exhausting a 100-instruction budget | +| Frame inspection | Instruction stepping observes an active nested call frame | +| Instruction stepping | Addition completes within 100 calls to `VmExecution::step` | +| Compiled-code reuse | Two VMs reference the same `Arc` but execute with independent stacks | +| Cross-thread reuse | Shared `Unit` and `RuntimeContext` move to a worker which constructs its own VM and returns 3 | +| Cancellation | Dropping a pending execution permits subsequent use of its VM | +| Failure paths | Invalid Rune source and division by zero produce errors | + +The async native function is deliberately deterministic: it returns `Pending` +once, requests a wake, and completes on its next poll. The experiment manually +polls it, using a no-op waker; it does not implement an executor or prove +integration with real I/O, timers, or a multithreaded async scheduler. + +## Measurements + +One Linux x86-64 debug-build run on 2026-11-01 measured: + +| Measurement | Observed value | +| --- | --- | +| Context creation, module registration, source compilation, runtime construction | 60.54 ms | +| One empty VM construction | 7.71 microseconds | +| `size_of::()` | 88 bytes | +| `size_of::()` | 96 bytes | +| `size_of::()` | 224 bytes | + +These are observations, not performance guarantees. The sizes are **shallow +Rust object sizes**, excluding heap allocations, bytecode, stack capacity, +native modules, reference-counted values, and allocator overhead. Total +per-actor memory and loaded-context heap cost remain unmeasured; do not use +these numbers to claim actor capacity. Timings vary with build mode and host. + +## API findings and limitations + +The implementation was checked against the downloaded 0.14.2 source: + +- `Context`, `Module::function(...).build()`, `Sources`, `Diagnostics`, and + `rune::prepare` support embedding and native registration. +- `VmExecution::async_complete` supports asynchronous invocation. A suspended + invocation remains live; awaiting is not a state commit. +- `runtime::budget::with` enforces VM instruction accounting. A finite + execution can resume after exhaustion without rewriting its frames. +- Budgeting does **not** interrupt blocking native callbacks or impose a + wall-clock deadline. Native operations need explicit cancellation and + resource limits. No preemptive scheduling or hostile-code sandbox is claimed. +- Rune's budget-halt error enums are private in this version. The experiment + checks its exact pinned diagnostic, ``Halted for unexpected reason `limited` ``, + and rejects any other error. This is a feasibility probe, not a recommended + long-term scheduler API; avoid depending on display strings in the kernel. +- `VmExecution::step`, `Vm::ip`, `Vm::call_frames`, `Vm::stack`, and + `Unit::debug_info` expose building blocks for debugging. Only stepping and + frame presence are tested here. Source breakpoints, variable inspection, + and source-level stepping are not implemented. +- Sharing immutable context and compiled code works with thread-local VMs. + The test does not move an active VM, execution, or Rune value between threads. +- Dropping an execution cancels this controlled local future. Cancellation of + external I/O and rollback of native side effects are not established. + +## Proposed Phase 1 direction + +The [worker-local execution decision](decisions/0001-worker-local-execution.md) +now implements this direction using owned asynchronous instruction stepping, +typed slice outcomes, and wake-driven scheduling. Follow-up tests cover budgets +before/after a genuinely pending operation, wake/resume, cancellation cleanup, +and pure-Rune runaway fairness. See [the runtime guide](runtime.md). + +Use immutable compiled units and native contexts shared by independent actor +execution stacks. Keep Rune values and active execution on their owning worker +until their cross-thread contracts are explicitly tested. + +Let the Rust runtime own actor IDs, mailboxes, committed state, and sequential +invocation scheduling. Keep mutable state explicit at the handler boundary; +never use shared VM stacks as actor state. Continue to reserve messages until +their transition completes, including across awaits. + +The original follow-up requirement was to define a typed slice outcome +(`Completed`, `Awaiting`, `BudgetExhausted`, `Failed`) at the Rune adapter +boundary. Instruction stepping now supplies that boundary without parsing +errors. An upstream bulk-slicing API remains a possible performance improvement; +native calls still do not have a guaranteed bounded execution time. + +Phase 0's basic running-example criterion is met, but heap profiling, real +async I/O cancellation, active execution +thread-safety, and richer debugger mapping remain open. Resolve these as +explicit follow-up experiments before promising production runtime guarantees. diff --git a/docs/runtime.md b/docs/runtime.md new file mode 100644 index 0000000..2d9af3a --- /dev/null +++ b/docs/runtime.md @@ -0,0 +1,72 @@ +# Phase 1 actor prototype + +Run the feasibility probes and two-actor demonstration with: + +```sh +cargo run -p runa +mise run pre-commit +``` + +The executable loads [counter.rn](../runa-std/actors/counter.rn), spawns two +actors sharing one implementation, and sends a structured message. The sender +commits state 1 and sends a message that moves the receiver from state 10 to 15. + +## Current contracts + +- `runa-actor` owns actor, mailbox and implementation IDs, structured messages, + snapshots, statuses and lifecycle events. +- `runa-rune` owns compilation and typed bounded invocation execution. +- `runa-runtime` owns implementation registration, queues, private committed + state, scheduling, message effects and lifecycle coordination. +- `runa` composes the experiment and demonstration. + +An implementation is published once under an ID. Actors reference that exact +implementation. IDs and incarnation counters use checked increments. + +Prototype state is an `i64`; messages contain integer `kind` and `value` +fields. Rune handlers have this deliberately restricted interface: + +```text +handle(state, kind, value) -> (new_state, [(target_actor_id, kind, value), ...]) +``` + +The returned effect vector describes sends; handlers cannot access the runtime +or directly mutate committed state. General schema/versioned values and +capability-bearing contexts are future work. + +## Scheduling and lifecycle + +`tick(nonzero_budget)` gives each runnable actor one bounded slice in ID order. +FIFO queues are bounded, including the reserved message in capacity accounting. +A queued message is reserved when its invocation starts. Subsequent messages +stay queued, including while the actor awaits. A wake marks an awaiting actor +runnable; the caller still needs to drive ticks. + +Successful handlers validate every send target and its aggregate queue +capacity before updating state, acknowledging the reserved message, and +enqueueing effects. Invalid effects fail the actor without partial sends or a +state commit. This all-or-nothing behavior is in-memory only, not a durable +transaction. + +Runtime errors retain the last committed state and reserved message, set an +observable failed status, and emit a failure event. External sends return errors +for unknown, unavailable, or full actors. + +Stopping drops the invocation, retaining committed state, mailbox and reserved +message. Restarting a stopped or failed actor preserves IDs, increments its +incarnation, and puts its reserved message at the front of the queue. Restart +is explicit; there is no automatic poison-message retry or supervision policy. + +## Limits + +This is not yet a full image: no persistent mailboxes, transactional outbox, +supervisors, hot upgrades, API server, GUI, or automatic image bootstrap. +The example default script is bundled through `include_str!`. +Messages are not accepted by stopped/failed actors. + +Native functions must be nonblocking. Dropping a future proves cleanup only +for the controlled test operation, not rollback of arbitrary external effects. +Instruction slices cannot interrupt a native callback. + +The [execution decision](decisions/0001-worker-local-execution.md) records the +scheduling trade-offs. Heap profiling and source-level debugging remain open. diff --git a/examples/feasibility.rn b/examples/feasibility.rn new file mode 100644 index 0000000..b2eb1ac --- /dev/null +++ b/examples/feasibility.rn @@ -0,0 +1,27 @@ +pub fn add(a, b) { + a + b +} + +pub fn native(value) { + native_double(value) +} + +pub async fn suspend(value) { + native_suspend(value).await +} + +fn increment(value) { + value + 1 +} + +pub fn count(limit) { + let value = 0; + while value < limit { + value = increment(value); + } + value +} + +pub fn runaway() { + loop {} +} diff --git a/runa-std/actors/counter.rn b/runa-std/actors/counter.rn new file mode 100644 index 0000000..c6f9101 --- /dev/null +++ b/runa-std/actors/counter.rn @@ -0,0 +1,7 @@ +pub fn handle(state, kind, value) { + if kind == 1 { + (state + 1, [(value, 0, 5)]) + } else { + (state + value, []) + } +}