Skip to content

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:

FileHoldsSide effects when loaded
build.defs.fsxOptions, stages, commands, and let root = Command.root { … }None
build.fsx#load "build.defs.fsx", then one line that invokes root and exitsRuns the build, exits the process
.SageFs/init.fsx#load "build.defs.fsx", then a build function over rootNone

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:

  1. cancels the run as a cancellation token would — the pipeline stops, and the whole process tree of every running command is killed, grandchildren included;
  2. waits up to five seconds for the run to wind down;
  3. raises ThreadInterruptedException, which ends the evaluation. No RunResult is 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.CancellationToken inside an async step). An async step 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:

  • root is built once. A pipeline written as Command.pipeline { … } or pipeline "…" { … } 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 code 1 and "Pipeline '…' is already running". Stages yielded straight into a command, and pipelines built inside an input { }, are built afresh for each call.
  • Option defaults are computed once, when the module declaring them initialises. Baked.Common.isCI reads the CI environment variables and Baked.NuGet.apiKey reads NUGET_API_KEY at 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.fsx and re-sending its #load defines a new BuildDefs module; the build function bound earlier still calls the old root. Re-send the whole of .SageFs/init.fsx instead.

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"
Edit this page