Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,7 @@ jobs:
java-version: '17'
cache: ${{ runner.environment == 'github-hosted' && 'gradle' || '' }}
cache-dependency-path: kotlin/rutis/**/*.gradle.kts
- name: gradle build (the SDK, the processor, the conformance programs)
- name: gradle build (the SDK, the processor, the testing tool, the conformance programs)
working-directory: kotlin/rutis
run: |
# The downloads first, retried (the runners' networks); then the
Expand All @@ -862,10 +862,28 @@ jobs:
done
./gradlew --no-daemon build
echo "RUTIS_KOTLIN_JAR=$PWD/rutis-conformance/build/libs/rutis-conformance-all.jar" >> "$GITHUB_ENV"
# The other languages, as the far ends of the events conformance.
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm --prefix node/rutis-runtime ci
- uses: actions/setup-python@v5
if: runner.environment == 'github-hosted' || runner.os != 'macOS'
with:
python-version: '3.10'
- run: python3 -m pip install "websockets==15.*"
# Only the Kotlin tests of the contract suites: the other runtimes run
# in their own jobs.
- name: cargo test (the contract suites with Kotlin)
run: cargo test -p rutis-bridge --features testing,kotlin --test runtime_conformance --test value_roundtrip kotlin_
run: cargo test -p rutis-bridge --features testing,websocket,kotlin --test runtime_conformance --test value_roundtrip --test session_matrix kotlin_
# The events conformance with X Kotlin, and Kotlin as Y of the others
# (the written placements on pull requests, a sample in full runs).
- name: cargo test (events conformance with Kotlin)
env:
RUTIS_EVENTS_PLACEMENTS: ${{ needs.changes.outputs.all == 'true' && 'sampled' || 'written' }}
RUTIS_EVENTS_REQUIRE: node,python,kotlin
RUTIS_EVENTS_SETTLE_SCALE: '2'
run: cargo test -p rutis-loader --features node,python,kotlin --test events_conformance
- name: self-hosted runner, end
if: always() && runner.environment == 'self-hosted'
env:
Expand Down
14 changes: 7 additions & 7 deletions crates/rutis-bridge/src/runtime/testing/events.expected.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
{
"about": [
"Unified events conformance (#464): one list both the real runtimes and the SDK testing tools are checked against. Format: docs/design-events-protocol-2026-10-18.en.md §13; the cases of §13.3 come first, then the ones added to cover every rule.",
"Real runtimes: crates/rutis-loader/tests/events_conformance.rs (rows of one rutis host, its runtimes, and a second node). Testing tools, the cases without `tool: false`: node/rutis/test/events-conformance.test.mjs, python/rutis/tests/test_events_conformance.py, go/rutis/rutistest/events_conformance_test.go, bun/rutis-bun/test/events-conformance.test.ts, swift/rutis/Tests/RutisTests/EventsConformanceTests.swift (RutisTest). The file is checked for shape by crates/rutis-bridge/tests/events_expected.rs.",
"Languages (`only` / `except`): rust (the host's Rust plugin), node (a Cordis plugin in the Node runtime), node-leaf (a leaf plugin in the Node runtime), bun, python, go, swift. Fixtures: node/rutis-runtime/test/fixtures/conformance-events{,-e,-f}/ (Cordis) and conformance-events-leaf{,-e,-f}.mjs, bun/rutis-bun/test/fixtures/conformance-events{,-e,-f}.ts, python/rutis/tests/fixtures/conformance_events{,_e,_f}.py, go/rutis/internal/fixtures/plugins/conformance (served by go/rutis/internal/fixtures/runtime; one plugin provides every emitter undeclared, its sync method on the wire `dispatchSync`), swift/rutis/Sources/RutisFixturePlugins/ConformanceEvents.swift (served by RutisConformance; as Go), and the Rust plugin of crates/rutis-loader/tests/events_conformance.",
"Real runtimes: crates/rutis-loader/tests/events_conformance.rs (rows of one rutis host, its runtimes, and a second node). Testing tools, the cases without `tool: false`: node/rutis/test/events-conformance.test.mjs, python/rutis/tests/test_events_conformance.py, go/rutis/rutistest/events_conformance_test.go, bun/rutis-bun/test/events-conformance.test.ts, swift/rutis/Tests/RutisTests/EventsConformanceTests.swift (RutisTest), kotlin/rutis/rutis-conformance/src/test/kotlin/arcships/rutis/conformance/EventsConformanceTest.kt. The file is checked for shape by crates/rutis-bridge/tests/events_expected.rs.",
"Languages (`only` / `except`): rust (the host's Rust plugin), node (a Cordis plugin in the Node runtime), node-leaf (a leaf plugin in the Node runtime), bun, python, go, swift, kotlin (the Kotlin SDK's jvm target). Fixtures: node/rutis-runtime/test/fixtures/conformance-events{,-e,-f}/ (Cordis) and conformance-events-leaf{,-e,-f}.mjs, bun/rutis-bun/test/fixtures/conformance-events{,-e,-f}.ts, python/rutis/tests/fixtures/conformance_events{,_e,_f}.py, go/rutis/internal/fixtures/plugins/conformance (served by go/rutis/internal/fixtures/runtime; one plugin provides every emitter undeclared, its sync method on the wire `dispatchSync`), swift/rutis/Sources/RutisFixturePlugins/ConformanceEvents.swift (served by RutisConformance; as Go), kotlin/rutis/rutis-conformance/src/main/kotlin/arcships/rutis/conformance/plugins/ConformanceEvents.kt (likewise: one plugin provides every emitter undeclared, `dispatchSync`), and the Rust plugin of crates/rutis-loader/tests/events_conformance.",
"Scopes: each scope path is one instance of its group, created once; t2's group is `team`, not `tenant`, so that rows placed in t1 are not copied into t2 (a loader instance copies its group's rows). The rule compares ids, so the group names do not matter to it.",
"Additions to the format of §13.2: a listener's `fails` ({name, message}: its row's apply throws that error, after registering nothing); `typed: \"unit\"` (a Rust unit struct event); `observesDispatch` (a Cordis listener row that also logs `<label>:dispatch:<mode>` from its internal/dispatch for each dispatch of the case's name); the filter `{\"throws\": {name, message}}` (the predicate throws); the step `{\"update\": label}` (the row's config changes, so it registers again); and `expect` on a `load` step. Rust events cross by serde without a declaration, so there is no NoWire case.",
"Expectations, strictly: a step without `throws` or `cancelled` requires every dispatch that ends in it to succeed. `cancelled: true`: the dispatch ends with its emitter's language's cancellation (Python `CancelledError`, Go `Canceled`, Swift `CancellationError`, the Rust fixture's `Cancelled`). `reported` entries ({label, name, filter?}): exactly one §5.6 line about the step's event names that row (`in row <label>`, or the dispatcher's `in runtime <its runtime>` / `in node <peer>`) with that error name; `filter: true` for a filter's line (`rutis: filter of <name> from row <label>`). `logLacks` (and `reported`) are checked again a moment after the rest held. A `$` tag that does not parse is an error of the file. `toolCases`: how many cases each testing tool runs (crates/rutis-bridge/tests/events_expected.rs checks it against the cases; each tool checks it ran that many).",
"Expectations, strictly: a step without `throws` or `cancelled` requires every dispatch that ends in it to succeed. `cancelled: true`: the dispatch ends with its emitter's language's cancellation (Python `CancelledError`, Go `Canceled`, Swift `CancellationError`, Kotlin `CancellationException`, the Rust fixture's `Cancelled`). `reported` entries ({label, name, filter?}): exactly one §5.6 line about the step's event names that row (`in row <label>`, or the dispatcher's `in runtime <its runtime>` / `in node <peer>`) with that error name; `filter: true` for a filter's line (`rutis: filter of <name> from row <label>`). `logLacks` (and `reported`) are checked again a moment after the rest held. A `$` tag that does not parse is an error of the file. `toolCases`: how many cases each testing tool runs (crates/rutis-bridge/tests/events_expected.rs checks it against the cases; each tool checks it ran that many).",
"The rows: plugin `conformance-events`, one row per participant, keyed by its label; its config is the participant as resolved by the runner (name, kind and log filled in). It injects `events-log`, a host service: add(entry) sync; filter(description) sync (logs `filter:<row>` and keeps the description for `filterSees`); wait(entry) async (resolves once entry is logged, for `cancel`). Emitter rows (labelled e or f) load the plugin `conformance-events-e` or `conformance-events-f` (Python `conformance_events_e` / `_f`): the same plugin, declaring only its own `emitter-<label>` (a declared service is exported whether the row provides it or not), with dispatch(spec) async and dispatch_sync(spec) sync (for bail and waterfall-sync). `conformance-events` itself declares nothing."
],
"plugin": "conformance-events",
"languages": ["rust", "node", "node-leaf", "bun", "python", "go", "swift"],
"languages": ["rust", "node", "node-leaf", "bun", "python", "go", "swift", "kotlin"],
"scopes": {"t1": "tenant", "t1/s1": "session", "t2": "team"},
"locations": ["X", "Y", "host", "node"],
"toolCases": {"node-leaf": 53, "bun": 53, "python": 54, "go": 50, "swift": 50},
"toolCases": {"node-leaf": 53, "bun": 53, "python": 54, "go": 50, "swift": 50, "kotlin": 51},
"cases": [
{ "case": "emit/reaches-all", "rule": "5.4 emit", "name": "t/emit", "vary": ["a", "b", "c", "e"],
"listeners": [
Expand Down Expand Up @@ -202,7 +202,7 @@
{"emit": {"from": "X", "mode": "bail", "args": []}, "expect": {"returns": "B", "log": ["a", "b"]}}
] },

{ "case": "bail/async-listener", "rule": "5.4 kinds", "name": "t/bail-async", "vary": ["a", "e"], "except": [{"a": ["go", "swift"], "why": "Go and Swift listeners are synchronous"}],
{ "case": "bail/async-listener", "rule": "5.4 kinds", "name": "t/bail-async", "vary": ["a", "e"], "except": [{"a": ["go", "swift", "kotlin"], "why": "Go, Swift and Kotlin listeners are synchronous"}],
"listeners": [
{"label": "a", "at": "Y", "kind": "async", "does": {"returns": "A"}}
],
Expand Down Expand Up @@ -563,7 +563,7 @@
{"emit": {"from": "host", "mode": "waterfall-sync", "args": [], "terminal": "t"}, "expect": {"throws": {"name": "WaterfallNext", "message": "was already called"}}}
] },

{ "case": "waterfall-sync/async-listener", "rule": "5.4 kinds", "name": "t/wfs-async", "tool": false, "vary": ["a"], "except": [{"a": ["go", "swift"], "why": "Go and Swift listeners are synchronous"}],
{ "case": "waterfall-sync/async-listener", "rule": "5.4 kinds", "name": "t/wfs-async", "tool": false, "vary": ["a"], "except": [{"a": ["go", "swift", "kotlin"], "why": "Go, Swift and Kotlin listeners are synchronous"}],
"listeners": [
{"label": "a", "at": "Y", "kind": "async", "does": {"next": "a"}}
],
Expand Down
6 changes: 4 additions & 2 deletions crates/rutis-bridge/src/runtime/testing/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1430,14 +1430,16 @@ async fn joined(handle: tokio::task::JoinHandle<Outcome>, settles: bool) -> Outc
/// emitter's language (§5.7: "the emitter's call ends with its own
/// language's cancellation"): Python's `asyncio.CancelledError`, Go's
/// `context.Canceled` (an `Error` saying `context canceled` on the wire),
/// and for Rust (a dropped future ends with nothing) the fixture's own
/// `Cancelled`.
/// Kotlin's `CancellationException`, and for Rust (a dropped future ends
/// with nothing) the fixture's own `Cancelled`.
/// JavaScript cannot give up a dispatch.
pub fn cancellation(language: &str) -> Option<Json> {
match language {
"python" => Some(json!({ "name": "CancelledError" })),
// A plain Go error crosses named `Error`.
"go" => Some(json!({ "name": "Error", "message": "context canceled" })),
// A cancelled coroutine's CancellationException.
"kotlin" => Some(json!({ "name": "CancellationException" })),
// Swift's own: a cancelled Task throws CancellationError.
"swift" => Some(json!({ "name": "CancellationError" })),
"rust" => Some(json!({ "name": "Cancelled" })),
Expand Down
15 changes: 13 additions & 2 deletions crates/rutis-bridge/src/runtime/testing/weather.expected.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"about": [
"What calls on the runtime conformance plugin (conformance-weather) return: one list that both the real runtimes and the SDK testing tools are checked against (issue #206, risk A1, Q6.7.2).",
"Real runtimes: rutis_bridge::runtime::testing::runtime (crates/rutis-bridge/tests/session_matrix.rs). Testing tools: node/rutis/test/conformance.test.mjs (@arcships/rutis/testing) and python/rutis/tests/test_conformance.py (rutis.testing), in strict mode, go/rutis/rutistest/conformance_test.go (rutistest) and swift/rutis/Tests/RutisTests/WeatherConformanceTests.swift (RutisTest).",
"Real runtimes: rutis_bridge::runtime::testing::runtime (crates/rutis-bridge/tests/session_matrix.rs). Testing tools: node/rutis/test/conformance.test.mjs (@arcships/rutis/testing) and python/rutis/tests/test_conformance.py (rutis.testing), in strict mode, go/rutis/rutistest/conformance_test.go (rutistest), kotlin/rutis/rutis-conformance/src/test/kotlin/arcships/rutis/conformance/WeatherConformanceTest.kt (arcships.rutis.testing) and swift/rutis/Tests/RutisTests/WeatherConformanceTests.swift (RutisTest).",
"The plugin injects `clock`, whose `now()` returns 0, 1, 2, ... one per call, and provides `service` with `methods`.",
"A call: `call` (method), `args` (JSON; {\"$callback\": ...} is a function the caller passes: \"upper\" returns its argument upper-cased; {\"throws\": {name, message, fields}} throws an error with that name and message and extra `fields`), and what it does: `returns` (a JSON value; keys in `ignore` are not compared; `copied`: it is not the object passed in), `throws` ({} for any error, or the `name` and `message` the caller receives; the thrown error's extra `fields` do not cross) or `exits` (the plugin's process exits with this status).",
"`samples` are values one language makes: the plugin's `samples()` names the ones it has, `sample(name)` returns one; a runtime whose plugin has no `samples` method has none.",
Expand Down Expand Up @@ -109,6 +109,17 @@
{ "sample": "swift/value-constructed", "returns": [{ "$datetime": "2026-10-15T14:30:05.25" }, { "$date": "2026-10-15" }, { "$time": "14:30:05.25" }, { "$decimal": "-12.34" }, { "$uuid": "0f8fad5b-d9cb-469f-a165-70867728950e" }], "why": "Rutis.DateTime, CalendarDate and TimeOfDay, a Decimal and a UUID (in lowercase) arrive as a datetime, date, time, decimal and uuid" },
{ "sample": "swift/nan", "throws": {}, "why": "a Double that is not finite does not cross" },
{ "sample": "swift/value-date-key", "throws": {}, "why": "a Dictionary keyed by Date does not cross: a point in time is no map key (design §5)" },
{ "sample": "swift/error-value", "returns": { "name": "Overdrawn", "message": "out of range" }, "why": "an Error returned as a value arrives as data: its name (its type's) and message (its errorDescription)" }
{ "sample": "swift/error-value", "returns": { "name": "Overdrawn", "message": "out of range" }, "why": "an Error returned as a value arrives as data: its name (its type's) and message (its errorDescription)" },
{ "sample": "kotlin/value-instant", "returns": { "$instant": "2026-10-15T03:22:15.123456789Z" }, "why": "a java.time.Instant (made from 05:22:15.123456789+02:00) arrives as an instant in UTC, to the nanosecond" },
{ "sample": "kotlin/value-kotlin-instant-uuid", "returns": [{ "$instant": "2026-10-15T03:22:15.5Z" }, { "$uuid": "0f8fad5b-d9cb-469f-a165-70867728950e" }], "why": "kotlin.time.Instant and kotlin.uuid.Uuid arrive as an instant and a uuid, as their java counterparts" },
{ "sample": "kotlin/value-data-class", "returns": { "raw": { "$bytes": "aGVsbG8=" }, "at": { "$instant": "2026-10-15T03:22:15Z" }, "n": null }, "why": "a ByteArray and an Instant inside a @Serializable class arrive as bytes and an instant, not as a list of numbers or text (RutisFormat, never JSON's default encoding)" },
{ "sample": "kotlin/value-int-keys", "returns": { "$map": [[1, "a"], [2, "b"]] }, "why": "a Map with Int keys arrives as a map, its keys in order" },
{ "sample": "kotlin/value-set", "returns": { "$set": ["a", "b"] }, "why": "a Set arrives as a set, its elements in order" },
{ "sample": "kotlin/value-bigint", "returns": { "$bigint": "-123456789012345678901" }, "why": "a BigInteger arrives as a big integer" },
{ "sample": "kotlin/value-constructed", "returns": [{ "$datetime": "2026-10-15T14:30:05.25" }, { "$date": "2026-10-15" }, { "$time": "14:30:05.25" }, { "$decimal": "1.10" }, { "$uuid": "0f8fad5b-d9cb-469f-a165-70867728950e" }], "why": "LocalDateTime, LocalDate, LocalTime, BigDecimal and UUID arrive as a datetime, date, time, decimal and uuid; the decimal keeps its trailing zero (its scale)" },
{ "sample": "kotlin/big-long", "returns": { "raw": null, "at": null, "n": { "$bigint": "1152921504606846976" } }, "why": "a Long beyond ±(2^53−1), here inside a @Serializable class, arrives as a big integer (V6): no BigInteger needed" },
{ "sample": "kotlin/big-long-keys", "returns": { "$map": [[1, "one"], [{ "$bigint": "9223372036854775807" }, "max"]] }, "why": "map keys beyond ±(2^53−1) arrive as big integers, the others as numbers (V6)" },
{ "sample": "kotlin/nan", "throws": {}, "why": "a Double that is not finite does not cross" },
{ "sample": "kotlin/exception-value", "returns": { "name": "IllegalStateException", "message": "bad" }, "why": "an exception returned as a value arrives as data: its name and message" }
]
}
2 changes: 1 addition & 1 deletion crates/rutis-bridge/tests/events_expected.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ fn every_case_runs_somewhere() {
let tools: Vec<&str> = expected.tool_cases.keys().map(String::as_str).collect();
assert_eq!(
tools,
["bun", "go", "node-leaf", "python", "swift"],
["bun", "go", "kotlin", "node-leaf", "python", "swift"],
"toolCases"
);
let counted: std::collections::BTreeMap<String, usize> = tools
Expand Down
30 changes: 30 additions & 0 deletions crates/rutis-bridge/tests/session_matrix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,16 @@ enum Runtime {
/// Unix only: Bun on Windows is not a declared platform.
#[cfg(all(unix, feature = "bun"))]
Bun,
/// The Kotlin SDK's jvm target (`Rutis.serve`), started by the generic
/// launcher: a socket path it dials back, and loopback (K-P1; the
/// WebSocket column comes with K-P2).
#[cfg(feature = "kotlin")]
Kotlin,
}

#[cfg(feature = "kotlin")]
mod kotlin;

#[derive(Clone, Copy, Debug, PartialEq)]
enum Via {
/// `fd:3`. Not on Windows, which hands no descriptor over.
Expand Down Expand Up @@ -127,6 +135,12 @@ async fn start(runtime: Runtime, via: Via, dir: &Path, ctx: &Ctx) -> (Arc<Proces
launcher.inherit_fd = false;
(launcher, dir.to_owned(), entry)
}
#[cfg(feature = "kotlin")]
Runtime::Kotlin => (
kotlin::launcher().cwd(dir),
dir.to_owned(),
PathBuf::from("weather"),
),
};
match via {
Via::WebSocket => return (listening(launcher, &anchor, ctx).await, entry),
Expand Down Expand Up @@ -479,3 +493,19 @@ async fn bun_dialing_a_socket_path() {
async fn bun_listening_on_a_websocket() {
semantics(Runtime::Bun, Via::WebSocket).await;
}

#[cfg(feature = "kotlin")]
#[tokio::test(flavor = "multi_thread")]
#[cfg_attr(
windows,
ignore = "socket path column: Windows has no Unix sockets (loopback handover only)"
)]
async fn kotlin_dialing_a_socket_path() {
semantics(Runtime::Kotlin, Via::DialBack).await;
}

#[cfg(feature = "kotlin")]
#[tokio::test(flavor = "multi_thread")]
async fn kotlin_dialing_a_loopback_address() {
semantics(Runtime::Kotlin, Via::Loopback).await;
}
3 changes: 3 additions & 0 deletions crates/rutis-loader/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ go = ["binary", "rutis-bridge/go"]
swift = ["binary", "rutis-bridge/swift"]
# JavaScript/TypeScript leaf plugins in Bun (`bun:<module>` rows).
bun = ["runtimes", "rutis-bridge/bun"]
# Tests only: Kotlin plugins (kotlin/rutis, the jvm target) as rows of a
# runtime started through the generic `Launcher`.
kotlin = ["runtimes", "rutis-bridge/kotlin"]
# What every language shares; enabled by the language features.
runtimes = ["dep:rutis-bridge"]
# What compiled languages (one runtime per program: Go, Swift)
Expand Down
Loading
Loading