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.

RunnerDischargesResidual (host service)
Path.run(program)PathWrite (and PathRead via subtyping)Sync & Abort[FileSystemException] & S
Path.runReadOnly(program)PathRead onlySync & Abort[FileSystemException] & S
Path.runWith(service)(program)PathWrite against a custom serviceS & Abort[FileSystemException] & S2
Path.runReadOnlyWith(service)(program)PathRead against a custom serviceS & 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.parts

Filesystem 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:

AccessorTypeDescription
partsChunk[String]Individual segments
nameMaybe[String]Final segment; Absent for a root or empty path
parentMaybe[Path]Containing directory
extNameMaybe[String]Extension including the leading dot, e.g. ".gz"; a leading dot in the filename is not treated as an extension
isAbsoluteBooleanWhether 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.walk

move 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:

MarkerOperations
FileReadExceptionInspection and content reads
FileWriteExceptionContent mutation and synchronization
FileStructureExceptionCreation, removal, copying, and movement
FileLockExceptionAdvisory lock acquisition and ownership
FileWatchExceptionWatch 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 $PATH
  • PermissionDeniedException(command): raised when the caller lacks execute permission
  • WorkingDirectoryNotFoundException(path): raised when the configured cwd does 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.download

BasePaths fields: cache, config, data, dataLocal, executable, preference, runtime, tmp. Three fields whose platform meanings are not immediately obvious:

FieldLinuxmacOSWindows
dataLocalsame 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.data

ProjectPaths 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").text

Builder 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"))
        .text

andThen(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 environment
  • envRemove(names) removes named variables from the inherited environment
  • envReplace(vars) replaces the entire environment with the given map
  • envClear clears all variables; the process inherits nothing

Stdin variants:

  • stdin(s: String): a UTF-8 encoded string (charset overridable)
  • stdin(bytes: Span[Byte]): raw bytes
  • stdin(stream: Stream[Byte, Sync]): a pure byte stream, drained into the child at spawn time
  • stdin(input: Process.Input): a Process.Input value; the three cases are Process.Input.Inherit (child reads from the parent's stdin), Process.Input.FromStream(stream) (a raw InputStream supplied by the caller), and Process.Input.Pipe (opens an unmanaged OS pipe, written via proc.unsafe.stdinJava)
  • inheritStdin: pipes the parent process's stdin through to the child
  • pipeStdin: opens an unmanaged pipe; the caller writes via proc.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 / inheritIO inherit streams from the parent process
  • stdoutToFile(path, append) / stderrToFile(path, append) redirect output to a file
  • redirectErrorStream(true) merges stderr into stdout

Execution methods:

MethodReturn typeDescription
textString < (Async & Abort[CommandException])stdout as a UTF-8 string
waitForExitCode < (Async & Abort[CommandException])exit code as a value
waitForSuccessUnit < (Async & Abort[CommandException | ExitCode])fails on non-zero exit
textWithExitCode(String, ExitCode) < (Async & Abort[CommandException])stdout and exit code together
streamStream[Byte, Async & Scope & Abort[CommandException]]stdout as a byte stream
spawnProcess < (Sync & Scope & Abort[CommandException])process handle, Scope-managed
spawnUnscopedProcess < (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 closes

spawnUnscoped 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:

MethodReturn typeDescription
waitForExitCode < AsyncSuspends the fiber until exit
waitFor(timeout)Maybe[ExitCode] < AsyncReturns Absent on timeout
exitCodeMaybe[ExitCode] < SyncNon-blocking poll; Absent if still running
isAliveBoolean < SyncNon-blocking liveness check
pidLong < SyncOS process identifier
destroyUnit < SyncRequests termination (SIGTERM or equivalent)
destroyForciblyUnit < SyncForces 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 0
  • ExitCode.Failure(code): any non-zero value that does not encode a signal
  • ExitCode.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), SIGKILL

Named 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").textWithExitCode

ExitCode.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"