Application and request lifetimes
Every resource has an owner and a lifetime. h34s gives you two scopes:
| Lifetime | Use it for | Kyo | ZIO |
|---|---|---|---|
| One request | A pooled connection, a transaction, a temporary file, a stream's source | The request Scope in HandlerEffects | The request Scope in HandlerEnv |
| The whole application | A connection pool, an HTTP client, a cache handle | Shared | ZShared |
Choose the shortest lifetime that works. Open request resources inside the handler; open application resources once.
Status. Shared and ZShared are new in 0.2.0-alpha.1.
Request resources
The core runs every handler in a request scope. Acquire a resource in that scope, and h34s releases it when the request is done:
- Kyo:
Scope.acquireRelease(acquire)(release)andScope.ensure(finalizer). - ZIO:
ZIO.acquireRelease(acquire)(release),ZIO.addFinalizer, or a scopedZLayeryou provide inside the handler.
"Done" means:
- For a value or
Unitbody: after your program finishes, before h34s writes the response. Asynchronous finalizers finish before the response settles. - For a streamed body: after the stream ends, fails, or is cancelled.
- When the client disconnects: h34s interrupts the program, waits for the finalizers, and writes nothing. See When the client disconnects.
A finalizer that checks the outcome, to commit or roll back, needs a scope that sees it. In ZIO, the request Scope closes with your program's Exit, so ZIO.addFinalizerExit there sees a failure. In Kyo, h34s closes the request scope after your program, without its outcome: a Scope.ensure finalizer there always gets Absent. Write a Scope.run inside your handler around the steps whose outcome you need; see roll back when a stream fails.
The demo's ZIO handlers take at most one pooled Skunk session per request this way. NotesLive.layer reads the request Scope from HandlerEnv and takes the session on first use (memoize), so a request that fails validation never touches the pool, and the session returns to the pool when the request ends:
/** Notes over at most one pooled session per request. The session is taken on first use, so a
* handler that rejects its input never touches the pool, and it returns to the pool when the
* request scope closes.
*/
val layer: ZLayer[SessionPool & Scope, Nothing, Notes] =
ZLayer.fromZIO(
for
pool <- ZIO.service[SessionPool]
scope <- ZIO.service[Scope]
session <- pool.session.provideEnvironment(ZEnvironment(scope)).memoize
yield NotesLive(session),
)
NotesHandlerSpec checks this with a pool whose database is down: invalid input takes no session and is a 422; valid input takes one session and fails.
A failing finalizer is logged on the server and does not change the response, in both runtimes. Kyo 1.0.0-RC7 logs a failing Scope finalizer. The ZIO adapter logs a dying ZIO finalizer as h34s: a ZIO finalizer failed. Do work that must affect the response in the program, not in a finalizer: the client never learns that a finalizer failed.
When the client disconnects
A client can leave before your handler finishes: the user closes the tab, or the frontend aborts its fetch. Kyo and ZIO handlers behave the same way:
- h34s interrupts your program. In ZIO, the handler's fiber is interrupted.
- Your finalizers run:
Scope.acquireReleaseandScope.ensurein Kyo,ZIO.acquireRelease,ZIO.addFinalizerand scoped layers in ZIO. - h34s waits for those finalizers, including asynchronous ones, before it tells h3 and Nitro that the request is over. This includes work that your handler forks with Kyo's structured combinators (
Async.timeout,Async.race,Async.gather,Async.zip, a concurrentAsync.foreach) and ZIO child fibers: they are interrupted, and their cleanup finishes first. - h34s writes nothing to the closed connection.
The exception is work on a fiber that nothing owns: Kyo's Fiber.initUnscoped, and the fiber that Kyo's Async.uninterruptible starts. h34s cannot wait for it, so do not use request resources there.
Interruption can stop work that you expected to finish. If the client leaves during a database transaction, the transaction can roll back instead of committing. For a step that must complete even when the client leaves, make that step uninterruptible:
- Kyo:
KHandler.uninterruptible(step). The step runs on its own fiber that the interrupt cannot reach, and the request scope waits for it before it releases resources acquired earlier (such as the connection the step uses). Do not use Kyo'sAsync.uninterruptiblealone in a handler: it protects the step, but the request settles and releases its resources while the step still runs. - ZIO:
step.uninterruptible, orZIO.uninterruptibleMaskwhen only part of the step must be protected. ZIO delays the interrupt until the step ends, and h34s waits for the fiber.
// Kyo, with the kyo-sql service from the database guide: the insert and its commit finish even if
// the client leaves; then earlier request resources are released; then the request settles.
note <- KHandler.uninterruptible(notes.create(input.text))
The step needs only Async & Abort[Throwable]: provide its other effects inside it (kyo-sql's DB.run(client) does). This line compiles against kyo-sql-postgres 1.0.0-RC7 (see database); the order is tested with a sleeping step, not with a database.
Keep uninterruptible regions small. A client disconnect cannot stop them, so h34s waits for them to finish before it settles the request. Tests in both runtimes disconnect during such a step and check the order: the step ends, then the request finalizers run, then the request settles. A Kyo test also documents that Async.uninterruptible alone does not wait.
Interruption does not cancel JavaScript work that has already started, for example a storage Promise. h34s stops waiting for it; the work itself finishes or fails on its own.
Application resources
Shared (Kyo) and ZShared (ZIO) hold one resource for all requests:
- The first
getacquires it. Concurrent first calls share one acquisition. - A failed acquisition is not cached. The next
gettries again. - The acquisition runs on its own fiber. A request that is cancelled while it waits does not cancel the acquisition for the others.
- When the acquisition succeeds, h34s registers the resource with the Nitro close hook.
closereleases it and waits for its finalizers. A latergetacquires it again.- A
closeduring the first acquisition releases the value as soon as it is acquired. Thegetcalls that waited for it fail withIllegalStateException; none receives a released value.
Declare a shared resource once, as a member of an object. Nothing runs until the first get.
Kyo, with any A < (Async & Scope & Abort[Throwable]) acquisition. The Scope here is the resource's own scope, not a request's:
object Resources:
val pool: Shared[Pool] = Shared(
Scope.acquireRelease(openPool)(pool => closePool(pool)),
)
openPool, closePool and Pool stand for your own code. Use it in a handler with Resources.pool.get, which is Pool < (Async & Abort[Throwable]).
The Kyo demo (AppHandler.scala) builds its greeting service this way, from the startup config, and provides it to every handler:
val greetings: Shared[Greetings] = Shared(RuntimeConfig.load[KyoConfig].map(config => Greetings.live(config.greeting)))
def define[B: ResponseBody](program: Request => B < (HandlerEffects & Env[Greetings]))(using Frame): EventHandler =
KHandler.define(options)(request => Services.greetings.get.map(greetings => Env.run(greetings)(program(request))))
ZIO, with any ZLayer[Any, E, R]. This is the demo's (AppHandler.scala):
/** One Skunk pool for all requests: built on first use, released when Nitro closes. */
private val database = ZShared.layer(SessionPool.layer)
Then database.provide(program) supplies the shared environment to a program, and database.get returns the ZEnvironment.
Load configuration inside the acquisition with RuntimeConfig.load or ZRuntimeConfig.load, not get. There is no request at startup. See configuration.
One copy per linked project
A Shared value is a normal Scala value. Every handler that the same sbt project links shares one copy, because Scala.js puts shared code in one internal module. Two Scala.js projects linked separately have two copies, and each opens its own pool.
When Nitro closes
The h34s Nitro plugin listens to Nitro's close hook and releases every registered Shared and ZShared value. One failing release does not stop the others. In Nitro 2.13:
- The
node-serverpreset calls the hook onSIGTERMorSIGINT(graceful shutdown, 30 seconds by default; seeNITRO_SHUTDOWN_TIMEOUTandNITRO_SHUTDOWN_DISABLED). nuxt devcalls it when it replaces the development worker, for example after relinking.
These facts come from Nitro's source. h34s tests the release path with a fake plugin record. In a production build of the demo, SIGTERM released the ZShared pool registered by the first Notes request, and the server exited with status 0. A nuxt dev worker replacement has not been checked.
Without the h34s plugin (for example in unit tests), nothing is registered. Call close yourself in that case.
What is tested
SharedSuite (Kyo) and ZSharedSpec (ZIO): one acquisition for concurrent first calls, an interrupted waiter does not cancel the acquisition, failures are not cached, a close during the first acquisition never hands out a released value, close releases once and a later get acquires again, and the close hook releases every registered value. Request-scope tests in both runtimes cover finalizers on success, safe failure and defect, asynchronous finalizers, client disconnects, uninterruptible steps, and (Kyo) cleanup in work forked by Async.timeout, race, gather, zip and foreach.