Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ Error handling has two layers:

1. **Lexical structure** β€” the document applies context, in two forms:
context values (`<Output>` sets the `output` error mode) and middleware
(`<PrintErrors>` installs failure printing; `<Retry>` installs
retry middleware).
(`<Retry>` installs retry middleware). `<PrintErrors>` uses both: it sets
`print` for its region and installs printing middleware.
2. **Runtime execution** β€” execution runs under the context applied by the
enclosing structure.

Expand Down Expand Up @@ -211,7 +211,7 @@ Status is measured against main.
| Construct | Does | Status |
| --- | --- | --- |
| `<PrintErrors>` / `printErrors(fn)` | prints failures | built on main |
| `<Output>` region `output` mode | an undecided error fails the run | defined, unbuilt β€” on main a region prints and continues |
| `<Output>` region `output` mode | an undecided error fails the run | built on main |
| `<Retry max timeout>` | retry a region until it completes | defined, unbuilt |
| suspension effect | suspend durably | defined, unbuilt |
| `<Result as>` | binds `{ok: true, value}` or `{ok: false, error}`; a failure becomes a bound value, not a raise | defined, unbuilt |
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/answers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ import type { ComponentElement, ErrorSegment, Json, Segment } from "./types.ts";
* arm holds that state, so it binds the recursion and passes it down; nothing
* here could reconstruct which expansion a region belongs to.
*/
type ExpandSegments = (segments: Segment[]) => Operation<Segment[]>;
/**
* `owner` is the region the segments render into, when they render at all: the
* body writes there as it goes, while a matcher's template produces a value and
* keeps its own buffer.
*/
type ExpandSegments = (segments: Segment[], owner?: Segment[]) => Operation<Segment[]>;

