feat: return AsyncResult from every async API, and give runtimes an info channel - #2
Merged
Conversation
…omise `AsyncResult<T, never>` is the honest spelling of "async, and cannot fail", so every async surface now awaits into a `Result` and a caller never has to remember which ones did and which ones did not. `prefer-async-result` could not catch these: it flags `Promise<Result<T, E>>`, and a `Promise<void>` is not Result-bearing. Converted: `RunningApp.probePort()`, `Clock.sleep` (and `systemClock` / `createFakeClock`), `FakeClock.advance`, `UnitRegistry.awaitIdle`, `TestRuntime.untilStarted` and `ProbeServer.close`. Three surfaces stay on `Promise`, each documented where it lives: `runMain` (the boundary out of the Result world, into a process exit code), `UnitWork`'s `Promise<Result<T, E>>` arm (it accepts a caller's own `async` handler), and `withApp` / its `use` callback — `use` is the test body, and an `AsyncResult` never rejects, so wrapping it would turn a failing `expect` into a `Defect` a caller can forget to unwrap: a green test that asserted nothing. `drain.spec.ts`'s two controlled-clock tests now cross a real macrotask instead of counting microtasks by hand, and the late-unit test opens its unit before releasing the pre-drain delay — an idle registry resolves `awaitIdle()` at once, which left a microtask-wide window to register inside. Assertions unchanged.
… own channel
`probePort()` answers for the kernel's probe server, but a runtime that binds
an ephemeral `port: 0` had no way to tell the caller which port it got — so
every such runtime would have to invent its own `onListening` hook. This is
the same deferred, one layer up.
A runtime publishes `Serving.info` when it starts serving; the caller reads it
back through `app.runtimeInfo()`, an `AsyncResult<Info | undefined, never>`.
The shape is the runtime's own — `Info` is a type parameter on `Serving` /
`Runtime` / `StartOptions` / `RunningApp`, deliberately not a hard-coded port,
since a queue runtime has none and would publish `{ queue, prefetch }`. It
defaults to `never`, so `info` is unwritable and every existing runtime type
reads exactly as it did: publishing is optional with no ceremony.
`runtimePublished` is settled from the same two `tapFailure` blocks that
already settle `probeBound`, so `runtimeInfo()` can no more hang than
`probePort()` can. `testRuntime` publishes `{ name }`, exercising the channel
end to end.
There was a problem hiding this comment.
Pull request overview
This PR updates the kernel’s public surface to (1) make all async APIs consistently return AsyncResult (including infallible ones as AsyncResult<T, never>) and (2) add a runtime-published “info” channel via Serving.info surfaced as RunningApp.runtimeInfo(), so runtimes (e.g. ephemeral-port binders) can publish structured runtime metadata without bespoke hooks.
Changes:
- Add
Serving<Info>.info?: InfoandRunningApp.runtimeInfo(): AsyncResult<Info | undefined, never>(plus docs and type-level doc sync). - Convert multiple async APIs from bare
PromisetoAsyncResult(probePort,Clock.sleep,FakeClock.advance,UnitRegistry.awaitIdle,TestRuntime.untilStarted,ProbeServer.close) and adjust tests accordingly. - Plumb the
Infogeneric throughRuntime,StartOptions,RunningApp,testRuntime, andwithApp, and document the three deliberate Promise-based exceptions.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents Runtime<Needs, Info> / Serving<Info> and the new runtimeInfo() channel; documents “all async surfaces are AsyncResult”. |
| packages/start/src/with-app.ts | Extends withApp typing to carry Info through to RunningApp<E, Info>; adds rationale docs for Promise exception. |
| packages/start/src/units.ts | Changes UnitRegistry.awaitIdle() to AsyncResult<void, never> and lifts immediate resolution via OkAsync(). |
| packages/start/src/units.spec.ts | Updates assertions for awaitIdle() now returning AsyncResult. |
| packages/start/src/testing.ts | Re-exports TestRuntimeInfo from the testing entrypoint. |
| packages/start/src/test-runtime.ts | Adds TestRuntimeInfo, publishes it via Serving.info, and changes untilStarted() to AsyncResult<void, never>. |
| packages/start/src/start.ts | Adds Info generic plumbing, introduces runtimeInfo() deferred, and changes probePort() to return AsyncResult. |
| packages/start/src/start.spec.ts | Adds coverage for runtimeInfo() behavior and updates async expectations to AsyncResult matchers. |
| packages/start/src/runtime.ts | Adds Info generic to Runtime/Serving and defines Serving.info?: Info semantics. |
| packages/start/src/run-main.ts | Widens runMain to accept RunningApp<E, unknown> (covariant Info), with boundary documentation. |
| packages/start/src/probes.ts | Changes probe server close() to AsyncResult<void, never>. |
| packages/start/src/invariants.spec.ts | Updates probePort() assertions and bound-port helper for AsyncResult return type. |
| packages/start/src/fake-clock.ts | Changes Clock.sleep/FakeClock.advance implementations to return AsyncResult. |
| packages/start/src/fake-clock.spec.ts | Updates fake-clock assertions for AsyncResult. |
| packages/start/src/drain.ts | Updates DrainArgs.serving to Serving<unknown> for info covariance. |
| packages/start/src/drain.spec.ts | Adapts drain tests to Clock.sleep now returning AsyncResult and adds a macrotask “settle” helper. |
| packages/start/src/docs-examples.test-d.ts | Keeps README snippets type-checked; adds new runtimeInfo() example type assertions. |
| packages/start/src/clock.ts | Changes Clock.sleep to AsyncResult<void, never> via fromSafePromise. |
| packages/start/src/clock.spec.ts | Updates clock test expectations for AsyncResult. |
| packages/start/README.md | Mirrors top-level README: describes Serving.info / runtimeInfo() and “AsyncResult everywhere” rule. |
| CLAUDE.md | Updates the authoritative repo spec to reflect AsyncResult-only async APIs and the new runtime info channel. |
| .changeset/initial-kernel.md | Updates the initial changeset narrative to include runtimeInfo() and the AsyncResult-only async surface rule. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
`drainApp` awaited `clock.sleep`, `serving.drain` and `registry.awaitIdle` and dropped every `Result` all three returned. All three are typed `AsyncResult<void, never>`, and `never` empties the *error* channel only — a `Defect` can still be present at runtime. `Serving` is implemented by third-party runtimes, so a `drain` throwing internally became a Defect the kernel silently swallowed, and the drain reported `Ok(report)`: a clean shutdown that never happened. The three beats are now a `flatMap` chain and the beat-3 race feeds its winning `Result` through `fromSafePromise(...).flatMap(...)` rather than a bare `await`, so no `Result` goes unexamined. Ordering is unchanged: the counters are still sampled synchronously first, the pre-drain delay is still waited out before the runtime is told to stop accepting, the timeout still races `allAsync([drain, awaitIdle])` and is never awaited on its own, and `deadline` is still aborted the instant the race settles — now on the defect branch too, so a compliant runtime is released either way. Four specs cover the propagation, one per source (the pre-drain sleep, the drain-timeout sleep, the runtime's drain, the registry going idle), each asserting the cause with `toBeDefectWith` and each verified to fail against a mutation that re-drops that one `Result`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two public-API changes, both free — the package has never been published, so
.changeset/initial-kernel.mdis simply updated to describe the final shape.1. No bare
Promisein the APIA library whose thesis is errors as values was handing back raw
Promises.prefer-async-resultcould not catch them: it only flagsPromise<Result<T,E>>, and aPromise<void>is not Result-bearing.Every async API now returns
AsyncResult—probePort,Clock.sleep,FakeClock.advance,UnitRegistry.awaitIdle,TestRuntime.untilStarted,ProbeServer.close.AsyncResult<T, never>is the right expression for "async and cannot fail": it is whatfromSafePromiseproduces, and it makes every async surface uniformly awaitable into aResult.Absence stays
T | undefined(probePortresolvesundefinedwhen probes are disabled or the bind failed).unthrownhas noOptiontype by design.Three documented exceptions
runMainkeepsPromise<void>. Its job is to leave the Result world and become a process exit code.UnitWork'sPromise<Result>arm stays — it exists to accept a caller'sasynchandler.withAppkeepsPromise. This one was argued rather than assumed: anAsyncResultnever rejects, so converting the test harness would catch a failingexpectinside the test body and turn it into aDefect. A test that awaits without unwrapping would then pass while asserting nothing. In a harness, throwing is the correct signal, because the runner is built around it.2.
Serving.info/RunningApp.runtimeInfo()Building the first real runtime surfaced a gap:
probePort()exists for the probe server, but a runtime binding an ephemeral port had no channel of its own, so every such runtime reinvented anonListeninghook.Serving.infolets a runtime publish structured information about itself once serving;RunningApp.runtimeInfo()is how the caller reads it. It mirrorsprobePort()'s existing deferred rather than introducing a second mechanism, is optional (a queue runtime with nothing to publish needs no ceremony), and is deliberately not port-shaped — a bound port is the motivating case, not the contract.Verification
100 tests (86 kernel + 14 examples), up from 96. Coverage 100% lines and functions. Full six-command gate green.
No assertion weakened. The
resolves.toBeUndefined()rewrites becametoBeOkWith(undefined), which is strictly stronger — it also asserts the variant — and the one restructured drain test became more deterministic with a byte-identical assertion.