Execution contexts and `HTTP::Server`

What is the plan for things like HTTP::Server regarding execution contexts? Is the idea that the HTTP::Server will receive an execution context to spawn the new fibers into?

context = Fiber::ExecutionContext::Parallel.new("http.server", 8)
log = Log.for("http.server")
handlers = [
  HTTP::LogHandler.new(log),
  HTTP::CompressHandler.new,
] of HTTP::Handler
http = HTTP::Server.new(handlers, context)

I have a proof of concept implementation of this:

class HTTP::Server::WithExecutionContext < HTTP::Server
  private getter execution_context : Fiber::ExecutionContext

  def initialize(handlers : Indexable(HTTP::Handler), @execution_context)
    super HTTP::Server.build_middleware(handlers)
  end

  def dispatch(io)
    execution_context.spawn { handle_client io }
  end
end

Looks good!
We should perhaps consider how this integrates with structured concurrency, though (ref [RFC] Structured Concurrency · Issue #6468 · crystal-lang/crystal · GitHub).

See Add `ExecutionContext` support to `WaitGroup` · Issue #15642 · crystal-lang/crystal · GitHub that proposed the same for WaitGroup.

We decided against explicitly specifying spawn contexts because we’d have to specify one to every type that spawns. Instead, we should be able to specify the current spawn context for a “scope”. No need to patch support everywhere: spawn would always spawn into the current spawn context.

The one thing we didn’t decide upon is the API, because it kinda steps on similar concepts to scope data (e.g. fiber locals).

Yes, and how integrated the spawn contexts should be with the execution contexts themselves. For example one option is to have the execution contexts default to being fully hierarchical and not changing the default inside them could be opt-in.

There are also some semantic questions to answer like “should the root spawn context be available in a variable somewhere to give an escape hatch” and stuff like that.

Naively, I’d consider the spawn context to be fiber local, and not inherited on spawn (though in practice it is). By default it’s the current context, when overridden it changes the spawn context for the current fiber only.

Fibers are spawned into context X, then these fibers will spawn into their current context, which is also X, unless locally overridden.

It might not always play well when a type has multiple level of spawns such as HTTP::Server. While the OP example would only spawn the incoming requests into ec, the following example would spawn both the listening fibers and the incoming requests into ec:

ec = Fiber::ExecutionContext::Parallel.new("http", 4)

server = HTTP::Server.new { ... }
server.bind_tcp 9292
server.bind_unix "http.sock"

Fiber.with_spawn_context(ec) { server.listen }

Though, this might have been the intent (spawn everything into the same context, to avoid moving client sockets to another context), we still don’t have control over where to spawn listener sockets vs request sockets without explicit support in HTTP::Server.

This seems like an indicator that we should not rely on an implicit spawn context inherited from the current fiber, but provide explicit configuration for that.

Maybe in a case like HTTP::Server it’s preferred that all fibers spawn in the same context. But that should not be set in stone. Applications may want to customize the behaviour and tune concurrency behaviour to specific use cases.