Hosting a build in a long-lived session
Hosting a build in a long-lived session
A build script run as dotnet fsi build.fsx -- test compiles the script, resolves its packages and starts a runtime on every run, and every tool script it shells out to (run "dotnet fsi tools/gen.fsx -- …") pays the same again. A long-lived F# session — SageFs, or any host holding an FsiEvaluationSession — pays it once. The build then becomes a function the session calls, answering a structured RunResult instead of an exit code and a screen of text.
This page is written against SageFs, but nothing in it depends on SageFs beyond the location of its startup script.
The shape
Three files, where a plain script build has one:
| File | Holds | Side effects when loaded |
|---|---|---|
build.defs.fsx | Options, stages, commands, and let root = Command.root { … } | None |
build.fsx | #load "build.defs.fsx", then one line that invokes root and exits | Runs the build, exits the process |
.SageFs/init.fsx | #load "build.defs.fsx", then a build function over root | None |
The split exists because a session must never evaluate the last line of build.fsx. rootCommand argv { … } and rootCommandOfScript { … } run the build as they are constructed, and exit terminates the process — in a session, the session's own worker. Command.root { … } takes the same operations and builds the command without parsing or running anything.
build.defs.fsx
module BuildDefs
#r "nuget: Partas.Build"
#r "nuget: Partas.Build.Baked"
open Partas.Build
open Partas.Build.Baked
let projects = [ "src/MyLib/MyLib.fsproj" ]
let root = Command.root {
description "MyLib's build"
workingDir __SOURCE_DIRECTORY__
command "build" {
description "Restores and builds"
Command.pipeline {
Stages.restore "MyLib.slnx"
Stages.build projects
}
}
command "test" {
description "Builds and runs the tests"
Command.pipeline {
Stages.restore "MyLib.slnx"
Stages.build projects
Stages.expecto "tests/MyLib.Tests/MyLib.Tests.fsproj" [ "--sequenced" ]
}
}
}
workingDir __SOURCE_DIRECTORY__ makes every relative path resolve against the repository, whatever the session's current directory is. The explicit module BuildDefs gives the file a name that reads well from the other two; without it, the module is named after the file.
build.fsx
#load "build.defs.fsx"
open Partas.Build
exit (Command.invoke (Args.script ()) BuildDefs.root).ExitCode
dotnet fsi build.fsx -- test --quick behaves exactly as a single-file build did: same commands, same --help, same exit codes.
.SageFs/init.fsx
#load "build.defs.fsx"
open System
open Partas.Build
let build (args: string list) = BuildDefs.root.Invoke(args, output = Console.Out)
SageFs evaluates the text of this file at the end of a session's warm-up, so #load paths resolve against the session's working directory — the repository — rather than against .SageFs/.
output = Console.Out is read on each call, which in SageFs is the writer capturing the current evaluation. Given a writer, Invoke sends everything the run prints to it as plain text: help, --explain, the pipeline's own lines, and the output of every child process whose stage writes to the console. Without it, a child process inherits the worker's real standard output, and its lines never reach the evaluation's result.
Calling it
From the session, or from an agent through SageFs's send_fsharp_code:
build [ "test"; "--explain" ] // what would run, and what is skipped and why
let result = build [ "test"; "--quick" ]
result.Outcome // Succeeded | Failed | UsageError | Cancelled
result.ExitCode // 0 | 1 | 2 | 130
[ for failure in result.Failures -> failure.Label, FailureCause.describe failure.Cause ]
[ for timing in result.Timings -> timing.Name, timing.Elapsed ]
build [ "test"; "--json" ] ends its output with the JSON form of the same result, for a caller that reads text rather than F# values.
Mounting other scripts' commands
A build that runs a tool script as a child process —
stage "wire" { run "dotnet fsi tools/wire.fsx -- generate" }
— cold-starts fsi for it on every run, and loses the tool's result to an exit code. Split the tool the same way and mount its command instead:
// tools/wire.defs.fsx
module Wire
#r "nuget: Partas.Build"
open Partas.Build
let generate = command "wire" {
description "Regenerates the wire types"
stage "generate" { run (cmd $"dotnet run --project tools/WireGen") }
}
// build.defs.fsx
#load "tools/wire.defs.fsx"
let root = Command.root {
// …
addCommand Wire.generate
}
build [ "wire" ] now runs in the session, <build> --help lists wire beside the build's own commands, and <build> wire --help lists the tool's options. The tool keeps a thin tools/wire.fsx of its own for standalone use. A tool that exposes its stages rather than a whole command composes further: yield them into a pipeline of the build's own.
Cancelling a run
SageFs's cancel_eval interrupts the thread running the evaluation (Thread.Interrupt); it signals no CancellationToken. Invoke runs the build on a thread of its own and waits for it, so the interrupt reaches the waiting thread, which:
- cancels the run as a cancellation token would — the pipeline stops, and the whole process tree of every running command is killed, grandchildren included;
- waits up to five seconds for the run to wind down;
- raises
ThreadInterruptedException, which ends the evaluation. NoRunResultis returned.
A caller that owns a token passes it instead: BuildDefs.root.Invoke(args, output = Console.Out, cancellationToken = token), and a cancelled run returns a RunResult with Outcome = Cancelled.
What cancellation does not reach:
- A step written as a plain F# function —
run (fun ctx -> …)— runs on until it returns, unless it reads the token itself (Async.CancellationTokeninside anasyncstep). Anasyncstep is stopped at its next asynchronous wait. A blocking function keeps its thread after the evaluation has ended. - Work already done. Cancellation stops what is running; it undoes nothing.
What stays shared across calls
A session keeps every value it evaluates. For a build, that means:
rootis built once. A pipeline written asCommand.pipeline { … }orpipeline "…" { … }with no CLI inputs is one value, reused by every call. Such a value runs one run at a time: a second call reaching it while a first is still running — including a straggler step from an interrupted run — fails with exit code1and "Pipeline '…' is already running". Stages yielded straight into acommand, and pipelines built inside aninput { }, are built afresh for each call.- Option defaults are computed once, when the module declaring them initialises.
Baked.Common.isCIreads the CI environment variables andBaked.NuGet.apiKeyreadsNUGET_API_KEYat that moment, and keep those values for the life of the worker. Pass the flag explicitly, or restart the session (hard_reset_fsi_session), after changing them. The environment a stage runs with is read afresh at the start of every run. - Editing
build.defs.fsxand re-sending its#loaddefines a newBuildDefsmodule; thebuildfunction bound earlier still calls the oldroot. Re-send the whole of.SageFs/init.fsxinstead.
The library keeps no other state between runs. It sets the console encodings at most once per process, and only for a stream that is not redirected, and its console follows whichever writer Console.Out is when it writes. Without a terminal — a SageFs worker has none — tables are laid out 80 columns wide.
Live testing
SageFs discovers Expecto tests in a loaded project and runs them as code changes, with a five-second default timeout per test. It classifies a test whose full name contains integration as an integration test, run only on demand. A build's own test suites that start processes — as this repository's do — belong there:
[<Tests>]
let tests =
testList "cmd" [
// …
]
|> testLabel "integration"