roc-lang / roc-lang/basic-webserver

Add a one-shot post-listen hook (and expose the resolved bound address)

Open
#231 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
HTML
Stars
107
Forks
20
Avg merge
1d 17h
Merged PRs (30d)
11

Description

Summary

There is currently no way for an application to run Roc code after the listener is bound but before (or without) an inbound request arriving, and no way for the application to learn the address the listener actually bound to. This makes a whole class of "start the server, then do one bounded thing against it" programs impossible to express as a single self-contained basic-webserver app.

I would like to propose a single, one-shot, post-listen hook — and, relatedly, a way to observe the resolved bound address (which matters when the configured port is 0).

I am not sure whether the absence of this is deliberate (design.md lists "arbitrary background Roc callbacks, schedulers, detached processes, daemon management, or worker supervision" as a non-goal), so please read this as a question about where the boundary sits rather than an assertion that something is missing by mistake. The proposal below is deliberately narrower than "background callbacks": exactly one hook, run exactly once, with a finite deadline, no concurrency with itself, and no new mutable state.

Motivating use case (CI: verify examples against a bundled package)

Roc will only fetch a bundled package (.tar.zst) over https or http://localhost — never file:// or a local path. So to verify in CI that a package's examples build against the bundle that will actually be released (not the working tree), the test has to:

  1. bundle the package to a .tar.zst in a temp directory,
  2. serve that directory over http://localhost:<port>,
  3. rewrite the examples to point at http://localhost:<port>/<hash>.tar.zst,
  4. run roc check / roc build / roc run on each example, so the compiler fetches the bundle from that server,
  5. stop the server and report the exit code.

basic-webserver looks like an excellent fit for steps 2–5: it already has Server.file_response/static_mount for host-managed static files, Cmd for running roc as a subprocess, and Server.stop_after/stop_after_with_code for self-shutdown. The one thing that cannot be expressed is step 4 starting at all: the roc subprocess must be launched only once the socket is listening, and there is nothing to launch it.

Today this forces a second, external process (a shell script or a basic-cli app) to supervise the server, which reintroduces the "is it up yet?" polling and port-allocation races that owning the listener would avoid.

What exists today

The app contract in platform/main.roc:

requires {
    [Context : context] for program : {
        init! : () => Try({ config : Server.Config, context : context }, [Exit(I64), ..]),
        respond! : Server.Request, context => Try(Server.Outcome, [ServerErr(Str), ..]),
        shutdown! : Server.ShutdownReason, context => Try({}, [Exit(I64), ..]),
    }
}
  • init! runs before the listener is bound — Server.roc documents this repeatedly ("init! and complete route validation finish before the listener is bound", "fail startup atomically before the listener is bound"). Blocking in init! to drive traffic against the server would deadlock, since nothing is listening yet.

  • respond! only runs in reaction to an inbound request, so it cannot bootstrap the first request.

  • shutdown! runs after the server has stopped accepting.

  • Server.Config (the Config := [Config({ listen : { host : Str, port : U16 }, ... })] record and its with_* builders) has no startup-callback field.

  • The nearest existing thing is the readiness gate added in #183:

    Readiness.create! : ReadinessState => Try(Readiness, [ReadinessCapacityExhausted])
    Readiness.set! : Readiness, ReadinessState => Try({}, [InvalidReadiness, StaleReadiness, ServerStopping])
    

    That is a state an external prober reads; it still requires someone outside the process to act on it. It does not let the app itself act at the moment of listening.

So as far as I can tell there is no existing mechanism that covers this. Please correct me if I have missed one.

The bound address is also not observable

Server.Config.with_listen takes a concrete { host : Str, port : U16 }. Passing port: 0 works at the OS level — src/http_server.rs does:

let listener = tokio::net::TcpListener::bind((context.config.host.as_str(), context.config.port)).await ...;
let address = listener.local_addr().unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], context.config.port)));
println!("Listening on <http://{address}>");

The host already computes the resolved local_addr() and prints it to stdout — but it is never handed to Roc. An application that binds port 0 (the race-free way to get a port in CI, and the natural choice when several test jobs run concurrently) has no way to discover which port it got, so it cannot construct the URL it needs to hand to roc. Today the only workarounds are to hard-code a port and hope, or to scrape the host's own stdout from a parent process.

This may be worth fixing on its own even without the hook: it is a small, self-contained gap.

Proposed API sketch

An optional fourth program function, defaulting to absent so existing apps are unaffected:

## Runs exactly once, after the listener is bound and native routes are
## validated, and before or concurrently with the first request.
on_ready! : Server.Listening, context => Try(Server.ReadyOutcome, [ServerErr(Str), ..]),

with:

## The resolved listening address. When the configured port was 0, `port` is
## the OS-assigned port, not 0.
Listening : { host : Str, port : U16 }

## What the server should do once `on_ready!` returns.
ReadyOutcome := [
    Serve,
    Stop,
    StopWithCode(I64),
]

## Keep serving requests.
serve : ReadyOutcome

## Begin graceful shutdown with exit code 0.
stop : ReadyOutcome

## Begin graceful shutdown with the given exit code.
stop_with_code : I64 -> ReadyOutcome

This mirrors the existing Server.Outcome vocabulary (respond / stop_after / stop_after_with_code), so "finish and stop" reads the same whether the decision is made by a handler or by the ready hook.

Interaction with Server.Config:

  • A new builder bounds the hook, in the style of the existing with_graceful_shutdown : Config, { drain_timeout_ms : U64, hook_timeout_ms : U64 } -> Config:

    with_ready_hook : Config, { timeout_ms : U64 } -> Config
    

    Exceeding the deadline is treated as a runtime failure and enters graceful shutdown with ShutdownReason.RuntimeFailed(...), exactly like a blown drain deadline forces a bounded outcome today.

  • with_listen gains no new field; port: 0 simply becomes useful, since Listening.port reports the resolved port.

