kyo-system
kyo-system is the file I/O, process execution, and environment layer for Kyo. File operations are tracked through the PathRead and PathWrite capabilities: reads and writes suspend under these effects and are discharged by a runner (Path.run, Path.runReadOnly, or their runWith variants), which folds the per-operation Abort[File*Exception] markers into the umbrella Abort[FileSystemException]. Process launches carry Abort[CommandException], and handles are Scope-managed for automatic cleanup. The same API compiles across JVM, Scala Native, and JavaScript (Node.js).
The following example reads a configuration file, fetches a git revision, runs a build step, and writes an artifact record. Every filesystem op runs inside Path.run, which installs the default host service and discharges both capabilities:
import kyo.*
val deploy =
Path.run {
for
config <- (Path / "etc" / "deploy.toml").read
version <- Command("git", "rev-parse", "--short", "HEAD").text
_ <- Command("sbt", "assembly").cwd(Path("backend")).waitForSuccess
_ <- (Path("dist") / "version.txt").write(version)
yield ()
}Path capabilities
Filesystem I/O is capability-tracked, not method-tracked. A program that only reads carries < PathRead in its row; a program that writes carries < PathWrite (which also satisfies reads). Sync, Scope, and the Abort[FileSystemException] umbrella appear on the runner residual after discharge, not on individual extension methods.
| Runner | Discharges | Residual (host service) |
|---|---|---|
Path.run(program) | PathWrite (and PathRead via subtyping) | Sync & Abort[FileSystemException] & S |
Path.runReadOnly(program) | PathRead only | Sync & Abort[FileSystemException] & S |
Path.runWith(service)(program) | PathWrite against a custom service | S & Abort[FileSystemException] & S2 |
Path.runReadOnlyWith(service)(program) | PathRead against a custom service | S & Abort[FileSystemException] & S2 |
PathWrite <: PathRead: a write-capable context also satisfies read operations, and Path.runReadOnly rejects write programs at the call site (the negative capability law).
Path.run and Path.runReadOnly use the backend selected by FileSystem.let. The default is the local host backend. Selection is dynamically scoped, inherited by child fibers, and restored when the block exits.
Backends advertise the authority they actually provide. FileSystem.Read[S] can be passed only to Path.runReadOnlyWith; FileSystem.Write[S] supports both runners. This distinction prevents a caller from accidentally gaining mutation authority through a read-only service.
A backend proves it behaves like the host by passing the conformance suites published as kyo-system-conformance, one abstract class per tier it implements: FileSystemReadConformanceTest, FileSystemWriteConformanceTest and FileSystemWatchConformanceTest. Its tests add the artifact in test scope, wire kyo-test's runner, and extend each class with a fixture that hands the suite a fresh backend. On JS the test link needs ModuleKind.CommonJSModule, since kyo-system reaches Node builtins through require.
// build.sbt
libraryDependencies += "io.getkyo" %%% "kyo-system-conformance" % "<version>" % Test
// src/test/scala
class S3FileSystemWriteTest extends FileSystemWriteConformanceTest[Async]:
protected def withFileSystem[A](
use: (FileSystem.Write[Async], Path) => A < (Async & Scope & Abort[FileSystemException])
)(using Frame): A < (Async & Scope & Abort[FileSystemException]) =
S3FileSystem.init(testBucket).map(files => use(files, Path("conformance")))Portable matching uses a compiled Glob, never a platform-specific string matcher:
import kyo.*
val scalaFiles =
Path.runReadOnly {
Path("src").walk(glob"**/*.scala", Glob.CaseSensitivity.Sensitive).run
}Typed channels
Positioned channels are acquired directly from a backend and owned by Scope. Read, write, and read-write channels expose only their corresponding capabilities. WriteOpen makes file existence policy explicit:
import kyo.*
val positioned =
Scope.run {
FileSystem.host.openReadWriteChannel(Path("index.bin"), FileSystem.WriteOpen.CreateNew).map { channel =>
channel.writeAt(0L, Span.from(Array[Byte](1, 2, 3))).andThen(channel.readAt(0L, 3))
}
}Locks and watchers
Advisory locks are scope-managed. Choose shared or exclusive compatibility and an explicit waiting policy. The claim is taken on a sentinel sibling of the path (state.bin.kyo-lock here), never on the path itself, so reading and writing the locked path while holding the lock is safe. Watchers have their own PathWatch capability and are registered before acquisition returns:
import kyo.*
val guarded = Scope.run {
Path.run {
Path("state.bin").lock(Path.LockMode.Exclusive, Path.LockWait.Immediate).map { lock =>
Path("state.bin").write("next").andThen(lock.check)
}
}
}
val changes = Scope.run {
Path.runWatch {
Path("src").openWatcher().map(_.events.take(1).run)
}
}File paths
Before reading or writing, you need a path value that identifies the target without touching the disk. Path is an immutable value built with the / operator or the apply factory; pure accessors like parts and parent require no capability.
The segment type Part accepts either a String or another Path; splicing a Path value expands its components inline:
import kyo.*
val config: Path = Path / "etc" / "myapp" / "config.toml"
val data: Path = Path("var", "data", "myapp")
// Splice an existing Path into another path
val base: Path = Path("home") / "user"
val nested: Path = base / Path("projects", "kyo")
// Pure accessors require no Path capability
val parts: Chunk[String] = config.partsFilesystem reads and writes require a runner. Wrap read-only programs in Path.runReadOnly and read-write programs in Path.run:
import kyo.*
val text: String < (Sync & Abort[FileSystemException]) =
Path.runReadOnly((Path / "etc" / "app.toml").read)Path.fileSeparator is the segment separator ("/" or "\\") and Path.pathSeparator is the classpath-style delimiter (":" or ";") for the current OS.
Pure accessors require no effects:
| Accessor | Type | Description |
|---|---|---|
parts | Chunk[String] | Individual segments |
name | Maybe[String] | Final segment; Absent for a root or empty path |
parent | Maybe[Path] | Containing directory |
extName | Maybe[String] | Extension including the leading dot, e.g. ".gz"; a leading dot in the filename is not treated as an extension |
isAbsolute | Boolean | Whether the path starts at a filesystem root |
ancestors is a pure Stream[Path, Any] that yields self, then its parent, grandparent, and so on up to the filesystem root without reading the disk. Use Stream.find to locate the nearest ancestor that satisfies a predicate, such as the first directory containing a build.sbt marker:
import kyo.*
val src: Path = Path("home") / "user" / "project" / "src"
// Walk toward the root; return the first ancestor that contains build.sbt
val projectRoot: Maybe[Path] < (Sync & Abort[FileSystemException]) =
Path.runReadOnly {
src.ancestors.find(ancestor => (ancestor / "build.sbt").exists)
}Inspecting files
Before opening a file for read or write, check whether the path exists and what kind of entry it is. exists, isDirectory, isRegularFile, and isSymbolicLink suspend under PathRead. An inaccessible path produces false rather than failing, so a missing config file and a permission-denied path both return false instead of aborting. Run them inside Path.runReadOnly:
import kyo.*
val path: Path = Path / "dist" / "release" / "artifact.jar"
val checks: (Boolean, Boolean) < (Sync & Abort[FileSystemException]) =
Path.runReadOnly {
for
e <- path.exists
f <- path.isRegularFile
yield (e, f)
}exists(followLinks: Boolean) controls symlink traversal. All four methods return false on any permission or access failure.
realPath resolves every symbolic link in the chain and returns the canonical absolute path. It fails with FileNotFoundException if any element of the path does not exist, or FileAccessDeniedException if the filesystem denies access:
import kyo.*
val canonical: Path < (Sync & Abort[FileSystemException]) =
Path.runReadOnly {
(Path / "var" / "run" / "app.sock").realPath
}Reading files
Once you know where a file lives, bulk reads and streams pull its contents under PathRead. After Path.runReadOnly, the residual is Sync & Abort[FileSystemException]:
import kyo.*
val path: Path = Path / "etc" / "app" / "config.toml"
val text: String < (Sync & Abort[FileSystemException]) =
Path.runReadOnly(path.read)
val bytes: Span[Byte] < (Sync & Abort[FileSystemException]) =
Path.runReadOnly(path.readBytes)
val lines: Chunk[String] < (Sync & Abort[FileSystemException]) =
Path.runReadOnly(path.readLines)All three accept an optional java.nio.charset.Charset; the default is UTF-8.
stat returns PathStat(lastModifiedMs, sizeBytes) from a single underlying syscall, which guarantees both fields reflect the same measurement instant. Prefer stat over separate size and last-modified calls when both are needed:
import kyo.*
val info: Path.PathStat < (Sync & Abort[FileSystemException]) =
Path.runReadOnly((Path / "var" / "data" / "records.db").stat)
val sz: Long < (Sync & Abort[FileSystemException]) =
Path.runReadOnly((Path / "var" / "data" / "records.db").size)Streaming reads keep only a buffer in memory at a time. The OS handle is opened when the stream starts and released when the enclosing Scope closes, whether by normal completion, error, or cancellation. All streaming read methods carry Scope in the stream's effect row:
import kyo.*
val processed: Unit < (Sync & Scope & Abort[FileSystemException]) =
Path.runReadOnly {
Path("var", "log", "events.ndjson")
.readLinesStream
.foreach(line => Sync.defer(println(line)))
}readStream(charset, bufferSize) and readBytesStream(bufferSize) expose the buffer-size parameter for tuning. walk is a Scope-managed stream of directory entries (covered under Directory operations).
tail polls for new content appended to a file. It seeks to EOF, then sleeps for the configured pollDelay (default 100ms) and reads any new bytes. When the file size decreases it resets to position 0, handling log rotation and truncation. The stream carries Async because of the poll sleep. tail is a poll loop, not a kernel watch API (inotify or kqueue):
import kyo.*
val errors: Unit < (Async & Scope & Sync & Abort[FileSystemException]) =
Path.runReadOnly {
Path("var", "app.log")
.tail(500.millis)
.filter(_.contains("ERROR"))
.foreach(line => Sync.defer(println(line)))
}For typed JSONL/NDJSON decoding, including per-record error recovery and watch mode, use Jsonl from kyo-schema-json.
tailBytes follows a file without text decoding or line splitting. Path.Origin controls where reading begins:
import kyo.*
val path = Path("var", "log", "events.ndjson")
val fromStart: Stream[Byte, PathRead & Async & Scope] =
path.tailBytes(Path.Origin.Start)
val fromEnd: Stream[Byte, PathRead & Async & Scope] =
path.tailBytes()
val fromOffset: Stream[Byte, PathRead & Async & Scope] =
path.tailBytes(Path.Origin.Offset(4096))tailBytes defaults to Origin.End, so it emits only bytes appended after the stream attaches. Origin.Start replays the file before following it, and Origin.Offset resumes from a recorded byte position.
Read buffer sizes are ByteSize values, so readStream, readBytesStream, tail, and tailBytes take a buffer as 8.kib rather than as a bare number. A buffer is clamped to the range an array can address: ByteSize.Zero reads one byte at a time rather than spinning on a buffer that holds nothing, and anything above Int.MaxValue bytes reads through the largest buffer there is. Path.Origin.Offset stays a numeric byte position, because it names a place in a file rather than an amount of storage.
Following tracks the open file, not the path name. Rename-based rotation keeps reading the original file, deletion produces no further bytes and no failure while the open handle remains valid, and truncation in place rewinds to byte 0. Rename and deletion follow POSIX descriptor behavior; Windows may refuse those operations for an open file. A consumer that needs a replacement rotated into the original name must close the stream and open a new one.
Path.ReadResult is the typed wrapper around the raw byte count returned by low-level read operations: ReadResult.Eof signals end-of-file, and a positive value is the number of bytes read.
Writing files
Persisting output or mutating the tree requires PathWrite. Run write methods inside Path.run. Write methods create parent directories by default (createFolders = true). Pass createFolders = false to fail when the parent is absent:
import kyo.*
val out: Path = Path("dist") / "build" / "version.txt"
val w: Unit < (Sync & Abort[FileSystemException]) =
Path.run(out.write("1.0.0"))
val a: Unit < (Sync & Abort[FileSystemException]) =
Path.run(out.append("\nbuilt by CI\n"))
val data: Span[Byte] = Span.from(Array[Byte](0x50.toByte, 0x4b.toByte))
val wb: Unit < (Sync & Abort[FileSystemException]) =
Path.run(out.writeBytes(data))writeLines and appendLines follow each line with the platform line separator including the last line. Use write(lines.mkString(sep)) to control the trailing newline yourself. truncate(size) shrinks or pads a file to exactly size bytes. setLastModified(epochMs) sets the last-modified timestamp.
Stream[Byte, S].writeTo(path), Stream[String, S].writeTo(path, charset), and Stream[String, S].writeLinesTo(path, charset) are stream sinks that acquire a write handle in a Scope. The sinks carry PathWrite in their row. If the stream fails, the partially written file is deleted before the error is re-raised:
import kyo.*
val sink: Unit < (Async & Scope & Sync & Abort[FileSystemException]) =
Path.run {
Path("var", "app.log")
.tail
.filter(_.contains("ERROR"))
.writeLinesTo(Path("var", "errors.log"))
}Directory operations
Creating directories, listing children, and moving or deleting entries are write-side mutations that belong with the other PathWrite operations. mkDir creates a directory and all missing parents. mkFile creates an empty file with missing parents. list returns direct children; list(glob) filters by a glob pattern supporting *, **, ?, [...], and {a,b} alternation. walk returns a Scope-managed stream of all entries in the tree:
import kyo.*
val dir: Path = Path("var", "uploads")
val mk: Unit < (Sync & Abort[FileSystemException]) =
Path.run(dir.mkDir)
val all: Chunk[Path] < (Sync & Abort[FileSystemException]) =
Path.runReadOnly(dir.list)
val tree: Stream[Path, PathRead & Scope & Sync] =
dir.walkmove and copy accept Path.MoveOptions and Path.CopyOptions. These named policy values make replacement, required atomicity, attribute copying, link following, and parent creation explicit. remove returns true when the path was deleted and false when it was absent. removeExisting raises FileNotFoundException when the path does not exist. removeAll recursively deletes a directory and all its contents:
import kyo.*
val src: Path = Path("tmp") / "build-output"
val dst: Path = Path("dist") / "release"
val moved: Unit < (Sync & Abort[FileSystemException]) =
Path.run(src.move(dst))
val deleted: Boolean < (Sync & Abort[FileSystemException]) =
Path.run(src.remove)Error handling
FileSystemException is a sealed abstract base. Marker traits partition the hierarchy by operation category:
| Marker | Operations |
|---|---|
FileReadException | Inspection and content reads |
FileWriteException | Content mutation and synchronization |
FileStructureException | Creation, removal, copying, and movement |
FileLockException | Advisory lock acquisition and ownership |
FileWatchException | Watch registration and delivery |
Each concrete exception implements only the marker traits that apply to it. After Path.runReadOnly, the runner folds the markers into Abort[FileSystemException]:
import kyo.*
val content: String < (Sync & Abort[FileSystemException]) =
Path.runReadOnly {
Abort.recover[FileNotFoundException] { _ =>
"# default config\n"
}(Path("etc", "app.toml").read)
}To materialize the error as a Result and decide what to do at the call site:
import kyo.*
val result: Result[FileSystemException, String] < Sync =
Abort.run[FileSystemException] {
Path.runReadOnly(Path("etc", "app.toml").read)
}CommandException is the sealed hierarchy for pre-launch failures:
ProgramNotFoundException(command): raised when the executable is not found on$PATHPermissionDeniedException(command): raised when the caller lacks execute permissionWorkingDirectoryNotFoundException(path): raised when the configuredcwddoes not exist
Standard directories
Path.cwd returns the current working directory. It reads at call time, so a process.chdir or fork with a different working directory takes effect on the next call:
import kyo.*
val cwd: Path < (Sync & Abort[FileSystemException]) =
Path.runReadOnly(Path.cwd)Path.basePaths and Path.userPaths are lazy vals that provide OS-appropriate paths without requiring an application identity. On Linux they follow the XDG Base Directory Specification; on macOS they use the Library hierarchy; on Windows they use APPDATA and LOCALAPPDATA:
import kyo.*
val cacheDir: Path = Path.basePaths.cache
val configDir: Path = Path.basePaths.config
val homeDir: Path = Path.userPaths.home
val downloadDir: Path = Path.userPaths.downloadBasePaths fields: cache, config, data, dataLocal, executable, preference, runtime, tmp. Three fields whose platform meanings are not immediately obvious:
| Field | Linux | macOS | Windows |
|---|---|---|---|
dataLocal | same as data ($XDG_DATA_HOME) | same as data (~/Library/Application Support) | %LOCALAPPDATA% (non-roaming; differs from data at %APPDATA%) |
executable | ~/.local/bin ($XDG_BIN_HOME) | ~/Applications | %LOCALAPPDATA% |
runtime | $XDG_RUNTIME_DIR or ~/.local/run (session-scoped sockets and pipes) | ~/Library/Application Support | %LOCALAPPDATA% |
UserPaths fields: home, audio, desktop, document, download, font, picture, public, template, video.
Path.projectPaths(qualifier, organization, application) derives per-application subdirectories under each base path:
import kyo.*
val proj = Path.projectPaths("com", "myorg", "myapp")
val appConfig: Path = proj.config
val appCache: Path = proj.cache
val appData: Path = proj.dataProjectPaths fields: path, cache, config, data, dataLocal, preference, runtime.
Path.temp(prefix, suffix) creates a temporary file and Path.tempDir(prefix) creates a temporary directory. Both are created on whichever filesystem the runner installed, so both carry PathWrite, and both register removal for when the enclosing Scope closes, so both carry Scope. The removal runs through the service that created the entry, which is what keeps a temp path made by one backend from being deleted by another:
import kyo.*
val tmpFile: Path < (Sync & Scope & Abort[FileSystemException]) =
Path.run {
Path.temp("kyo-build-", ".json")
}
val tmpDir: Path < (Sync & Scope & Abort[FileSystemException]) =
Path.run {
Path.tempDir("kyo-build-")
}Where the entry lands is the service's choice. The host backend uses the OS temporary directory; a confined or in-memory backend puts it wherever that backend keeps its entries.
When the file or directory must outlive the scope that creates it, Path.tempUnscoped and Path.tempDirUnscoped skip the registration and hand removal to the caller. These two are host primitives rather than service calls, because an entry whose lifetime the caller owns has no scope for a service-vended handle to hang from. Reach for them only when something else owns the lifetime, such as a directory passed to a container or a background process that outlives the request:
import kyo.*
val ownedByCaller: Path < (Sync & Abort[FileStructureException]) =
Path.tempDirUnscoped("kyo-container-certs-")On JVM and Scala Native, path.toJava: java.nio.file.Path converts to the standard library type without a cast. It is not available on Scala.js.
Running commands
When a deploy script, build tool, or health check needs to run an external program, Command builds an immutable process description and an execution method launches it. Each builder method returns a new Command; construction performs no I/O.
Arguments are passed directly to the OS with no shell interpretation. Pipes, globs, and variable expansion require an explicit shell:
import kyo.*
// Each string is one OS argument, no expansion
val count: String < (Async & Abort[CommandException]) =
Command("grep", "-rc", "ERROR", "var/log").text
// Shell required for pipe operators
val piped: String < (Async & Abort[CommandException]) =
Command("sh", "-c", "grep ERROR var/log/app.log | wc -l").textBuilder methods compose in any order, each returning a new Command:
import kyo.*
val build: ExitCode < (Async & Abort[CommandException]) =
Command("npm", "run", "build")
.cwd(Path("frontend"))
.envAppend(Map("NODE_ENV" -> "production"))
.redirectErrorStream(true)
.waitFor
val pipeline: String < (Async & Abort[CommandException]) =
Command("cat", "app.log")
.andThen(Command("grep", "ERROR"))
.andThen(Command("head", "-20"))
.textandThen(that) pipes stdout of one command into stdin of the next, equivalent to | in a shell.
Environment configuration:
envAppend(vars)adds or overrides variables on top of the inherited environmentenvRemove(names)removes named variables from the inherited environmentenvReplace(vars)replaces the entire environment with the given mapenvClearclears all variables; the process inherits nothing
Stdin variants:
stdin(s: String): a UTF-8 encoded string (charset overridable)stdin(bytes: Span[Byte]): raw bytesstdin(stream: Stream[Byte, Sync]): a pure byte stream, drained into the child at spawn timestdin(input: Process.Input): aProcess.Inputvalue; the three cases areProcess.Input.Inherit(child reads from the parent's stdin),Process.Input.FromStream(stream)(a rawInputStreamsupplied by the caller), andProcess.Input.Pipe(opens an unmanaged OS pipe, written viaproc.unsafe.stdinJava)inheritStdin: pipes the parent process's stdin through to the childpipeStdin: opens an unmanaged pipe; the caller writes viaproc.unsafe.stdinJava
Caution: pipeStdin exposes the unsafe tier. proc.unsafe.stdinJava is a raw OutputStream that requires AllowUnsafe and bypasses Kyo's effect tracking. Use it only when the caller controls the write loop directly (for example, a JSON-RPC over stdio protocol). Prefer stdin(stream: Stream[Byte, Sync]) for all other cases: it pumps bytes into the child's stdin through a background fiber at spawn time without leaving the safe API.
Output routing:
inheritStdout/inheritStderr/inheritIOinherit streams from the parent processstdoutToFile(path, append)/stderrToFile(path, append)redirect output to a fileredirectErrorStream(true)merges stderr into stdout
Execution methods:
| Method | Return type | Description |
|---|---|---|
text | String < (Async & Abort[CommandException]) | stdout as a UTF-8 string |
waitFor | ExitCode < (Async & Abort[CommandException]) | exit code as a value |
waitForSuccess | Unit < (Async & Abort[CommandException | ExitCode]) | fails on non-zero exit |
textWithExitCode | (String, ExitCode) < (Async & Abort[CommandException]) | stdout and exit code together |
stream | Stream[Byte, Async & Scope & Abort[CommandException]] | stdout as a byte stream |
spawn | Process < (Sync & Scope & Abort[CommandException]) | process handle, Scope-managed |
spawnUnscoped | Process < (Sync & Abort[CommandException]) | process handle, caller owns lifetime |
When you need the full stdout as a string and the process exits before returning, use text. When you need only the exit code, use waitFor or waitForSuccess. When stdout is large or arrives incrementally, use stream or spawn with collectOutput. When the process outlives the current scope or you manage lifetime explicitly, use spawnUnscoped.
The built Command is readable without launching: args returns Chunk[String], workDir returns Maybe[Path] (the configured working directory, or Absent when none was set), and env returns Map[String, String] (the current environment snapshot reflecting any envAppend/envRemove/envReplace calls).
Process handles
After spawn, the returned Process handle exposes stdout and stderr streams, lifecycle controls, and concurrent output draining. Command.spawn registers the process with the enclosing Scope. When the scope closes before the process exits, the process is forcibly killed. Command.spawnUnscoped omits scope registration and is appropriate for long-lived workers whose lifetime is managed explicitly:
import kyo.*
val example: Unit < (Async & Sync & Scope & Abort[CommandException]) =
for
proc <- Command("my-server", "--port", "8080").spawn
_ <- Async.sleep(5.seconds)
alive <- proc.isAlive
_ <- Sync.defer(println(s"alive: $alive"))
yield ()
// proc is forcibly killed when the Scope closesspawnUnscoped omits scope registration. The caller is responsible for calling destroy or destroyForcibly at the appropriate moment: after a timed run, on application shutdown, or when the work unit completes:
import kyo.*
// The caller drives the process lifetime explicitly
val worker: Unit < (Async & Sync & Abort[CommandException]) =
for
proc <- Command("background-worker", "--queue", "jobs").spawnUnscoped
_ <- Async.sleep(30.seconds)
_ <- proc.destroyForcibly
yield ()proc.stdout and proc.stderr return Stream[Byte, Sync & Scope]; the underlying InputStream is closed when the enclosing Scope closes.
Caution: Reading stdout then stderr sequentially deadlocks when the process writes more than the OS pipe buffer (~64 KB) to both channels. The unread producer blocks the OS, which in turn blocks waitFor. Use collectOutput to drain both concurrently.
import kyo.*
val capture: (Chunk[Byte], Chunk[Byte]) < (Async & Sync & Scope & Abort[CommandException]) =
for
proc <- Command("build-tool", "--verbose").spawn
(out, err) <- proc.collectOutput
yield (out, err)Other lifecycle operations on a Process:
| Method | Return type | Description |
|---|---|---|
waitFor | ExitCode < Async | Suspends the fiber until exit |
waitFor(timeout) | Maybe[ExitCode] < Async | Returns Absent on timeout |
exitCode | Maybe[ExitCode] < Sync | Non-blocking poll; Absent if still running |
isAlive | Boolean < Sync | Non-blocking liveness check |
pid | Long < Sync | OS process identifier |
destroy | Unit < Sync | Requests termination (SIGTERM or equivalent) |
destroyForcibly | Unit < Sync | Forces termination (SIGKILL or equivalent) |
Exit codes
Once a process exits, interpret its status through ExitCode. ExitCode is a three-case enum. ExitCode.Signaled(number) follows the POSIX shell convention where the raw integer value equals 128 + signal number:
ExitCode.Success: raw value 0ExitCode.Failure(code): any non-zero value that does not encode a signalExitCode.Signaled(number): the process was terminated by an OS signal; raw = 128 + number
ExitCode.apply(raw: Int) constructs the appropriate case:
import kyo.*
val ok: ExitCode = ExitCode(0) // Success
val fail: ExitCode = ExitCode(1) // Failure(1)
val killed: ExitCode = ExitCode(137) // Signaled(9), SIGKILLNamed constants are available for common POSIX signals: ExitCode.SIGHUP, SIGINT, SIGQUIT, SIGKILL, SIGSEGV, SIGPIPE, SIGTERM. Use them in pattern matches:
import kyo.*
def describe(code: ExitCode): String = code match
case ExitCode.Success => "succeeded"
case ExitCode.SIGTERM => "terminated gracefully"
case ExitCode.SIGKILL => "killed forcibly"
case ExitCode.Signaled(n) => s"killed by signal $n"
case ExitCode.Failure(n) => s"failed with exit code $n"waitForSuccess adds ExitCode to the abort channel on non-zero exits alongside CommandException for pre-launch failures. The union type distinguishes the two failure origins at the call site:
import kyo.*
val strict: Unit < (Async & Abort[CommandException | ExitCode]) =
Command("sbt", "test").waitForSuccess
val withCode: (String, ExitCode) < (Async & Abort[CommandException]) =
Command("make", "check").textWithExitCodeExitCode.toInt converts a code back to its raw integer (0 for Success, the original code for Failure(n), and 128 + n for Signaled(n)). ExitCode.isSuccess returns true when the code is Success. ExitCode.signalName returns Present("SIGKILL") and similar for the seven named POSIX signals, or Absent for Success, Failure, and any unrecognized signal number.
System environment
import kyo.* brings kyo.System into scope, and it shadows java.lang.System. A call that used to compile stops: System.nanoTime() reports value nanoTime is not a member of object kyo.System. The error names the object it resolved to, so the fix reads off it: qualify the JDK one as java.lang.System.nanoTime(). kyo.SecureRandom shadows java.security.SecureRandom the same way. Both are intentional, since the kyo type is the one a kyo program wants by default.
System.env[A](name) and System.property[A](name) retrieve an environment variable or system property and parse it to type A using a Parser[E, A] typeclass instance. A missing variable returns Absent; a present but unparseable value fails with Abort[E]:
import kyo.*
val home: Maybe[String] < Sync =
System.env[String]("HOME")
val port: Maybe[Int] < (Abort[NumberFormatException] & Sync) =
System.property[Int]("server.port")When the type parameter A has E = Nothing (such as String), the Abort disappears from the effect row. Each method also has a default-value variant: System.env[A](name, default) and System.property[A](name, default) return A and fall back to default when the variable is absent.
Built-in Parser instances cover: String, Int, Long, Float, Double, Boolean, Byte, Short, Char, Duration, java.util.UUID, java.net.URI, java.net.URL, java.time.LocalDate, java.time.LocalTime, java.time.LocalDateTime, and Seq[A] (comma-split). Provide a given Parser[E, A] to support custom types.
System.lineSeparator returns the platform line separator ("\n" on Linux and macOS, "\r\n" on Windows) as String < Sync. System.userName returns the OS user name as String < Sync. Both read from the host environment and carry Sync.
System.live is the default ambient instance backed by the host environment. All System.* calls delegate to it when System.let has not been called.
System.let(system)(computation) replaces the ambient System for the duration of the computation. Use it in tests to inject controlled values without touching the process environment:
import kyo.*
val mock: System = System(new System.Unsafe:
def env(name: String)(using AllowUnsafe): Maybe[String] =
if name == "APP_ENV" then Present("staging") else Absent
def property(name: String)(using AllowUnsafe): Maybe[String] = Absent
def lineSeparator()(using AllowUnsafe): String = "\n"
def userName()(using AllowUnsafe): String = "ci"
def operatingSystem()(using AllowUnsafe): System.OS = System.OS.Linux
def architecture()(using AllowUnsafe): System.Arch = System.Arch.X86_64
def availableProcessors()(using AllowUnsafe): Int = 4)
val result: Maybe[String] < Sync =
System.let(mock)(System.env[String]("APP_ENV"))System.operatingSystem returns a System.OS value and System.architecture returns System.Arch. Both are available across all three platforms. System.OS cases: Linux, MacOS, Windows, BSD, Solaris, IBMI, AIX, Unknown. System.Arch cases: X86, X86_64, Arm, Aarch64, Unknown:
import kyo.*
val adapted: String < Sync =
for
os <- System.operatingSystem
arch <- System.architecture
cpus <- System.availableProcessors
yield s"$os / $arch, $cpus processors"