How to Make Contributions
September 24, 2026 · View on GitHub
Thank you for considering contributing to this project! We welcome all contributions, whether it's bug reports, feature requests, documentation improvements, or code contributions.
Table of Contents
- Contributing
- Core Principles
- API Design
- Code Conventions
- Module READMEs
- Optimization
- Testing
- Unsafe Boundary
- Effect Implementation Reference
Contributing
Getting Started
Prerequisites
Before you begin, make sure you have the following installed:
- Java 25 (or later): the build refuses to load on an older JDK
- Scala
- Node (Node 24+ to run the experimental WASM target: it defaults to V8's Turboshaft Wasm pipeline)
- sbt (Scala Build Tool)
- Git
Setting Up Your Environment
-
Fork the Repository
- Navigate to the kyo repository and click the Fork button.
-
Clone Your Fork
git clone https://github.com/your-username/kyo.git cd kyo -
Set Up Upstream Remote
git remote add upstream https://github.com/getkyo/kyo.git
Configuring Java Options
The sbt JVM is configured by the checked-in .jvmopts, so no environment setup is needed. The sbt launcher appends .jvmopts after JAVA_OPTS, so a flag set in both takes the .jvmopts value, and an -Xmx in JAVA_OPTS has no effect.
Explanation of Parameters
-Xmx12G: maximum heap of 12GB.-Xss10M: thread stack size of 10MB.-XX:+UseG1GC: the G1 garbage collector.-XX:+UseCompactObjectHeaders: compact object headers (a JDK 25 flag).-XX:MaxMetaspaceSize=2G: metaspace capped at 2GB.-XX:ReservedCodeCacheSize=256M: 256MB reserved for compiled code.-Dfile.encoding=UTF-8: UTF-8 file encoding.
Adjusting These Values
To change the heap for one run, pass it on the command line, which the launcher places after .jvmopts:
sbt -J-Xmx8G 'kyo-coreJVM/test'
JAVA_OPTS still carries flags that .jvmopts does not set, such as -Xms.
How to Build Locally
Windows toolchains
Builds that reach a kyo-ffi module need a C compiler. The FFI plugin reads the
CC environment variable and otherwise invokes cc. If GCC is installed on
Windows as gcc.exe but no cc.exe alias exists, set CC before starting sbt:
$env:CC = "gcc"
Some modules also use MSVC. Install the Visual Studio Build Tools C++ workload,
then run the build from a shell initialized by VsDevCmd.bat so cl.exe,
link.exe, INCLUDE, and LIB are available. For an x86_64 build:
cmd.exe /k '"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\Common7\Tools\VsDevCmd.bat" -arch=amd64'
This opens an initialized Command Prompt. Run powershell inside it if the
commands below are being entered in PowerShell.
Confirm the initialized shell sees both tools with where.exe cl and
where.exe link. See kyo-aeron's contributor guide
for that module's required Windows staging step.
On Windows ARM64, install the native ARM64 Podman package explicitly. Scoop can otherwise select a package for a different architecture:
scoop install -a arm64 podman
podman machine init
podman machine start
podman info
Run podman machine init only when creating the machine. Start an existing
machine with podman machine start.
Local build runner
On native Windows, invoke POSIX build scripts through Git Bash. Use the direct environment for diff-selected verification:
& $env:SHELL -lc 'scripts/build.sh --env direct testDiff JVM JS'
Do not use --env podman testDiff. The container receives a clean source
archive without .git, so the diff selector cannot discover the changed
modules. Use --env podman test when a full Linux container run is needed:
& $env:SHELL -lc 'scripts/build.sh --env podman test JVM JS Native'
Run the following commands to build and test the project locally:
sbt '+kyoJVM/test' # Runs JVM tests
sbt '+kyoJS/test' # Runs JS tests
sbt '+kyoNative/Test/compile' # Compiles Native code
Format before submitting. A bare scalafmtAll or scalafmtCheckAll reaches only the JVM projects, so name all four platform aggregates, as CI does:
sbt kyoJVM/scalafmtAll kyoJS/scalafmtAll kyoNative/scalafmtAll kyoWasm/scalafmtAll scalafmtSbt
CI runs this command and fails if it leaves a diff.
Running CI in Your Fork
Pull requests to the main repository require maintainer approval before CI runs, so checks may not start right away. To get full CI signal on your own schedule, run the same workflows in your fork. They use only GitHub-hosted runners that are free for public repositories and read no repository secrets, so they run unmodified.
-
Enable Actions on your fork. GitHub disables a fork's workflows by default. Open your fork's Actions tab (
https://github.com/<your-user>/<your-fork>/actions) and enable them when prompted. -
Run the
ci-dispatchworkflow. Theciworkflow runs only on pushes and pull requests tomain; the manually runnable one isci-dispatch(.github/workflows/ci-dispatch.yml). In the Actions tab, select ci-dispatch and click Run workflow, then choose your branch. Pull-request CI runs four os poles:linux-x64andlinux-arm64with JVM, JS, Native and Wasm, andwindows-x64andwindows-arm64with JVM and JS. The oses default omitswindows-arm64; set it tolinux-x64 linux-arm64 windows-x64 windows-arm64to match pull-request CI. Leave mode asfullfor a complete run, or set it todiffto test only the modules your branch changed, which is what pull-request CI runs. -
Or open a fork-internal pull request. A PR from your working branch against your fork's own
maintriggers the same diff-mode run an upstream PR would, on your runners, with no approval needed. It also runsrelease-probe, a no-secrets publishability check, for free.
Diff mode compares against your fork's main, so sync your fork before a diff-mode run (the Sync fork button, or git fetch upstream && git push origin main) to match upstream. The first run is slower while the runner caches warm up.
When you open your pull request, include a link to the fork CI run if you have one, so reviewers can see the result.
Adding a New API
If you want to contribute a new method or type, feel free to:
- Open an issue
- Discuss on Discord: https://discord.gg/afch62vKaW
- Share design examples:
- Use cases
- Equivalent in
ZIOorCats Effect - Other motivating patterns
Where to Add Your API
| Subproject | Use For |
|---|---|
kyo-data | Data structures (Chunk, Maybe, Result, etc.) |
kyo-prelude | Effect types without Sync (Abort, Env, Var, etc.) |
kyo-core | Methods requiring Sync |
kyo-system | File system, OS process, and environment methods |
kyo-combinators | Extensions or composition helpers |
Add corresponding tests in the same subproject.
Example:
A new Stream.fromSomething method:
- If it uses
Sync: place it inkyo-core/shared/src/main/scala/kyo/StreamCoreExtensions.scala - If it doesn't: place it in
kyo-prelude/shared/src/main/scala/kyo/Stream.scala
LLM Use Guide
We encourage contributors to leverage Large Language Models (LLMs) responsibly:
- Do not submit low-effort, AI-generated code without review.
- If you use AI assistance, ensure that the submission is well-tested and meets our standards.
- Automated PRs without human oversight may be closed.
Core Principles
These are the axioms. Everything else in this guide derives from them.
-
Source files are documentation. Every
.scalafile is meant to be read top-to-bottom. Method ordering, scaladocs, section separators, and the flow from public API down to internals all serve readability. A contributor opening a file for the first time should understand the type's purpose and usage patterns by reading the file, before touching any external docs. Treat the ordering and structure of a file as carefully as you treat the implementation. -
Most-used first. Within each section, prioritize what users reach for most. Factory methods before configuration,
runbeforerunWith, simple overloads before complex ones. Discoverability beats alphabetical order. -
Action verbs, not theory.
foreachnottraverse. Accessible naming lowers the barrier to entry and keeps the API approachable for developers who don't have category theory backgrounds. -
Performance is a first-class feature. Avoid allocations, avoid unnecessary suspensions, use
inlineand opaque types where appropriate. Zero-cost abstractions aren't optional: they're the reason Kyo can be both safe and fast. -
Composition over inheritance. Delegate, don't extend. No
protected, no deep hierarchies. Build complex behavior by combining simple pieces. -
Type safety first, escape hatches as last resort. Write type-safe code by default.
asInstanceOfand@uncheckedare acceptable only when they're strictly necessary inside opaque type boundaries or kernel internals where the type system can't express a known invariant, never as a convenience shortcut. Never use@uncheckedVariance.Frame,Tag, andAllowUnsafeguard the public surface where users interact. -
Symmetry across related types. Paired or complementary types should share the same structural patterns (factory methods, config, lifecycle) and naming conventions. Keep names consistent: if one type uses
close, the paired type should too. When users learn one side, the other should feel familiar. -
Explain the surprising, skip the obvious. A comment on a race condition is essential. A comment on
getreturning a value is noise.
API Design
Naming
| Don't write | Write instead | Why |
|---|---|---|
traverse | foreach | Describes the action, not the structure |
sequence | collectAll | Says what it does |
pure / succeed | Kyo.lift or rely on implicit lifting | No ceremony for the common case |
void / as(()) | .unit | Direct |
*> / >> | .andThen | Readable without memorizing operators |
replicateM | fill | Plain English |
filterA | filter | Same name as stdlib |
foldM | foldLeft | Same name as stdlib |
bracket | acquireRelease | Says what it does |
provide / provideLayer | Env.run / Env.runLayer | Consistent run pattern |
No symbolic operators in kyo-data, kyo-prelude, or kyo-core. Use named methods (.andThen, .unit, .map). Symbolic operators like *>, <*>, <&> live exclusively in kyo-combinators for users who prefer that style.
Effect operations follow consistent naming:
-
runeliminates an effect:Abort.run,Var.run,Emit.run,Choice.run,Env.run. Common handler variants beyondrun:runWith(v)(continue): canonical handler with continuationrunTuple: returns(State, A)tuplerunDiscard: discards emitted/intermediate valuesrunPartial/runPartialOrThrow: handles a subset of a union error type Non-runeliminators have specific semantics beyond elimination:recover/recoverError: recovers from errors with a fallback valuecatching: catches exceptions and converts to effect errorsfold/foldError: maps all result cases (success/failure) to a single return type
-
getextracts a value from a container type, lifting the error case into the effect.Abort.get(either)extracts theRightvalue, liftingLeftintoAbort.Abort.get(maybe)extractsPresent, liftingAbsentintoAbort. For context effects,getdemands the current value:Env.get[R]: R < Env[R],Var.get[V]: V < Var[V]. -
useapplies a function to the gotten value:Env.use[R](f: R => A < S),Var.use[V](f: V => A < S). -
init/initWith/use/initUnscoped/initUnscopedWith: resource factory variants with increasing lifecycle control:init: creates aScope-managed resource (default choice)initWith(f): creates aScope-managed resource and appliesfto ituse(f): bracket semantics withoutScopein the effect setinitUnscoped: no cleanup guarantees, caller manages lifecycleinitUnscopedWith(f): no cleanup, appliesf
See Closeable Resource Pattern for the full delegation chain.
-
fooPuresuffix for pure (non-effectful) variants:mapPure,filterPure,collectPure,contramapPure. The pure version avoids suspension overhead. Used consistently acrossStream,Pipe, andSink. -
fooDiscarddrops the return value:offerreturnsBoolean,offerDiscardreturnsUnit. Same forcomplete/completeDiscard,interrupt/interruptDiscard, etc. -
Sync-try vs async-wait: sync-try operations use names that imply attempt (
offer,poll) and return a success indicator (Boolean,Maybe). Async-wait operations use names that imply completion (put,take) and suspend until done. The async version tries the sync version first and only suspends on failure. -
noop/Noopfor degenerate cases (formally, the identity implementation):Latch.init(0)returns a pre-completed noop,Meter.Noopis a no-op meter that passes through without rate-limiting. This is both an optimization (avoids allocating real state when nothing will happen) and a naming convention for when you need an identity/pass-through implementation of a type.
Types
When a Kyo primitive exists for a concept, use it instead of the stdlib equivalent.
kyo-data, foundational value types:
| Kyo primitive | Replaces | Notes |
|---|---|---|
Maybe[A] | Option[A] | Option only as conversion input (e.g., Abort.get(opt: Option[A])). When stdlib methods return Option (e.g., collectFirst), convert to Maybe as soon as possible via Maybe.fromOption |
Result[E, A] | Either, Try | Three-way: Success/Failure/Panic; never raw Either or Try in effect signatures |
Chunk[A] | Seq, List, Vector | Use internally; accept generic collections in public APIs (see below) |
Duration | java.time.Duration, scala.concurrent.duration.Duration | Opaque Long-based, zero-allocation |
ByteSize | a bare Long or Int holding a byte count | Opaque Long-based, zero-allocation, saturating arithmetic, non-negative by construction |
Instant | java.time.Instant | Kyo's own wrapper with consistent API |
Span[A] | IArray[A], ArraySeq[A] | Immutable array wrapper, avoids boxing, O(1) indexing |
Schedule | Custom retry/timing logic | Composable scheduling policies |
TypeMap[A] | Heterogeneous maps | Type-safe map keyed by type |
Prefer ByteSize over a bare numeric type wherever a value means "a quantity of bytes": storage and disk sizes, file sizes, buffer and byte-array capacities, read and write chunk sizes, network packet and frame sizes, transfer limits and quotas, memory footprints. It carries the unit in the type, so a call site cannot silently pass kibibytes where bytes were expected, and its arithmetic saturates instead of overflowing. A raw Long or Int stays correct for an index, an offset into a buffer, or a count of elements; those are positions, not sizes.
This is guidance for new and changed code. Existing APIs that thread byte counts as Long are not required to migrate, and a migration should be its own change rather than a drive-by edit inside an unrelated one.
kyo-prelude, effects:
| Kyo primitive | Purpose | Notes |
|---|---|---|
Abort[E] | Short-circuit errors | Typed error channel; use Abort.fail, Abort.recover, Abort.run |
Env[R] | Dependency injection | Context effect; use Env.get, Env.run |
Var[V] | Mutable state | Effect-tracked state; use Var.get, Var.set, Var.run |
Emit[V] | Value emission | Push-based output; use Emit.value, Emit.run |
Poll[V] | Value polling | Pull-based input; use Poll.one, Poll.run |
Choice | Nondeterminism | Multiple values; use Choice.eval, Choice.run |
Check | Assertion checking | Lightweight validation; use Check.require, Check.runAbort |
Batch | Automatic batching | Groups operations for batch execution |
Memo | Memoization | Caches results; opaque alias for Var[Memo.Cache] |
kyo-prelude, streaming and composition:
| Kyo primitive | Purpose | Notes |
|---|---|---|
Stream[V, S] | Lazy effectful sequences | Chunked push/pull hybrid; prefer over manual Emit for sequences |
Pipe[A, B, S] | Stream transformation | Composable Stream[A] => Stream[B] |
Sink[V, A, S] | Stream consumption | Composable Stream[V] => A |
Layer[Out, S] | Dependency injection | Composable; use Layer.init for compile-time wiring |
kyo-core, effects:
| Kyo primitive | Purpose | Notes |
|---|---|---|
Sync | Effect suspension | Marks side-effecting code; use Sync.defer { ... } |
Async | Concurrency | Main effect for concurrent programming; prefer over direct Fiber use |
Scope | Resource management | acquireRelease, ensure; structured cleanup via Scope.run |
Clock | Time operations | now, sleep, deadline; withTimeControl for testing |
Log | Logging | trace, debug, info, warn, error with level control |
Console | Console I/O | readLine, print, printLine, printErr |
Random | Random generation | Seeded or context-bound; use for testability |
Retry | Retry with policy | Takes a Schedule from kyo-data |
kyo-core, concurrency primitives:
| Kyo primitive | Purpose | Notes |
|---|---|---|
Fiber[A] | Async computation handle | Low-level; prefer Async API in application code |
Channel[A] | Async message passing | Bounded, backpressured; use over raw queues for async communication |
Queue[A] | Concurrent collection | Synchronous ops, bounded/unbounded; use Channel when async backpressure is needed |
Hub[A] | Broadcast messaging | Fan-out to multiple listeners; built on Channel + Fiber |
Signal[A] | Reactive value | Current + change notification; use for observable state |
AtomicInt, AtomicLong, AtomicBoolean, AtomicRef[A] | Atomic operations | Thread-safe single-value containers |
LongAdder | High-contention counter | Use over AtomicLong for write-heavy workloads |
Meter | Concurrency control | Semaphore, mutex, rate limiter; composable via pipeline |
Latch | One-shot barrier | Use for coordination points |
Error types (Closed is in kyo-kernel, the rest in kyo-core):
| Kyo primitive | Purpose | Notes |
|---|---|---|
Closed | Resource closed | Standard error for closeable resources; carries creation Frame |
Timeout | Operation timed out | Used by Async.timeout and related APIs |
Interrupted | Fiber interrupted | A KyoException; an interrupted fiber completes with it as a Panic |
Rejected | Admission rejected | Load shedding signal from Admission |
kyo-system (file system, OS processes, and environment):
| Kyo primitive | Purpose | Notes |
|---|---|---|
Path | File system operations | Immutable, cross-platform; construct with /, reads carry PathRead, writes PathWrite |
FileSystem | Pluggable backend | Host filesystem service the runners install |
Command | OS process launch | Builds and runs external processes; spawn, text, waitFor |
Process | Running process handle | stdout, stderr, waitFor, destroy |
System | Environment/properties | Type-safe access with custom Parsers |
FileSystemException | File I/O errors | Sealed hierarchy: FileReadException, FileWriteException, FileStructureException |
Accept generic collections in public APIs, use Chunk internally:
def foreach[CC[+X] <: Iterable[X] & IterableOps[X, CC, CC[X]], A, B, S](
source: CC[A]
)(f: A => B < S)(using Frame): CC[B] < S =
Kyo.foreach(Chunk.from(source))(f).map(source.iterableFactory.from(_))
Failure Tracking
A typed Abort[E] row is a precise contract: it names exactly which failures an operation can produce, so a caller handles one and the compiler narrows the row to prove what remains. A blanket error-base on a row, or a raw non-module exception leaking onto it, discards that; the row degrades to "something might fail" and the leaf hierarchy becomes decorative.
Each public operation's row names its precise failure set. Declare the narrowest type that covers exactly what the operation produces; do not smear a module-wide error base onto a row when the operation raises a small subset of it.
One sealed module-exception hierarchy, a sealed trait per operation, leaves that mix in. This is how kyo-jsonrpc's JsonRpcError and kyo-http's HttpException are organized:
sealed abstract class FooException(code: Int, message: String, ...)(using Frame) extends KyoException(message, ...)
sealed trait FooCallFailure extends FooException // one trait per operation; this is the row type
sealed trait FooReadFailure extends FooException
case class FooUnknownException(name: String)(using Frame)
extends FooException(...) with FooCallFailure // leaf in one operation
case class FooConnectionClosedException()(using Frame)
extends FooException(...) with FooCallFailure with FooReadFailure // shared leaf, several operations
A trait can extend the parameter-carrying base because the concrete leaf supplies the constructor arguments, so a leaf mixes in every operation-trait it belongs to. The operation's row is then the operation-trait (def call(...): A < Abort[FooCallFailure]): one lean type in the signature that is exactly that operation's sealed failure set, matched exhaustively.
No raw non-module exception on a public row. A kyo Closed / Timeout, a wire-protocol error, or a raw Throwable reaching a public row is untyped tracking-loss: it carries no module-level message and cannot be matched as part of the model. Map it to a typed leaf at the boundary (the Abort.recover site); a closed transport becomes a typed *ConnectionClosedException leaf, a remote wire error becomes a typed remote-error leaf.
Handler-body rows carry the user's error, not a framework blanket. When a module accepts a user-supplied handler, the body's row is Abort[E | <control signal>] where E is the user's declared or inferred error; never a fixed module-exception blanket. A body that raises no module failure has an empty E; one that calls a framework accessor infers that accessor's leaves into E.
Every leaf carries its own message, built from typed fields. The leaf's fields are typed (name: String, uri: Uri, requested: Version) and its message is constructed from them in the case-class body; no free-form detail: String parameter stands in for structure. See KyoException Convention for the base-class mechanics.
Construction-time validation panics; it does not appear on a tracked Abort row. A bad configuration (an empty required set, a non-positive timeout, a handler claiming a framework-reserved code) is a construction-time programmer error, so require throws rather than threading an Abort[ConfigError] through every init signature, exactly as the sibling JsonRpcHandler.Config.require does. Prefer a typed leaf for the thrown payload (McpConfigurationError) over a raw IllegalArgumentException so the message stays structured, but that leaf carries no operation-trait and never reaches a row. Impossible states and misuse (an accessor used outside its dynamic extent, a partition invariant) are likewise bugs, not tracked failures: they panic and stay off every row.
Method Signatures
(using Frame) as Type Parameter Separator
Scala 3 doesn't support def foo[A][B, C]. The workaround is to use (using Frame) between type parameter clauses. The first clause holds the type parameter the user specifies explicitly; the second holds types inferred from value arguments:
// user specifies E inferred from args
// vvvvvvvvvvvvvvvv vvvvvvvvvvvvvvvvvvvv
inline def get[E](using inline frame: Frame)[A](either: Either[E, A]): A < Abort[E]
inline def runWith[E](using Frame)[A, S, ER, B, S2](
v: => A < (Abort[E | ER] & S)
)(continue: Result[E, A] => B < S2)(...): B < (S & reduce.SReduced & S2)
def apply[E: ConcreteTag](using Frame)[A, S](
v: => A < (Abort[E] & S)
): A < (Async & Abort[E] & S)
Pattern: [UserSpecified](using Frame)[InferredFromArgs]
using Clause Ordering
Inline methods put Tag before Frame:
inline def get[V](using inline tag: Tag[Var[V]], inline frame: Frame): V < Var[V]
Non-inline methods put Frame before type-level evidence:
def run[E](
using Frame
)[A, S, ER](v: => A < (Abort[E | ER] & S))(
using
ct: ConcreteTag[E],
reduce: Reducible[Abort[ER]]
): Result[E, A] < (S & reduce.SReduced)
AllowUnsafe always last:
def init(parallelism: Int)(spawn: Unit < Async => Unit)(using frame: Frame, u: AllowUnsafe): Finalizer
Frame and Tag
Frameon every method that suspends or handles effects. Never on pure data accessors (capacity,size). Alwaysinlineon inline methods for zero-cost source location capture.Tagwhen runtime effect dispatch is needed. Parametric effects likeVar[V],Emit[V],Env[R]require tags because the handler must identify which effect to match at runtime.
@targetName for Extension Methods
Use @targetName to disambiguate extension methods on opaque types that erase to the same JVM signature as methods on the underlying type.
Overload Organization
Simple variants delegate to the canonical implementation and never duplicate logic:
// Canonical: does the actual work
private[kyo] inline def runWith[V, A, S, B, S2](state: V)(v: A < (Var[V] & S))(
inline f: (V, A) => B < S2
): B < (S & S2) = ArrowEffect.handleLoopState(...)
// Variants: project the result differently
def run[V, A, S](state: V)(v: A < (Var[V] & S)): A < S =
runWith(state)(v)((_, result) => result)
def runTuple[V, A, S](state: V)(v: A < (Var[V] & S)): (V, A) < S =
runWith(state)(v)((state, result) => (state, result))
For overloads by arity: variadic delegates to Seq, Seq delegates to Seq+config:
def race(first, rest*) = race(first +: rest)
def gather(iterable) = gather(iterable.size)(iterable)
Ordered by increasing arity/complexity within each group.
Code Conventions
Pending Type (A < S)
A < S represents a computation that produces an A with pending effects S. Kyo automatically lifts plain values into computations: any A can be used where A < S is expected, with no wrapping needed. Use Kyo.lift only when the compiler struggles with type inference.
- Avoid nested computations (
(A < S1) < S2). They require.flattenand make code harder to follow. Use.mapchains or for-comprehensions to keep computations flat.Kyo.liftexists to wrap a value as a nested computation when unavoidable, but needing it usually signals the code should be restructured. - Use
.map, not.flatMap. They are identical on pending types;flatMapexists for for-comprehensions only - Use
.andThen(next)to sequence, not.map(_ => next) - Use
.unitto discard a result toUnit < S - Prefer
.mapchains over for-comprehensions; use for-comprehensions when readability benefits (many dependent steps) - Use
.handle(Abort.run, Env.run(x))for left-to-right handler pipelines instead of nested calls - Prefer
Abort.recoveroverAbort.run+Resultpattern matching
Scala Conventions
- Prefer functional style: no mutable
vars, nowhileloops, nothrow/catchfor control flow. Useval, recursion (with@tailrec),Loop, andAbortfor errors. Mutable state is acceptable only in performance-critical internals (atomics, bit-packing) where it's encapsulated behind a pure interface. - Use
discard(expr)to suppress unused value warnings, notval _ = expr - Provide
CanEqualfor all comparable types: usederives CanEqualon case classes and enums. Skip types with non-comparable fields like functions. - Minimize explicit type parameters at call sites. APIs should infer well from value arguments. If a user must write
Abort.fail[String]("error")instead ofAbort.fail("error"), that's a design smell. Use the(using Frame)type parameter separator pattern (see Method Signatures) when user-specified types are unavoidable - Explicit return types on public API only; let the compiler infer elsewhere:
def offer(v: A)(using Frame): Boolean < (Sync & Abort[Closed]) // public: explicit private def helper(v: A) = ... // private: inferred val result = someCall() // val: inferred val chunk = Chunk.from(values) // not Chunk.from[A](values) - No
protected; useprivate[kyo]orprivate[kernel] - All public APIs in the
kyopackage; internal code useskyo.internal - Avoid
asInstanceOfand@unchecked. They are acceptable only inside opaque type boundaries or kernel internals. Never use@uncheckedVariance - Imports: specific over wildcard, internal wildcards OK, grouped by origin
- Keep
Sopen if appropriate: useA < (S & SomeEffect)instead ofA < SomeEffect - Prefer call-by-name (
body: => A < S) for methods that capture a side-effecting body. This is a safety net in case the user forgets to suspend side effects: without call-by-name,Abort.catching(connection.read())would executeread()eagerly beforecatchingcan intercept the exception - Mark methods
finalin abstract classes/traits when not intended for override - Format all four platforms before submitting (see How to Build Locally)
Documentation
Type-Level Scaladoc
Every main public type needs a scaladoc (8-35 lines) covering:
- Opening sentence: what the type is, brief and definitional
- Conceptual "why": 1-3 paragraphs on mental model and design rationale
- Behavior a caller must know that the signatures do not carry. Name key operations only as navigation (which entry point to reach for), never a description of what each method does; that belongs on the method (see Method-Level Scaladoc)
- Gotcha callouts:
WARNING:,IMPORTANT:, orNote:for surprising behavior @tparamtags for all type parameters@seereferences: 3-6 links per type, grouped by topic (creation, handling, related types)- No code examples unless demonstrating composition patterns or system property syntax (rare)
WARNING/IMPORTANT/Note decision:
WARNING:: risk of data loss, memory exhaustion, or incorrect behavior if misusedIMPORTANT:: subtle semantic distinction that affects correctnessNote:: behavioral clarification or platform difference
Method-Level Scaladoc
- Brief description (1-3 lines)
@param/@returnonly when name and type aren't enough- Skip for truly trivially obvious methods (
capacity: Int,size: Int)
Markdown Formatting
Scaladoc parses comments as CommonMark markdown (the Scala 3 default). Scala 2 wiki syntax (=Heading=, '''bold''', ''italic'', [[url text]]) renders as literal text and must not be used.
Heading levels map to CSS classes that determine visual size:
| Source | HTML | Size |
|---|---|---|
# / ## | h1 / h2 | very large (page-level) |
### | h3 | large |
#### | h4 | moderate; use for in-class sub-sections |
##### / ###### | h5 / h6 | small |
Use #### for sub-sections inside a class/object/trait docstring. # and ## compete visually with the type's own title. For light visual grouping where no anchor or sidebar entry is wanted, use **bold** inline labels instead of a heading.
Code blocks: prefer markdown fenced blocks with a language tag for syntax highlighting:
```scala
val x: Int < Async = 42
```
Scala 2 {{{ }}} blocks render correctly (backward-compat) but get no syntax highlight; prefer fenced form for new code. Indented (4-space) code blocks do not work: they render as plain paragraphs.
Links:
- External URLs:
[text](https://…)markdown form. Never[[https://… text]](Scala 2 wiki). - Internal type / member references:
[[kyo.Foo]]or[[Foo]](wiki-style member-link, kept for backward-compat and preferred for cross-references). [[Foo custom label]]for a renamed link target.
Emphasis: **bold** and *italic*. Never '''bold''' / ''italic''.
Other supported markdown: GFM tables (| col |), block quotes (>), horizontal rules (--- / ***), ordered/unordered lists with nesting, strikethrough (~~), inline HTML (<em>, <sub>, etc.).
Scaladoc tags (@param, @tparam, @return, @throws, @note, @see, @example, @since) render in a structured "Attributes" section below the description. For @throws, always use a resolvable type name (e.g. @throws java.lang.IllegalArgumentException); bare type-parameter names like @throws E emit unresolvable-link warnings.
scalafmt interaction (critical): the repo .scalafmt.conf sets docstrings.wrap = keep so scalafmt preserves docstring line structure. Even with that setting, markdown still requires blank * lines between blocks: heading + paragraph, paragraph + list, paragraph + code block all need a blank separator line. Otherwise markdown joins them into a single paragraph.
Example of correct structure:
/** Single-sentence summary on the line right after the slash-star-star.
*
* Longer paragraph describing the type. Spans as many sentences as needed.
*
* #### Sub-section heading
*
* Paragraph that follows the heading. The blank `*` lines above and below
* the heading are mandatory.
*
* - List items go here
* - With a blank line above and below the list
*
* ```scala
* val example = …
* ```
*
* @tparam A summary
* @param input summary
*/
Inline Comments
The default is no comment. Code that needs no explanation gets none. The list below is a closed set of exceptions, not an invitation to explain. A comment is a liability: the code moves and the comment does not.
A comment is warranted only as an answer to one of these:
- Non-obvious choices: why this shape and not the obvious one.
- Load-bearing invariants: what breaks if this changes, the ordering that must hold, why something stays that a reader would otherwise delete or simplify.
- External facts the types cannot carry: a JVM inlining budget, a runtime flag, a protocol requirement.
- Measured results, stated as the number.
- Race conditions and concurrency hazards: the interleaving, which carrier owns what.
- Bit-packing and encoding schemes: diagram the layout.
- Phase markers in multi-step methods: one line per phase naming the phase (
// extract path params,// map errors), never restating a call.// process completed transfersaboveprocessCompletedTransfers()is noise. - Navigational signposts in large methods (30+ lines) with non-linear control flow, so a reader can skim the structure.
- Required markers:
// Unsafe:at a bridging site,// deviation: <reason>at a test that keeps an unavoidable real-clock bound (see Deterministic Tests), and the audit comments individual modules require at a declared exception.
Placement. Categories 7 and 8 live only inside a method body. Never put a navigational or phase comment on a top-level declaration, a build setting, a field, or an import block: those have no structure to navigate.
Three kill tests, applied to every comment before it ships:
- Grep test: is the content recoverable by grepping the identifier below it? Then it is a tautology. Delete it.
- Sync test: does it name anything nothing keeps in sync (test classes, call sites, file lists, counts)? It becomes false on the next rename and nothing will catch it. State the constraint, never the inventory.
- Decision test: would removing it change what a maintainer does? If no, delete it.
Two banned shapes. The development diary: "previously", "used to", "this was changed because", "after the review", phase or campaign codes, any change-relative wording. History belongs in the commit message. The quick-to-stale tautology: restates the line below it, or enumerates the tests, classes or files that use the thing. It says what the code already said, and then rots.
Say what the thing is, not what it does. A comment on a type or member says what it is and why it has that shape, not what its methods do. A scaladoc on a difference says what the difference is, not which caller has it.
File Organization
A source file should read like a guided tour of the type. A contributor opening it for the first time learns, in order, what the type is, how to create it, how to use it, and only then how it works internally. Scaladocs set the context, method ordering tells the story, and section separators mark the chapters.
One Project per Class Name
Every module compiles into the same packages, kyo and kyo.internal, and private[kyo] does not narrow that: it is package visibility, and every module sits in that package. When two modules declare one fully qualified name, a classpath holding both keeps the first class and drops the other, and nothing reports it: not the JVM classloader, not the Scala.js linker, not the Scala Native linker.
A name in main sources belongs to one module, since users combine published modules in ways this build never does. In test sources the rule is narrower: two test classes under one name are fine until a test->test dependency puts them on one classpath, and the check fails on the change that does.
Name a platform facade after its module. kyo-http binds Node's path module as HttpNodePath because kyo-system already binds it as NodePath.
sbt 'checkClassNames JVM' compiles that platform and fails on a duplicate, naming both projects; the other arguments are JS, Native and Wasm. CI runs only the JVM row, which reuses the compile the doctest step already paid for; run the others locally when adding a class to js, js-wasm, native or wasm sources. Projects that produce one name by design, as kyo-compat's five bindings do, declare ClassNameCheck.classNameGroup; the check then accepts the shared name and instead fails if any project's classpath reaches two of them.
File Template
package kyo // or kyo.internal
import kyo.specific.imports
import scala.annotation.tailrec
/** Type-level scaladoc.
*
* Conceptual explanation.
*
* @tparam A description
* @see [[kyo.Related]]
*/
sealed trait MyEffect[A] extends ArrowEffect[...] // or opaque type, final class
object MyEffect:
// --- Public API (frequency-of-use order) ---
// Suspend/create methods
inline def create[A](...)(using ...): A < MyEffect[A] = ...
// Query/access methods
inline def get[A](...)(using ...): A < MyEffect[A] = ...
inline def use[A](...)(using ...): B < (MyEffect[A] & S) = ...
// Handler methods
def run[A](...)(using ...): Result < S = runWith(...)(identity)
private[kyo] def runWith[A](...)(f: ...)(using ...): B < S = ...
// Factory methods (for resource types)
def init[A](...)(using Frame): MyType[A] < (Sync & Scope) = ...
def initWith[A](...)(f: ...)(using Frame): B < (Sync & Scope & S) = ...
def initUnscoped[A](...)(using Frame): MyType[A] < Sync = ...
// --- Nested Types ---
object Unsafe:
...
// --- Internal ---
private[kyo] def internal(...) = ...
end MyEffect
Readability Ordering
The file template above reflects a deliberate top-to-bottom reading order:
- Type definition + scaladoc: the reader learns what the type is and why it exists
- Public API: organized into groups by usage pattern, most-used first within each group:
- Suspend/create methods
- Query/access methods
- Handler methods
- Factory methods (for resource types)
- Nested types:
Unsafe,Config, auxiliary case classes, public type aliases - Internal methods: implementation details,
private[kyo]helpers, internal-only givens
Within each group, simple overloads come before complex ones. The canonical implementation sits next to its variants so the reader sees the delegation at a glance. Scaladocs on each method flow from one to the next, each building on context established by the previous.
This ordering matters because it determines how quickly a contributor can orient themselves. A well-ordered file answers "what does this do?" and "how do I use it?" without scrolling.
Visibility Tiers
| Modifier | Scope | Use for |
|---|---|---|
| (none) | Public | User-facing API |
private[kyo] | Cross-package | Internal utilities used across modules |
private | Class-local | Mutable state, helpers |
Section Separators
Use // ---... separators with section names in all files:
// --- Generic ---
def foreach[...] = ...
def filter[...] = ...
// --- List ---
def foreach[A, B, S](source: List[A])(...) = ...
Group by semantic category (reads → writes → updates → handlers), then by arity within each group.
export for Nested Type Promotion
When a nested type is heavily used, promote it to package level with export:
// In Fiber.scala, after the companion object
export Fiber.Promise // makes kyo.Promise available without Fiber. prefix
Use sparingly, only for types that users reference frequently enough that qualification would be noisy.
Macros
Never read a type's declaration through Symbol.tree. What that call returns depends on -Yretain-trees, a flag the user's build sets and the library does not control. Without it the compiler fabricates a declaration from the symbol info; with it the retained source declaration comes back instead. For a type member the two carry different things: the fabricated one carries the bounds, the real one carries the right-hand side as written, which for an opaque type is its alias and never mentions the bounds. A macro that matches on one shape crashes or answers differently under the other, and the tag or schema it derives stops agreeing with the one every other build derives.
Ask the symbol instead. kyo.internal.DeclaredBounds reads a type member's declared bounds through the node's own prefix, which answers the same in both modes, and substitutes the node's own type arguments into a parameterized bound.
Whether that call is stable is a per-symbol question, not a global one, because Symbols.retainsDefTree decides it as a disjunction: the flag is one disjunct, denot.owner.isTerm is another. For a type local to a term (a method's type parameter) the second holds unconditionally, since such a symbol dies with its term and cannot leak across runs, so both modes read the same declaration and reading it is sound. That case also has no prefix to be a member of, so it is the one place DeclaredBounds reads a tree, and it already handles it. For every other symbol the flag is the deciding disjunct, which is exactly what makes the general read unstable.
Reading a term definition's body (ValDef.rhs, a given's right-hand side) is different: those trees exist only under the flag, so such a macro must degrade gracefully rather than depend on them.
To check a macro answers the same in both modes:
KYO_RETAIN_TREES=true sbt 'kyo-dataJVM/test'
The flag is never set in CI. Retaining the trees of every dependency costs a few hundred MB per module, which the runners do not have to spare, so this is a local check to run when touching a macro that inspects types.
Module READMEs
Every module ships a README.md aimed at a developer evaluating or first using it. It positions the module, shows how to install and use it, and introduces features in an order a reader can stop at any time with a coherent picture. It is not a reference manual (scaladoc covers the full API) and not a tutorial (kyo-examples covers end-to-end programs).
To add or revise a module README, use the /readme skill. The skill is the authoritative source for both structure and writing style: it orchestrates source analysis, drafting, multi-axis critique, and doctest verification, and it carries the conventions every Kyo README follows (opening hook, capability summary, topical ordering, code-example rules, gotcha markers, and so on). kyo-http/README.md and kyo-schema/README.md are the working models the skill draws from.
sbt <module>/doctest validates that every fenced Scala block in the README compiles against the module's classpath. CI runs sbt doctest over the full project.
Optimization
Performance
- Prefer
final classfor concrete types andabstract classovertraitfor base types: JVM interface dispatch is more expensive than class dispatch. Usetraitonly when defining a pure interface (like effect types extendingArrowEffect/ContextEffect) - Mark classes
finalunlesssealedorabstract - Provide pure-function variants (
mapPure,filterPure) when a hot transformation doesn't need suspension - Fast-path before slow-path: always check for degenerate cases (empty, single-element, already-resolved) before entering the general/expensive path:
if source.isEmpty then Chunk.empty // empty: return immediately else if source.sizeIs == 1 then f(source.head).map(Chunk(_)) // single: avoid Loop setup else Loop.indexed(...) // general case - Use opaque types; never wrap when you can alias. See Zero-Cost Type Design
- Use
inlinestrategically: inline creation paths, not handling paths. See Inline Guidelines - Prefer
@tailrecloops; allocate continuations only when effects force suspension - Never block a thread: use
Async-based suspension (Channel.put,Fiber.get,Clock.sleep) instead of blocking primitives (Thread.sleep,CountDownLatch.await,synchronized,Future.await). - Prefer lock-free algorithms (CAS +
@tailrec) over blocking synchronization - Bit-pack atomically-updated composite state to avoid wrapper allocations. Always include a layout comment:
// Bit allocation: // Bits 0-15 (16 bits): depth (0-65535) // Bit 16 (1 bit): hasInterceptor flag // Bits 17-63 (47 bits): threadId - Avoid the erased tag pattern.
Taghandles variant effects, so existing usages are tech debt; do not introduce new instances:// tech debt: do not copy private inline def erasedTag[E]: Tag[Abort[E]] = Tag[Abort[Any]].asInstanceOf[Tag[Abort[E]]]
Zero-Cost Type Design
Kyo achieves zero-cost abstractions through opaque types. When designing a new type, choose the strategy that eliminates allocation on the hot path.
Union discriminability: When using union types, ensure all components are fully discriminable at runtime. Overlapping erasures will cause incorrect dispatch.
| Strategy | Example | Wraps | When to use |
|---|---|---|---|
| Opaque over primitive | Duration = Long | Raw primitive | Numeric quantities (time, size, count) |
| Opaque over JVM type | Instant = JInstant | Existing class | Wrapping a well-tested JVM type with a safer/simpler API |
| Opaque over union | Maybe[A] = Absent | Present[A] | Union of subtypes | Discriminated types where the success path avoids boxing |
| Opaque over array | Span[A] = Array[? <: A] | Mutable array | Immutable view of array data without copying |
| Opaque over Unsafe | Channel[A] = Channel.Unsafe[A] | Unsafe implementation | Concurrent types with safe/unsafe tiers (see Unsafe Boundary) |
| Effect alias | Async <: (Sync & Async.Join) | Subtype bounds | Composing effects via subtype relationships |
| Subtype-bounded opaque | Queue.Unbounded[A] <: Queue[A] = Queue[A] | Parent opaque | Expressing subtype relationships between opaque types |
Structuring an opaque type:
- Define the opaque type and its companion in the same file
- Expose the safe API via extension methods in the companion, not methods on a class
- Factory methods in the companion validate input:
Maybe(null)returnsAbsent,Duration.fromNanosclamps negatives - Internal code accesses the underlying value via pattern matching on union members or direct use within the opaque boundary
- Avoid exposing the underlying representation; if escape hatches are needed, use
private[kyo] - Code inside the type's own scope cannot summon a
Tagfor the type or derive one mentioning its underlying type; it passesTag.derive[X]explicitly. See Tags inside an opaque type's scope
Given instances for new types, as applicable:
CanEqual: required if the type supports==/!=(strict equality is enabled project-wide)Render: for human-readable displayOrdering: if the type is naturally sortableTag: automatically derived; never declare agiven Tag[X]insideX's own scope (below)
Tags inside an opaque type's scope
Inside the template that declares an opaque type, and inside its companion, the compiler substitutes the underlying type for the opaque one wherever it has to infer, and it does so before any macro runs. Env.get[X] written there reaches the Tag macro as the underlying type, so the tag derived inside describes something different from the tag every call site outside derives, and a value stored under one is not found under the other.
Nothing at that point can say which type was meant, so the macro refuses any derivation whose type mentions the underlying type of an opaque type transparent there ([Tag.opaque.collapsed]), whether it was inferred or written. Naming the opaque type in Tag.derive[X] always survives the substitution and derives the same tag as anywhere else. An implicit query may or may not survive depending on how the compiler resolves it (an Emit.value(m) in a companion extension method on the opaque type does, a summoned Tag[X] does not); when it does not, the derivation is refused rather than misnamed, and the tag is passed explicitly:
opaque type Meters = Long
object Meters:
def get: Meters < Env[Meters] = Env.get[Meters](using Tag.derive[Meters])
A given Tag[X] must not be defined inside X's scope ([Tag.opaque.given]): there it is also a Tag for the underlying type and would answer every such query with X's tag, silently. The same holds for an imported given or an inline given, which the macro cannot see; do not define them.
Refusal is per site, so a type like opaque type Count = Int refuses every Int on a tag surface in its own template. A scope that needs the underlying type on a tag surface keeps the opaque type in an object of its own.
Sealed trait vs opaque type:
- Use opaque type when you want zero-cost wrapping of an existing representation
- Use sealed abstract class or enum when you need pattern matching on cases or case class features (structural equality, copy). Reserve sealed traits for effect type hierarchies
- Both can coexist:
Resultis an opaque union whose members (Success,Failure,Panic) are sealed subtypes
Inline Guidelines
inline creates code bloat when overused. The codebase follows a deliberate strategy: inline the creation path (where effects are born), not the handling path (where effects are processed), and inline function parameters to avoid closure allocation.
IMPORTANT: Inline as little as possible. When a method needs inline, mark only the function/by-name parameters as inline and keep the method body small. This eliminates closure allocation while minimizing code bloat at each call site. Two patterns:
-
Trivial body: the method body is a one-liner, so inlining the whole thing is fine:
// Maybe.map: body is a simple branch, no bloat risk inline def map[B](inline f: A => B): Maybe[B] = if isEmpty then Absent else f(get) -
Non-trivial body: define a local non-inline
deffor the real logic and call it once. Theinlinemethod removes the lambda allocation forf, whose body lands inside the local method, and the loop or recursion stays a method call instead of expanding into the caller's control flow. A schematic shape (Pending.mapfollows it; its kernel internals are covered in kyo-kernel's guide):inline def map[B, S2](inline f: A => B < S2)(using inline frame: Frame): B < (S & S2) = @nowarn("msg=anonymous") def loop(v: A < S): B < (S & S2) = ... // suspension handling, recursing into loop, applying f to a value loop(self)
When in doubt, don't inline. The cost of unnecessary inlining (code bloat, slower compilation) is higher than the cost of a method call on a non-hot path.
DO inline:
| Category | Examples | Why |
|---|---|---|
| Effect suspend/create calls | Abort.fail, Var.set, Emit.value, Choice.evalSeq | Direct calls to ArrowEffect.suspend/suspendWith; must be zero-cost |
| Kernel framework entry points | ArrowEffect.handleLoop, ArrowEffect.suspend | Backbone of the effect system; enables compile-time specialization |
private[kyo] hot-path helpers | Var.runWith, internal handler implementations | Optimizes internal glue without affecting public API size |
| Methods with function/by-name parameters | Maybe.map, Maybe.flatMap, Var.use, Result.fold | Eliminates Function1 allocation: the lambda body is substituted directly at the call site |
DO NOT inline:
| Category | Examples | Why |
|---|---|---|
| Public effect handlers/runners | Abort.run, Var.run, Emit.runFold, Choice.run | These call back into the kernel; inlining would duplicate complex handler logic at every call site |
| Methods that take only value parameters | Maybe.get, Maybe.zip, Maybe.contains, Maybe.toList | No function/by-name parameters means no closure to eliminate, so inlining adds bloat without benefit |
The pattern in practice, in an effect like Abort:
Abort.fail,Abort.panic,Abort.get,Abort.when→ inline (creation)Abort.run,Abort.recover,Abort.fold→ not inline (handling)Abort.runWith→ inline: a handler, but it takes a continuation, so the function-parameter rule applies
Similarly for data types like Maybe:
Maybe.map,Maybe.flatMap,Maybe.filter,Maybe.getOrElse→ inline (simple branches)Maybe.get,Maybe.zip,Maybe.contains,Maybe.flatten→ not inline (complex logic)
@nowarn for inlined lambdas: when inlining a function parameter causes the compiler to warn about new anonymous classes, use @nowarn("msg=anonymous").
Testing
Deterministic Tests
A test must pass or fail on the code's behavior, never on how fast the machine ran. A timing-dependent assertion is a broken test: it flakes on a loaded CI runner, a fast laptop, or a slow emulator.
No test may depend on the real clock. No assertion on measured elapsed time (currentTimeMillis/nanoTime, Clock.now/nowMonotonic deltas) against a threshold; no Thread.sleep to "give something time" then asserting it happened; no real-time delay or timeout as the pass condition. Coordinate with barriers, and control durations with virtual time.
A threshold is a defect whatever it measures (elapsed time, clock skew, latency, memory, an iteration count under load): a number that passes on your machine fails on a slow, emulated, or contended one, and widening it only lowers the flip rate. Do not tune thresholds. Replace the magnitude with a property a fast or slow runner cannot flip:
- Bracketing: to check a reading matches a reference, sample the reference on both sides:
before = ref(); v = read(); after = ref(); assert(before <= v && v <= after). A slower host only widens the interval; a wrong value falls outside, and no tolerance appears. This is how to check a clock or epoch binding. - Ordering / monotonicity:
assert(b >= a)across successive reads, or that entries arrived in order. - State / count:
assert(consumed + remaining == total),assert(peerClosedFlag).
Virtual time (Clock.withTimeControl): the clock advances only when the test tells it to, so sleeps, delays, timeouts, schedules, and stopwatches become exact. Drive a sleeping effect by forking it alongside an advancer and joining; assert exact durations (elapsed == 5.seconds, never >= 5.seconds). TimeControl gives set, advance, and awaitPendingSleepers(n) (advance only after n sleepers register, so the tick count is exact rather than a function of interleaving).
Clock.withTimeControl { control =>
for
fiber <- Fiber.initUnscoped(theEffectThatSleeps)
advancer <- Fiber.initUnscoped(Loop.forever(control.advance(1.milli)))
result <- fiber.get // completes only once virtual time reaches its deadlines
_ <- advancer.interrupt
yield assert(deterministicProperty)
}
Barriers, not sleeps, to make one fiber wait for another: Latch (a one-shot countdown: Latch.init(n) opens after n releases, which also covers "wait until N arrived"), or Channel/Fiber.get (rendezvous). A negative property ("X must NOT have happened yet") is proven by a barrier the other fiber would have had to pass, never by sleeping and checking.
Common conversions:
| Flaky shape | Deterministic replacement |
|---|---|
assert((currentTimeMillis - start) >= d) | run under withTimeControl; assert on calls/state and/or stopwatch.elapsed == d |
Thread.sleep(n); assert(done) | release a Latch/Channel from the other fiber and await it |
assert(elapsed < budget) (op returns fast) | assert the terminal event/state that proves it returned, not the elapsed |
| retry/schedule/backoff timing | withTimeControl plus an advancer; assert attempt count and exact virtual elapsed |
| "settle" sleep before reading | wait on the settle's own completion signal |
| real-thread concurrent soak | bound each worker by a fixed op count and self-terminate; a producer/consumer uses a producers-done latch plus drain-until-done, asserting conservation, never the window |
Legitimately not the real clock (fine to keep): sleeps, delays, and timeouts under withTimeControl; Duration arithmetic; a generous ceiling used only as a deadlock or hang canary, where the assertion reads a state and the ceiling exists so a hang trips the suite timeout rather than being asserted on.
Deviations. Some tests exercise a seam virtual time cannot cover: the platform clock itself, a real OS socket or kernel poller, a spawned subprocess, raw threads below the effect system. The absence of a virtual-time seam is not a license to keep a timing assertion: the pass/fail must still assert a state, event, structure, or monotonicity, never measured elapsed. A timeout is legitimate only as a hang-canary ceiling, never the pass condition. Before any magnitude bound, prove no ordering, bracketing, or state proxy exists; only then does a catastrophic-only bound survive (so wide that only a genuine defect trips it), written at the site as // deviation: <reason> and reported to the maintainer. A test that deliberately wedges the scheduler (a livelock repro) cannot rely on the per-leaf timeout, which runs on that same scheduler; a raw watchdog thread is then legitimate only if its firing is gated on a completion latch the test releases when it finishes, so a slow-but-correct run releases the latch first and is never disturbed.
The per-leaf timeout already guards hangs. Every kyo.test.Test suite caps each leaf: TestBase.timeout defaults to 120s (Infinity only when a debugger is attached), and a suite may tighten it (kyo.net.Test uses 60s). An in-test Async.timeout(d)(op) added only to turn a hang into a failure duplicates that cap, and a too-tight one is itself a flake: it fires on a slow-but-progressing run. Keep an in-test timeout only when its value is the tested behavior (an asserted deadline), the test asserts the window expires, or the suite is raw ScalaTest with no framework cap, which then needs its own catastrophic watchdog, latch-gated as above.
A test's timers are a system: widen them together. When a test keeps multiple real deadlines (a production deadline plus the ceiling that observes it, a pacer plus the await that watches its reap), they form one system. Widening one alone breaks the ordering the discrimination depends on: a ceiling shorter than the deadline it observes races the very event it exists to catch, and a "must complete before X" budget left tight while X is widened deletes the coverage. Scale them together, preserving every a < b the test relies on.
The production-deadline race. A recurring flake shape: a test arms a short production deadline (a handshake or connect timeout, a cancel budget, a container stop-timeout, an fd-drain window) and then its own setup or observation must win a race against that live timer, measuring no elapsed time, so a textual scan misses it. Fix with one of: (a) a barrier on the event the deadline produces (a reap completion, a close promise, a marker file); (b) the production deadline scaled far above the operation's real cost so it fires only on a genuine hang, a documented catastrophic margin; (c) virtual time, when the deadline is engine-internal and Clock-driven. Virtual time is preferred but not always reachable: when the deadline is reset across a seam with no sleep to fence, a withTimeControl advancer races past it, and the coupled-margin fallback (b) is correct. Confirm which one holds by running the converted leaf, never by reasoning alone.
Checklist before adding a timing-touching test: does the assertion depend on real elapsed time (if so, move it under withTimeControl); is a Thread.sleep or bare Async.sleep used to coordinate (replace with a barrier); is a duration or timeout the pass condition (assert the state or event it produces instead); is an in-test timeout there only to catch a hang on a kyo.test.Test suite (remove it, the per-leaf cap already does that); if the real clock is unavoidable, is the deviation commented and reported.
Framework
Test suites extend kyo.test.Test[Any], directly or through a module base that extends it. The type parameter is an additive extra effect row; Any is the common case (baseline Async & Abort[Any] & Scope only). Test files are named FooTest.scala and mirror the main source structure.
A module base exists only when the module's suites share configuration or a fixture (a tighter timeout, an aroundLeaf, a helper). It lives in the module's test sources, as a Test.scala in the module's package or as a named base such as ParseTestBase, and extends kyo.test.Test[Any].
A project runs on kyo-test when its build definition registers kyo-test's framework: a cross project calls .withKyoTest, and a JVM-only project adds the same runner wiring by hand. Projects without that wiring test on ScalaTest, apart from kyo-zio-test, which tests on the zio-test framework it integrates. kyo-kernel is the ScalaTest project to know, since its suites assert on the scheduler state kyo-test would run them on (see kyo-kernel's guide). Everything else in this section describes kyo-test.
class ChannelTest extends kyo.test.Test[Any]:
"put and take" in {
for
channel <- Channel.init[Int](2)
_ <- channel.put(1)
v <- channel.take
yield assert(v == 1)
}
Modules that share a common fixture define a thin abstract base:
// e.g. kyo-parse
abstract class ParseTestBase extends kyo.test.Test[Any]
class ParserTest extends ParseTestBase:
"parse empty" in {
assert(parse("") == Result.unit)
}
Test Patterns by Level
Pure / synchronous: no effects, leaf body is Unit:
"value equality" in {
assert(Maybe(42) == Maybe(42))
}
Effectful: the leaf body is Unit < (Async & Abort[Any] & Scope); Kyo effects compose directly with no run wrapper:
"put and take" in {
for
channel <- Channel.init[Int](2)
_ <- channel.put(1)
v <- channel.take
yield assert(v == 1)
}
Groups are registered with - (always a group; body runs at registration time to collect nested leaves):
"channel" - {
"bounded" - {
"put and take" in { ... }
}
}
Assertion Model
assert(cond) is a power-assert macro: on failure it prints a diagram of subexpression values. Every leaf must evaluate at least one assertion; a leaf that completes without any assertion is failed by default (the failOnNoAssertion check). To explicitly mark a leaf as asserting no runtime value, write succeed (or succeed("why")) in the body.
To disable the check for a whole suite:
override def config = super.config.failOnNoAssertion(false)
Compile-Time Tests
Verify that code compiles or fails to compile:
"valid code compiles" in {
typeCheck("Env.get[Int]")
}
"invalid code fails with expected message" in {
typeCheckFailure("Layer.init[String]()")("Missing Input: scala.Predef.String")
}
The string passed to typeCheckFailure is a substring match against the compiler error.
Concurrent Test Helpers
assertEventually(cond) retries cond every 10ms until it yields true, bounded by the per-test timeout. Use it for eventually-consistent concurrent state instead of sleeps:
"counter reaches 1" in {
for
counter <- AtomicInt.init(0)
_ <- Fiber.init(counter.incrementAndGet.unit)
_ <- assertEventually(counter.get.map(_ > 0))
yield ()
}
Debugging Hangs and Timeouts
When a leaf stalls past the heartbeat interval or hits its timeout, the runner captures a full thread dump and every registered diagnostic snapshot alongside the failure. This makes a rare hang or deadlock root-causable from a single occurrence, rather than needing a second, instrumented reproduction.
Runtime components publish their live internal state through the process-global registry kyo.internal.Diagnostics. Register a named dumper (typically at construction) and close the registration when the component shuts down so it does not accumulate across a long-lived process:
private val diag =
Diagnostics.register("ConnectionPool@" + java.lang.System.identityHashCode(this)) { () =>
s"active=${active.size} idle=[${idleKeys.mkString(" ")}]"
}
// on shutdown:
diag.close()
A dumper runs on the reporter's thread, concurrently with the component it inspects, so it must read state best-effort (a stale or partial snapshot is acceptable) and must never block. An I/O driver, for example, can register its pending-operation tables and poll/reap liveness counters this way.
Exception and Failure Assertions
intercept[E](body) asserts that body throws an exception of type E and returns the caught exception:
"throws on invalid input" in {
val ex = intercept[IllegalArgumentException] {
parseStrict("")
}
assert(ex.getMessage.contains("empty"))
}
Skipping and Preconditions
assume(cond, msg) cancels (not fails) the test when a precondition is not met. cancel(msg) cancels unconditionally. Use these for platform-specific prerequisites rather than guarding with if:
"requires multiple cores" in {
assume(Runtime.getRuntime.availableProcessors > 1, "requires multi-core")
// ...
}
Disabling and Focusing Tests
These decorators chain on a leaf name before in:
"not written yet" .ignore in { ... }
"reconnect after reset" .ignore("issue #42: deadlocks the pool") in { ... }
"iterating on this one" .focus in { ... }
"needs a database" .only(databaseAvailable) in { ... }
.ignoreregisters the leaf, never runs its body, and reports itIgnored..ignore(reason)records why; give the reason whenever a test is disabled..focusruns only the focused leaves; every other leaf reportsSkipped. It is an editing aid, not something to commit..only(cond)runs the leaf only whencondis true at registration; otherwise it reportsSkipped.- To keep running a known-broken body, use
.pendingUntilFixed(reason)(see Decorators).
Platform-Conditional Tests
Platform gates restrict a leaf or group to one or more platforms. On a disabled platform the body is compile-excluded (absent, not skipped). .jvm, .js, .native and .wasm enable a platform; .notJvm, .notJs, .notNative and .notWasm disable one. Wasm counts as JS: .js includes Wasm and .notJs excludes it, while .wasm and .notWasm select Wasm alone. So .notNative still runs on Wasm. Chained gates intersect:
"jvm only" .jvm in { ... }
"not native" .notNative in { ... } // JVM, JS and Wasm
"jvm or js" .notNative.notWasm in { ... }
Decorators
Decorators chain on a leaf name before in:
"flaky network call" .flaky in { ... } // retry up to 3x, tags "flaky"
"slow integration" .timeout(120.seconds) in { ... }
"known broken" .pendingUntilFixed("issue #42") in { ... }
"retry on failure" .retry(3) in { ... }
Per-Suite Configuration
Override config to control parallelism, timeout, and other run settings:
// Run all leaves in this suite sequentially
override def config = super.config.sequential
// Change the default per-leaf timeout
override def timeout = 30.seconds
Override aroundLeaf to wrap every leaf with shared setup or teardown:
override def aroundLeaf[A](body: A < (Async & Abort[Any] & Scope))(using Frame) =
HttpClient.withConfig(_.timeout(60.seconds))(body)
Unsafe Boundary
The Two-Tier API Pattern
Every concurrent type (Channel, Queue, Hub, Fiber, Meter, Latch, Signal, etc.) exposes two parallel APIs: a safe tier that tracks effects in the type system, and an Unsafe tier for integrations, libraries, and performance-sensitive code that bypasses effect tracking.
Users can always navigate between the two tiers:
.unsafeon any safe instance returns theUnsafecounterpart.safeon anyUnsafeinstance returns the safe counterpart
The two tiers mirror each other: every operation available on the safe API has an Unsafe equivalent, and vice versa. The safe tier wraps operations in effects (Sync, Abort[Closed], Async), while the Unsafe tier returns raw values and Results, guarded by (using AllowUnsafe).
Structure: For a type T, expect to find:
T: the safe type with effectful methods (takesusing Frame)T.Unsafe: the form depends on the type:sealed abstract classwhen multiple implementations are needed (e.g., open/closed states), oropaque typefor zero-cost wrapping of a single Java/platform type (e.g.,AtomicInt.Unsafe = AtomicInteger). Operations take(using AllowUnsafe)instead of(using Frame)T.Unsafe.init(...): factory in theUnsafecompanion (takesusing AllowUnsafe; addFrameonly if the factory uses it, e.g., to capture creation context forClosed). Unsafe methods should return raw values orResults, not effectful computations (A < S).Framein unsafe code is primarily for capturing creation context, not for effect suspensionT.init(...)/T.use(...)/T.initUnscoped(...): safe factories that delegate toUnsafe.initwith lifecycle management (see theinitfamily in Naming and Closeable Resource Pattern)
Safe→Unsafe bridge: Safe methods enter the unsafe tier via Sync.Unsafe.defer { ... }, which provides AllowUnsafe implicitly. Inside, they call the Unsafe method and wrap Result[Closed, A] in Abort.get to convert to Abort[Closed] effect.
Subtypes preserve the pattern: Queue.Unbounded has Queue.Unbounded.Unsafe that extends Queue.Unsafe. The subtype relationship holds on both tiers.
Unsafe API Conventions
- WARNING scaladoc on every
Unsafeclass andobject Unsafe, always the same text: "Low-level API meant for integrations, libraries, and performance-sensitive code. See AllowUnsafe for more details." Skip detailed method-level scaladocs forUnsafeAPIs; the WARNING class-level scaladoc is sufficient. Add method-level docs only when the behavior is non-obvious or differs from the safe counterpart. (using AllowUnsafe)on every method that performs side effects without suspension. Pure accessors likecapacitydon't need it.extends Serializableonsealed abstract class Unsafefor closeable types- Closeable operations return
Result[Closed, A]; never throw on closed state - Factory methods in
object Unsafetake(using AllowUnsafe). AddFrameonly when the factory actually uses it (e.g., to capture the creation context forClosederrors)
AllowUnsafe Tiers
All side effects must be suspended. No side-effecting code should execute outside of Kyo's effect system without either an AllowUnsafe proof or a suspension boundary like Sync.Unsafe. This is a hard rule: unsuspended side effects break referential transparency.
In order of preference:
-
Propagate the proof: the caller explicitly opts in (preferred for performance, since there is no suspension overhead):
def init[A](capacity: Int)(using AllowUnsafe): Queue.Unsafe[A] -
Suspend in Sync: wraps the unsafe operation in an effect:
def offer(v: A)(using Frame): Boolean < (Sync & Abort[Closed]) = Sync.Unsafe.defer(Abort.get(self.offer(v))) -
Import danger: external runtime callbacks (Netty listeners, platform interop), application boundaries (KyoApp, tests), and initialization of globally shared module-level values that need unsafe operations at class loading time (e.g.,
Clock.live, which creates its executor there):import AllowUnsafe.embrace.danger
Scope AllowUnsafe as narrowly as possible. Never place (using AllowUnsafe) on a constructor or class-level import where it leaks to all methods: this masks accidental unsafe operations that the compiler would otherwise catch. Instead, take it only on the specific methods that need it, or scope the import to the smallest block possible:
// Wrong: leaks AllowUnsafe to every method in the class
class MyConnection(...)(using AllowUnsafe):
private val flag = AtomicBoolean.Unsafe.init()
def send(...) = ... // compiler won't catch unsafe ops here
// Right: scoped to the specific initialization
class MyConnection(..., closed: AtomicBoolean):
def isAlive(using AllowUnsafe): Boolean = !closed.unsafe.get()
def send(...) = ... // compiler catches accidental unsafe ops
Prefer the safe type, access .unsafe when needed. When a type has safe/unsafe tiers, hold the safe version and use .unsafe only in methods that already have AllowUnsafe in scope. This ensures the compiler enforces safety by default:
// Preferred: hold safe type, access .unsafe in AllowUnsafe methods
class MyConnection(closed: AtomicBoolean):
def isAlive(using AllowUnsafe): Boolean = !closed.unsafe.get()
def close(using Frame): Unit < Async = closed.set(true).andThen(...)
// Avoid: holding Unsafe type bypasses safety checks everywhere
class MyConnection(closed: AtomicBoolean.Unsafe):
def isAlive(using AllowUnsafe): Boolean = !closed.get()
AllowUnsafe for Zero-Allocation Side Effects
AllowUnsafe is a compiler-enforced proof that the side effect has already been suspended at an outer scope. Methods can perform side effects directly without allocating a Sync.Unsafe suspension, while the proof guarantees the call chain is rooted in a properly suspended context.
This is the mechanism behind the safe→unsafe bridge. The safe tier suspends via Sync.Unsafe, which provides AllowUnsafe implicitly, then calls the Unsafe method that performs the side effect directly:
// Safe tier: suspends once, then delegates
def set(v: Boolean)(using Frame): Unit < Sync = Sync.Unsafe.defer(unsafe.set(v))
// Unsafe tier: performs the side effect directly, no allocation
// The AllowUnsafe proof guarantees an outer scope has already suspended
extension (self: Unsafe)
inline def set(v: Boolean)(using AllowUnsafe): Unit = self.set(v)
The same pattern applies to internal APIs. When a method takes (using AllowUnsafe), it's declaring: "I perform side effects, but I trust my caller to have suspended." This allows multiple unsafe operations to compose without each one wrapping in its own Sync.Unsafe:
// One suspension covers multiple unsafe operations, with no per-operation allocation
def release(conn: Connection)(using Frame, AllowUnsafe): Unit =
if conn.isAlive then // unsafe: reads atomic flag
idleChannels.offer(conn) // unsafe: mutates concurrent queue
else
conn.closeAbruptly() // unsafe: closes connection
Without AllowUnsafe, each of these would need its own Sync.Unsafe wrapper, allocating a closure each time. The AllowUnsafe proof eliminates this overhead by hoisting the suspension to the outermost boundary.
Closeable Resource Pattern
All closeable resources follow:
// Closed (kyo-kernel) carries creation context for diagnostics
final class Closed(resource: String, createdAt: Frame, details: String = "")(using Frame)
// Unsafe level returns Result[Closed, A]
def offer(v: A)(using AllowUnsafe): Result[Closed, Boolean]
// Safe level converts to Abort[Closed]
def offer(v: A)(using Frame): Boolean < (Sync & Abort[Closed]) =
Sync.Unsafe.defer(Abort.get(self.offer(v)))
Resource factory convention: all variants delegate down to Unsafe.init. The close each variant registers is the type's own choice; Channel below registers close with its Scope and closeDiscard in use.
| Method | Lifecycle | Effect Set | Delegates to |
|---|---|---|---|
init | Scope-managed cleanup | Sync & Scope | initWith(identity) |
initWith(f) | Scope-managed + callback | Sync & Scope & S | Unsafe.init + Scope.ensure(close) |
use(f) | Bracket (no Scope needed) | Sync & S | Unsafe.init + Sync.ensure(close) |
initUnscoped | No cleanup guarantees | Sync | initUnscopedWith(identity) |
initUnscopedWith(f) | No cleanup + callback | Sync & S | Bare Unsafe.init |
The delegation chain (using Channel as canonical example):
// init delegates to initWith with identity: Scope-managed lifecycle
def init[A](capacity: Int, access: Access)(using Frame): Channel[A] < (Sync & Scope) =
initWith[A](capacity, access)(identity)
// initWith: create resource, register Scope cleanup, apply callback
inline def initWith[A](capacity: Int, access: Access)[B, S](
inline f: Channel[A] => B < S
)(using inline frame: Frame): B < (S & Sync & Scope) =
Sync.Unsafe.defer:
val channel = Unsafe.init[A](capacity, access)
Scope.ensure(Channel.close(channel)).andThen:
f(channel)
// use: bracket semantics via Sync.ensure, no Scope in the effect set
inline def use[A](capacity: Int, access: Access)[B, S](
inline f: Channel[A] => B < S
)(using inline frame: Frame): B < (S & Sync) =
Sync.Unsafe.defer:
val channel = Unsafe.init[A](capacity, access)
Sync.ensure(Channel.closeDiscard(channel)):
f(channel)
// initUnscoped delegates to initUnscopedWith with identity: no cleanup
def initUnscoped[A](capacity: Int, access: Access)(using Frame): Channel[A] < Sync =
initUnscopedWith[A](capacity, access)(identity)
// initUnscopedWith: bare Unsafe.init, no cleanup registered
inline def initUnscopedWith[A](capacity: Int, access: Access)[B, S](
inline f: Channel[A] => B < S
)(using inline frame: Frame): B < (S & Sync) =
Sync.Unsafe.defer(f(Unsafe.init[A](capacity, access)))
Choose init (Scope-managed) by default. Use use when you want bracket semantics without Scope in the effect set. Use initUnscoped only when the caller manages lifecycle manually.
Close Method Convention
The close variants depend on what the resource holds when it closes.
A resource whose shutdown has a drain window (in-flight requests, open connections) provides three variants. The parameterized version is the canonical implementation; the others delegate to it:
// Canonical: takes an explicit grace period
def close(gracePeriod: Duration)(using Frame): Unit < Async
// Default: delegates with a default grace period
def close(using Frame): Unit < Async = close(30.seconds)
// Immediate: delegates with zero grace period
def closeNow(using Frame): Unit < Async = close(Duration.Zero)
This gives callers control without forcing them to pick a timeout for the common case, and closeNow states that a close does not wait.
A resource that buffers elements (Channel, Queue, Hub) has no grace period. Its close returns the elements it still held (Maybe[Seq[A]] < Async), and closeDiscard drops them and stays in Sync. Channel and Queue also provide closeAwaitEmpty, which closes to new elements and completes once the remaining ones are consumed.
Local-Backed Service Pattern
Types like Clock, Log, Random, Console, System, and HttpClient follow a common pattern: a Local holds a default instance, and the companion exposes get/use/let to interact with it. Convenience methods on the companion delegate through the local so callers never need the instance directly.
final case class Clock(unsafe: Clock.Unsafe):
// Instance methods operate on `this`
def now(using Frame): Instant < Sync = ...
object Clock:
private val local = Local.init(live)
// Access the local instance
def get(using Frame): Clock < Any = local.get
def use[A, S](f: Clock => A < S)(using Frame): A < S = local.use(f)
// Swap the instance for a scope
def let[A, S](c: Clock)(f: => A < S)(using Frame): A < S = local.let(c)(f)
// Convenience: delegates through the local
def now(using Frame): Instant < Sync = ...
def sleep(duration: Duration)(using Frame): Unit < Async = ...
Key points:
- The
Localis private; external code uses onlyget/use/let - Companion convenience methods delegate to the local instance so most callers never call
getoruseexplicitly letenables testing by substituting a mock/controlled instance- The live default means the service works out of the box with no setup
KyoException Convention
All custom exceptions extend KyoException, which provides:
NoStackTracefor performance (stack traces are expensive and rarely useful for expected errors)Frame-based context that captures the creation site- Environment-aware formatting (rich ANSI in dev, minimal in prod)
final class Closed(resource: String, createdAt: Frame, details: String = "")(using Frame)
extends KyoException(render"$resource created at ${createdAt.position.show} is closed.", details)
Follow this pattern for all new exception types:
- Extend
KyoException, notExceptionorRuntimeException.KyoExceptionalready mixes inNoStackTrace, so a subclass does not need to add it - Take
(using Frame)to capture context - Use
Stringfor messages - Keep the message concise; the
Frameprovides the location context
Effect Implementation Reference
NOTE: This section is a reference for the uncommon task of implementing a new effect. For everyday coding, the sections above are sufficient.
Anatomy of an Effect
The kernel side of this work (handler variants, region rows, context strategies, isolates) is covered in depth by kyo-kernel's guide; the scaladoc on each ArrowEffect and ContextEffect handler states its contract.
-
Type definition: a sealed trait extending one of two base classes:
ArrowEffect[I, O]: for function-like effects that transform inputs to outputs. Operations are encoded as an ADT and answered by a handler. Used byAbort,Var,Emit,Choice,Poll.ContextEffect[A]: for value-providing effects (dependency injection). No ADT or handler loop is needed:ContextEffect.handleInheritable(tag, value)(computation)binds a value that forked computations inherit,ContextEffect.handleNonInheritable(tag, value)(computation)binds one that a fork does not carry, andContextEffect.handletakes explicitforkandjoinstrategies. Used byEnvand byLocal, whose per-local fork strategy (Local.init(default)(forkValue), whereAbsentmeans not inherited, andLocal.initNoninheritable) lives in the value it binds.
sealed trait Var[V] extends ArrowEffect[Const[Op[V]], Const[V]] // ArrowEffect sealed trait Env[+R] extends ContextEffect[TypeMap[R]] // ContextEffect -
Operations as data: encode operations as an ADT, not as methods:
object internal: type Op[V] = Get.type | V | Update[V] object Get abstract class Update[V]: def apply(v: V): VUpdateis a class rather than aV => Valias so the handler'scase input: Update[V]stays distinguishable fromcase input: V(see the union discriminability rule in Zero-Cost Type Design). -
Suspend: translate domain operations into kernel inputs:
inline def get[V](...): V < Var[V] = use[V](identity) inline def use[V](...)(inline f: V => A < S)(...) = ArrowEffect.suspendWith[V](tag, Get: Op[V])(f) inline def set[V](inline value: V)(...) = ArrowEffect.suspend[Unit](tag, value: Op[V]) -
Handle: choose the kernel handler by what the clause needs (kyo-kernel's "Recipe: choose and write the handler" covers the
*Withandrecoverforms):Handler When to use ArrowEffect.handleContThe clause holds the continuation and applies it at most once, or not at all (early exit) ArrowEffect.handleContRepeatedThe clause applies the continuation more than once (backtracking, non-determinism) ArrowEffect.handleLoopAnswer each occurrence with Loop.continue(answer), no state between occurrencesArrowEffect.handleLoopStateThread state between occurrences with Loop.continue(nextState, answer)Each has an overload with a
doneclause that transforms the region's final value.Var.runWiththreads the variable throughhandleLoopState:ArrowEffect.handleLoopState(tag, state, v)( [C] => (state, input) => input match case input: Get.type => Loop.continue(state, state) case input: Update[V] @unchecked => val nst = input(state) Loop.continue(nst, nst) case input: V @unchecked => Loop.continue(input, state), done = f )To answer a failure of the handled body, use the overload that adds a
recover: Throwable => Maybe[B < (S & S2)]clause. Converting exceptions from user code into effect errors isAbort.catching. -
Reducible.Eliminablegiven if the effect can be fully eliminated:given eliminateAbort: Reducible.Eliminable[Abort[Nothing]] with {} -
Reduciblein handler signatures: when an effect has union types (e.g.,Abort[E | ER]), handlers useReducibleto allow partial handling. The handler eliminatesEand reduces the remainingERviareduce.SReduced:def run[E](using Frame)[A, S, ER]( v: => A < (Abort[E | ER] & S) )(using ct: ConcreteTag[E], reduce: Reducible[Abort[ER]] ): Result[E, A] < (S & reduce.SReduced)This lets callers handle one layer of a union error type while preserving the rest in the effect stack.
-
Variants delegate to canonical; never duplicate handler logic:
def run[V, A, S](state: V)(v: A < (Var[V] & S)): A < S = runWith(state)(v)((_, result) => result) def runTuple[V, A, S](state: V)(v: A < (Var[V] & S)): (V, A) < S = runWith(state)(v)((state, result) => (state, result))
Delegation Pattern for Higher-Level Types
Higher-level types delegate to lower-level ones instead of reimplementing them:
| Higher | Delegates to | Adds |
|---|---|---|
Channel | Queue | Fiber-aware put/take with suspension |
Hub | Channel + Fiber | Fan-out distribution fiber |
Async | Fiber | Structured concurrency, isolation |
Stream | Emit[Chunk[V]] | Chunked processing, transformations |
Meter.pipeline | Seq[Meter] | Composed admission control |
Build new types by composing existing ones. Create anonymous instances when composition logic varies:
def pipeline[S](meters: Seq[Meter < (Sync & S)])(using Frame): Meter < (Sync & S) =
Kyo.collectAll(meters).map { seq =>
new Meter:
def run[A, S](v: => A < S)(using Frame) = ...
}
Isolate Protocol for Fiber-Crossing Operations
Methods that fork computations carrying arbitrary effect state (A < (Abort[E] & Async & S)) into new fibers must use the three-step Isolate protocol to safely move effectful state across fiber boundaries. When the forked computation has a concrete type with no extra effects (e.g., Int < Async), no Isolate is needed because there is no effect state to transfer across the fiber boundary.
A schematic of the three steps, not the code of any one method. Async.race takes the Isolate in this position and passes it to Fiber.internal.race, which captures once and runs each isolated computation inside its own fiber:
def forkAll[E, A, S](
using isolate: Isolate[S, Abort[E] & Async, S] // Isolate comes first in using clause
)(computations: Seq[A < (Abort[E] & Async & S)])(using Frame): A < (Abort[E] & Async & S) =
isolate.capture { state => // 1. capture current state
forkEach(computations.map(isolate.isolate(state, _))) // 2. attach state to each forked computation
.map(winner => isolate.restore(winner)) // 3. restore state from the completed fiber
}
Note the using parameter ordering: Isolate precedes Frame because it participates in type inference.
Effects that carry state across fibers provide standard isolate strategies in an object isolate namespace in their companion:
update: the forked fiber gets the current state; on completion, the outer state is replaced with the forked fiber's final statemerge(f): combines the outer and inner states using a merge functionfdiscard: the forked fiber gets the current state but its final state is thrown away
Example: Var.isolate.update, Var.isolate.merge((a, b) => a + b), Var.isolate.discard.