const ANSWERS = "Answers";
const ANSWER = "Answer";
Expand Down Expand Up @@ -130,6 +135,8 @@ export function strayAnswerError(element: ComponentElement): ErrorSegment {
export function* expandAnswers(
element: ComponentElement,
expand: ExpandSegments,
/** The region the answered body renders into. */
owner: Segment[],
): Operation<Segment[]> {
for (const name of Object.keys({ ...element.props, ...element.expressions })) {
if (name !== "delegate") {
Expand Down Expand Up @@ -200,7 +207,8 @@ export function* expandAnswers(
{ at: "min" },
);

return yield* expand(body);
yield* expand(body, owner);
return [];
});
}

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/component-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ export interface ComponentApi {
* does; a printing boundary answers with a printed error instead.
*
* Distinct from `raise`: this handles an operation failure, while `raise`
* observes an `ErrorSegment`. Failure printing uses both β€” it converts, then
* observes exactly once.
* observes an `ErrorSegment`. A printing boundary uses both β€” it converts,
* then observes exactly once.
*/
handleFailure(failure: ComponentFailure): Operation<ErrorSegment>;
/**
Expand Down
41 changes: 27 additions & 14 deletions packages/core/src/component-failures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
* A component that fails fails the operation it is part of, like any other
* Effection work. Carrying on instead is a decision somebody makes: either the
* component says so about itself with `printErrors()`, or a document says so
* about a region with `<PrintErrors>`. Both install the same middleware, so
* "the nearest printing boundary handles it" is one rule rather than two.
* about a region with `<PrintErrors>`. Both install the boundary through
* `usePrintErrors()`, so "the nearest printing boundary handles it" is one rule
* rather than two.
*
* Printing turns a failure into a printed error. It does not decide what happens
* to that printed error β€” the caller's ambient error mode still settles it, so under
* documentation a printed failure still stops the document.
* A boundary sets `print` for its region and turns a propagating failure into
* one printed error. Both halves are the same decision: the region prints, and
* a failure that reaches the boundary is what gets printed.
*/

import { Component, raise } from "./component-api.ts";
import { attributeCause } from "./errors.ts";
import { attributeCause, ErrorMode } from "./errors.ts";
import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.ts";
import type { Operation } from "effection";

Expand Down Expand Up @@ -51,16 +52,28 @@ export function printsErrors(component: FunctionComponent): boolean {
}

/**
* Report an invocation failure as one printed error instead of failing the
* operation.
* Print this region's errors, and report an invocation failure as one printed
* error instead of failing the operation.
*
* Terminal: it answers rather than delegating, so the nearest boundary is the
* one that handles a failure and an enclosing one never sees it again. The
* original failure is attributed as the printed error's cause, so what the
* component actually did remains reachable from the outside.
* The mode is a context value, so it governs by lexical structure and nothing
* more: a region nested inside this one that chooses its own β€” an `<Output>`
* region in a component invoked here β€” shadows it, and what happens inside that
* region is the same whether or not this boundary is written around it.
*
* `throw` is the one mode this does not replace. Documentation and value roots
* render nothing, so a printed error there is a printed error nobody can read,
* and the failure stays a failure (Β§6.9).
*
* The middleware is terminal: it answers rather than delegating, so the nearest
* boundary is the one that handles a failure and an enclosing one never sees it
* again. The original failure is attributed as the printed error's cause, so
* what the component actually did remains reachable from the outside.
*/
export function useFailurePrinting(): Operation<void> {
return Component.around({
export function* usePrintErrors(): Operation<void> {
if ((yield* ErrorMode.get()) !== "throw") {
yield* ErrorMode.set("print");
}
yield* Component.around({
*handleFailure([failure], _next): Operation<ErrorSegment> {
const segment: ErrorSegment = {
type: "error",
Expand Down
73 changes: 61 additions & 12 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,35 @@ import type { ErrorSegment } from "./types.ts";
* error crossing from a component's own error mode to its caller's does not emit a
* second observation.
*/
export type ErrorMode = "print" | "throw";
export type ErrorMode = "print" | "output" | "throw";

export const ErrorMode: Context<ErrorMode> = createContext<ErrorMode>(
"component.errorMode",
"print",
);

/**
* Settle a segment under the ambient error mode: the default `Component.raise`
* implementation calls this, and so does a consumer applying its own error mode to
* an error that already crossed a nested one.
* Settle a segment under the ambient error mode β€” the decision an undecided
* error is raised into, made exactly once, where it is raised.
*
* The three modes differ over an error no middleware converted:
*
* - `print` prints it into the document and the run continues. This is the
* root's mode, and what a printing boundary installs for its region.
* - `output` fails the run. Every `<Output>` region installs it: a region that
* shows an operator what a stage produced must not also let a failed stage
* reach the step after it. The failure that leaves the region propagates like
* any other, so the nearest printing boundary may print it instead.
* - `throw` fails the run whatever a printing boundary says. Documentation and
* value roots are hidden, so a printed error there gives an author nothing to
* read.
*/
export function* settle(segment: ErrorSegment): Operation<ErrorSegment> {
const mode = yield* ErrorMode.get();
if (mode === "throw") {
throw new DocumentationError(segment);
const mode = (yield* ErrorMode.get()) ?? "print";
if (mode === "print") {
return segment;
}
return segment;
throw new DocumentationError(segment, mode);
}

/**
Expand Down Expand Up @@ -71,11 +82,19 @@ export function attributeCause(segment: ErrorSegment, from: unknown): void {
*/
export class DocumentationError extends Error {
readonly segment: ErrorSegment;
/**
* The error mode that decided this failure. Recorded because the two failing
* modes end differently at a printing boundary (`decidedByOutput`), and
* because the decision was already made: nothing reads the ambient mode again
* to work out what this failure means.
*/
readonly mode: "output" | "throw";

constructor(segment: ErrorSegment) {
constructor(segment: ErrorSegment, mode: "output" | "throw") {
super(segment.message);
this.name = "DocumentationError";
this.segment = segment;
this.mode = mode;
// Membership, not value: a component can throw `undefined`, and that is
// still the exact value this failure was translated from β€” the own `cause`
// property records it. Only a segment with no attribution has none.
Expand Down Expand Up @@ -137,8 +156,10 @@ export type FatalFailure = DocumentationError | DurabilityFailure;
* is right for anything the document itself got wrong. Two kinds are not that,
* and every generic catch in the engine rethrows them:
*
* - `DocumentationError` β€” the ambient error mode has already decided this
* execution fails (Β§6.9); printing it would undo that decision.
* - `DocumentationError` β€” the error mode has already decided this execution
* fails (Β§6.9); printing it here would undo that decision and resume work
* the decision stopped. The one place that asks a narrower question is the
* invocation boundary β€” see `decidedByOutput`.
* - a `DurabilityFailure` β€” the journal no longer describes this run (Β§6.11).
* The document is not wrong and there is nothing useful to render: continuing
* would run later siblings on top of work that never happened, and rendering
Expand Down Expand Up @@ -170,7 +191,35 @@ export type FatalFailure = DocumentationError | DurabilityFailure;
* `isRecoveredContent` for why the asymmetry is the point.
*/
export function fatalCause(error: unknown): FatalFailure | undefined {
return durabilityFailure(error) ?? firstCause(error, asDocumentationError, isRecoveredContent);
return durabilityFailure(error) ?? documentationFailure(error);
}

/**
* The documentation failure this one carries, if any β€” the same search
* `fatalCause` runs, asked on its own by the execution boundary, which reports
* a document's failure as the document's own outcome and lets anything else
* escape as an infrastructure failure.
*/
export function documentationFailure(error: unknown): DocumentationError | undefined {
return firstCause(error, asDocumentationError, isRecoveredContent);
}

/**
* Whether a printing boundary is allowed to print this failure.
*
* Every generic catch in the engine asks `fatalCause` a broader question β€” "may
* I turn this into a printed error and carry on?" β€” and the answer there is no
* for both failing modes, because carrying on resumes work the decision
* stopped. A printing boundary asks a narrower one: the region is already torn
* down and nothing after the failure ran, so the only thing left to decide is
* whether the document gets to read what happened.
*
* An `output` decision says yes β€” that is the whole difference between the mode
* a region installs and the mode documentation installs. A `throw` decision and
* a durability failure say no.
*/
export function decidedByOutput(failure: FatalFailure): boolean {
return failure instanceof DocumentationError && failure.mode === "output";
}

/**
Expand Down
Loading
Loading