Errors:

  • Err(ServerErr(msg)) from on_ready! → log the message and begin graceful shutdown with a non-zero exit code, via ShutdownReason.RuntimeFailed(msg). shutdown! still runs, so cleanup stays in one place.
  • The hook never runs if binding fails; StartupFailed continues to go straight to shutdown! as it does now.

Concurrency and boundedness (the properties I think the design doc cares about most):

  • Exactly one invocation, ever. It is not a scheduler and cannot register further callbacks.
  • It occupies one handler slot (or a dedicated slot) so max_handlers still bounds total concurrent Roc execution.
  • It introduces no new mutable application state: it receives the same immutable context that init! returned, and its only output is a bounded ReadyOutcome value. Anything durable it produces still belongs in SQLite or an external service, per the README and design.md.
  • Requests may be served concurrently with it; a program that wants strict "nothing served until ready" already has the readiness gate from #183 and can compose the two.

A smaller variant, if a fourth required function is too much churn: make it a Config field — e.g. with_ready_hook : Config, { timeout_ms : U64, run! : Listening, context => Try(ReadyOutcome, [ServerErr(Str), ..]) } -> Config — which keeps the requires block at three functions and makes the feature purely opt-in. I have no strong preference; the Config form may fit the "future server settings can be added without invalidating application record construction" comment on Config better.

Alternatives considered

1. Let the application own the listener (what most ecosystems do). In Rust, Go, Node, and Python the "after bind" problem does not exist, because the program owns the listener and "after bind" is just the next line:

  • Rust: let listener = TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?; then axum::serve(listener, app) — you have the resolved address before you ever hand the listener to the server.
  • Go: ln, _ := net.Listen("tcp", "127.0.0.1:0"); ln.Addr() then http.Serve(ln, h).
  • Node: server.listen(0, cb) with server.address().port inside the callback.
  • Python: uvicorn exposes it as a lifespan startup event.

This is the most flexible design, but it is a much bigger change for basic-webserver: the host owns the Tokio runtime, the accept loop, connection admission, and shutdown, and handing Roc a listener handle would mean exposing the runtime's lifecycle. The one-shot hook is the small version of the same idea, and Node's listen(port, callback) — which also solves exactly the OS-assigned-port case — seems like the closest precedent for a platform where the host keeps ownership.

2. Add a spawn/background-process API to basic-cli and drive the server from outside. This works and is probably what I will do in the meantime: a basic-cli script starts the server as a child process, waits for it to be ready, runs the roc commands, and kills it. Downsides: it needs a second program and a second platform for what is conceptually one task; it reintroduces readiness polling and port races (or stdout scraping to recover the OS-assigned port); and orphaned children on failure become the script's problem. It also does not help the more general "the server itself wants to do one thing at startup" cases (warmup, registering with a discovery service, printing the real port).

3. Have an external process poke the server to bootstrap it. i.e. keep the work in respond! behind a magic route and have something curl it. This just moves the coordination problem outside and needs a known port — the thing that is not observable.

4. Report only the bound address, no hook. A strictly smaller change: hand the resolved local_addr to shutdown!, or expose it via a Server effect readable from respond!. It fixes the port-discovery half and nothing else. If the hook is out of scope, I would still find this valuable on its own.

Cross-platform / host notes

  • local_addr() is already computed on every supported target in run_tcp_server; exposing it is SocketAddr{ host : Str, port : U16 } at the ABI boundary. For an IPv6 bind, host would be the textual address without brackets, matching how Server.Authority already models { host : Str, port : [Absent, Present(U16)] }.
  • The hook itself is one more roc_*_for_host entry point alongside roc_init_for_host / roc_respond_for_host / roc_shutdown_for_host, invoked from the same executor, with the same Box(Context) handling — no new threading model, and nothing target-specific.
  • benchmark-simulation builds use run_server_with_listener with a non-TCP listener; that path would need a synthetic address (or to skip the hook), which is worth deciding explicitly rather than by accident.

Against the design.md checklist

  1. Which use case? Self-contained integration/CI servers, and startup work that needs the server to be up: warmup, service registration, reporting the real port.
  2. Whose responsibility? Application policy, invoked at a host-owned lifecycle moment — like init! and shutdown!, which are already exactly that.
  3. Achievable another way? Only with a second process on a second platform; see alternative 2.
  4. Hidden process-local state or ordering? No new state. It adds one defined ordering point — after bind — which is the whole point, and it does not order handlers relative to one another.
    5–8. Bounded by an explicit timeout_ms, one invocation, one handler slot, released before or at shutdown; failure paths route into the existing ShutdownReason machinery.
  5. Same contract on every target? Yes; nothing here is OS-specific.
  6. Does every application pay? No — absent hook, zero cost, and existing apps do not change.
  7. Toward a non-goal? This is the part I am least sure about, given "arbitrary background Roc callbacks, schedulers". My argument is that a single non-repeating hook with a finite deadline is a lifecycle event of the same kind as shutdown!, not a scheduler — but if the maintainers read it as the thin end of that wedge, I would rather hear that than have it half-adopted.

Happy to prototype this if the shape sounds acceptable.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with design.md, platform/main.roc, and Server.roc to determine whether a one-shot post-listen callback fits the platform boundary. Then inspect src/http_server.rs, the existing roc_init_for_host/roc_respond_for_host/roc_shutdown_for_host entry points, and run_server_with_listener for address and benchmark implications. Done means the API, timeout and shutdown semantics, resolved-address behavior, and non-TCP path are explicitly decided and covered by tests.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
api, backend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.