Host lifecycle for Moon-Lander applications: services, stop requests and exit codes
///|
import {
"moonrockz/moon-lander-core" @lander_core,
"moonrockz/moon-lander-hosting" @hosting,
}///|
test "build a host with one service" {
let log = []
let host : @hosting.Host[Unit] = "acme"
|> @lander_core.AppName::parse
|> @hosting.builder(["greet"])
|> @hosting.service(
"greeter",
start=_ => log.push("greeter started"),
stop=_ => log.push("greeter stopped"),
)
|> @hosting.build(_ => ())
ignore(host)
// The build runs no hook.
assert_eq(log, [])
}| Package | Targets | Content |
|---|---|---|
| moonrockz/moon-lander-hosting (@hosting) | all; run on native and js; run_embedded on wasm and wasm-gc | Builder, command, hosted services, Host[S], run, SignalProbe, run_embedded, Lifetime, StopToken, StopReason, SignalKind, StopRequested, ServiceName, ExitCode and HostError |
| moonrockz/moon-lander-hosting/argparse (@hosting_argparse) | all | Command factory, global standard discovery flags, and the argparse types for the command (Command, OptionArg, FlagArg and others) |
| moonrockz/moon-lander-hosting/console (@console) | native, js | The console driver run: signals, the second signal and the process exit. A C signal stub on native, Node.js listeners on js |
| moonrockz/moon-lander-hosting/vendor/argparse | all | A copy of moonbitlang/core/argparse with Command::try_parse, until a pinned MoonBit release has it. Do not import it; use @hosting_argparse |
///|
test "register lifetime handlers from a hook" {
let host : @hosting.Host[Unit] = "acme"
|> @lander_core.AppName::parse
|> @hosting.builder([])
|> @hosting.service("events", start=ctx => {
ctx.lifetime.on_started(() => ())
ctx.lifetime.on_stopping(() => {
// Completed, Requested, Signal, Embedder, StartFailed or Failed.
ignore(ctx.lifetime.stop_reason())
})
ctx.lifetime.on_stopped(() => ())
})
|> @hosting.build(_ => ())
ignore(host)
}///|
test "bind an enclosing shutdown callback from a public hook" {
let shutdown : Ref[(() -> Unit)?] = Ref(None)
let host = @hosting.builder(@lander_core.AppName::parse("desktop"), [])
|> @hosting.service("shutdown", start=ctx => {
shutdown.val = Some(() => ctx.lifetime.stop_from_embedder())
})
|> @hosting.build(_ => ())
// The driver starts the host and registers the callback when this hook runs.
ignore(host)
assert_true(shutdown.val is None)
}///|
test "a command requests stop and polls its token" {
let command : @hosting.Hook[Unit] = ctx => {
let token = ctx.lifetime.stop_token()
ctx.lifetime.stop_application(exit_code=@hosting.ExitCode::success())
token.check_cancelled()
}
ignore(command)
}///|
test "a console driver gives the run a signal probe" {
let signal : Ref[@hosting.SignalKind?] = Ref(None)
// On native and js: @hosting.run(host, command, signals=probe)
let probe = @hosting.SignalProbe::new(() => signal.val)
ignore(probe)
}///|
test "a long command yields between batches" {
let command : @hosting.Hook[Unit] = ctx => {
for _ in 0..<100 {
ctx.check_cancelled()
// Process one bounded batch here.
@async.pause()
}
}
ignore(command)
}///|
test "set the shutdown timeout" {
let host : @hosting.Host[Unit] = "acme"
|> @lander_core.AppName::parse
|> @hosting.builder([])
|> @hosting.shutdown_timeout(@lander_core.TimeLimit::seconds(5))
|> @hosting.build(_ => ())
ignore(host)
}| Code | Cause |
|---|---|
| 0 | Clean run |
| 1 | Unhandled factory, hook or command failure; stop failure or timeout |
| 2 | Configuration or usage error in any phase |
| 130 | Interrupt signal stop |
| 143 | Terminate signal stop |
| 131 | Quit signal stop |
| n | Application code from set_exit_code or stop_application(exit_code=...) |
///|
test "application and signal codes" {
let command : @hosting.Hook[Unit] = ctx => {
ctx.lifetime.set_exit_code(@hosting.ExitCode::from_int(3))
ctx.lifetime.stop_application(exit_code=@hosting.ExitCode::from_int(4))
}
assert_eq(@hosting.SignalKind::Interrupt.exit_code().to_int(), 130)
ignore(command)
}///|
test "replace the failure report" {
let reports : Array[(String, @hosting.ExitCode)] = []
let host : @hosting.Host[Unit] = "acme"
|> @lander_core.AppName::parse
|> @hosting.builder([])
|> @hosting.error_report((error, code) => {
let message = match error {
@hosting.HostError::_ as e => e.message()
@hosting.UsageError::_ as e => e.message()
@hosting.ShutdownTimedOut::_ as e => e.message()
_ => error.to_string()
}
reports.push((message, code))
})
|> @hosting.build(_ => ())
let command : @hosting.Hook[Unit] = _ => raise @hosting.UsageError("expected a command")
ignore((host, command))
}///|
test "set the variable prefix and starting settings" {
let host : @hosting.Host[Unit] = @lander_core.AppName::parse("my-tool")
|> @hosting.builder([], env_vars={ "MT_LOG__FORMAT": "json" },
working_dir=@lander_core.AbsolutePath::parse("/work"))
|> @hosting.env_prefix(@lander_core.EnvPrefix::parse("MT"))
|> @hosting.reserve_env(["MT_LOG_DIR"])
|> @hosting.app_dirs()
|> @hosting.build(_ => ())
ignore(host)
}///|
test "reserve a full name before changing the prefix" {
let host : @hosting.Host[Unit] = @lander_core.AppName::parse("my-tool")
|> @hosting.builder([], env_vars={ "MT_TOKEN": "secret" })
|> @hosting.reserve_env(["MT_TOKEN"])
|> @hosting.env_prefix(@lander_core.EnvPrefix::parse("MT"))
|> @hosting.build(_ => ())
ignore(host)
}///|
test "configure application sources and conventions" {
let host : @hosting.Host[Unit] = "acme"
|> @lander_core.AppName::parse
|> @hosting.builder([])
|> @hosting.configure(fn(sources) raise {
sources
|> @config.defaults({ "log": { "format": "text" } })
|> @conventions.add(@conventions.app_dirs("acme"))
})
|> @hosting.build(_ => ())
ignore(host)
}///|
test "choose a database service from recorded profile membership" {
let postgres = @config.ProfileName::parse("postgres")
let host : @hosting.Host[String] = @hosting.builder(
@lander_core.AppName::parse("acme"), ["--profiles", "postgres"],
env_vars=({} : Map[String, String]), working_dir=@lander_core.AbsolutePath::root())
|> @hosting_argparse.command(name => @hosting_argparse.Command(name,
options=[@hosting_argparse.profiles_option()],
flags=[@hosting_argparse.no_profiles_flag()]))
|> @hosting.configure(s => s |> @config.format(@toml.format()))
|> @hosting.app_dirs()
|> @hosting.profiles(@profiles.Policy::new())
|> @hosting.service("database", start=ctx => {
assert_eq(ctx.services == "postgres", ctx.config.profiles().contains(postgres))
})
|> @hosting.build(ctx =>
if ctx.config.profiles().contains(postgres) { "postgres" } else { "memory" })
ignore(host)
}///|
test "prepare a host and a polling command for the embedded driver" {
let stop = Ref(false)
let host : @hosting.Host[Unit] = "acme"
|> @lander_core.AppName::parse
|> @hosting.builder([])
|> @hosting.files(@config.FileTree::new(complete=true))
|> @hosting.build(_ => ())
let command : @hosting.Hook[Unit] = ctx => {
while !ctx.cancelled() {
// Process one bounded batch here, without a wait.
stop.val = true
}
}
// On wasm and wasm-gc:
// let outcome = @hosting.run_embedded(host, command, stop_probe=() => stop.val)
ignore((host, command))
}type ReportWriter = async (String) -> Unitimpl IntoEnvVars for Jsonimpl IntoEnvVars for Map[String, String]impl IntoServiceName for Stringpub(all) suberror HostError {
InvalidServiceName(text~ : String, reason~ : String)
DuplicateService(name~ : ServiceName, existing~ : ServiceName)
InvalidExitCode(code~ : Int)
} derive(Debug)pub(all) suberror StopRequestedpub suberror SuspendError {
HookSuspended(service~ : ServiceName, phase~ : HookPhase)
CommandSuspended
}pub(all) suberror UsageError {
UsageError(String)
}pub suberror WorkingDirectoryError {
MissingWorkingDirectory
InvalidWorkingDirectory(AbsolutePathError)
} derive(Debug)pub struct BuildContext {
config : Config
environment : HostEnvironment
env_vars : SortedMap[String, String]
working_dir : AbsolutePath
args : Matches
}pub struct Context[S] {
services : S
config : Config
environment : HostEnvironment
env_vars : SortedMap[String, String]
working_dir : AbsolutePath
lifetime : Lifetime
args : Matches
}pub struct Host[S] {
// private fields
}pub struct HostBuilder[S] {
// private fields
}pub struct HostEnvironment {
application_name : AppName
environment_name : EnvironmentName?
content_root : AbsolutePath
}pub struct Lifetime {
// private fields
}pub struct Mapping {
// private fields
}pub(all) enum MappingAction {
Flag
Count
Set
Append
}pub struct RunOutcome {
// private fields
}impl Show for ServiceNameimpl IntoServiceName for ServiceNamepub struct SignalProbe {
// private fields
}pub(all) enum StopReason {
Completed
Requested
Signal(SignalKind)
Embedder
StartFailed(Error)
Failed(Error)
}pub struct StopToken {
// private fields
}fn[S] builder(name : AppName, args : Array[String], env_vars? : &IntoEnvVars, working_dir? : AbsolutePath, environment? : EnvironmentName) -> HostBuilder[S]fn[S] command(builder : HostBuilder[S], make : (String) -> Command, mappings? : Array[Mapping]) -> HostBuilder[S]fn[S] configure(builder : HostBuilder[S], sources : (ConfigBuilder) -> ConfigBuilder raise) -> HostBuilder[S]fn[S] conventions(builder : HostBuilder[S], transform : (Conventions) -> Conventions) -> HostBuilder[S]fn[S] error_report(builder : HostBuilder[S], report : async (Error, ExitCode) -> Unit) -> HostBuilder[S]fn map(option : OverrideOption, key : KeyPath, action? : MappingAction, parse? : (String) -> ConfigValue raise) -> Mappingasync fn[S] run(host : Host[S], command : async (Context[S]) -> Unit, signals? : SignalProbe) -> RunOutcomefn[S] service(builder : HostBuilder[S], name : &IntoServiceName, starting? : async (Context[S]) -> Unit, start? : async (Context[S]) -> Unit, started? : async (Context[S]) -> Unit, stopping? : async (Context[S]) -> Unit, stop? : async (Context[S]) -> Unit, stopped? : async (Context[S]) -> Unit) -> HostBuilder[S] raise HostErrorInstall
Download zipHost lifecycle for Moon-Lander applications: services, stop requests and exit codes