Skip to content

🐛 Markdown as= capture swallows errors rendered away by string projections #233

Description

@taras

Problem

A markdown component invocation captured with as= swallows errors that its body rendered away through a string projection. The binding is stored, the structured ErrorSegment never reaches the caller, and the only trace of the failure is an <!-- ERROR --> comment embedded inside the captured string.

This violates the capture contract (spec §6.9, capture-errors.test.ts CE4/CE7): a capture never silently swallows an unhandled structured error — the binding stays unset and the original errors return to the consumer boundary.

Reproduction

components/Wrap.md:

<Output>
```ts persist eval
const projected = yield* renderChildren();
output(projected);
```
</Output>

doc.md:

<Wrap as="cap">
<Missing />
</Wrap>

value:{cap}:end

Observed output (real compiler + eval, harness as in packages/core/tests/eval-policy.test.ts):

value:

<!-- ERROR: Failed to import component Missing: Cannot resolve component: Missing (searched: components, .) -->
:end

No throw; cap is bound to a string containing the rendered error comment, and {cap} interpolates it. The structural control — Wrap.md containing <Output><Content />\n\ndone</Output> — correctly refuses the binding: {cap} stays literal and the error segment surfaces at the invocation site.

Cause

projectToString() exists to keep a string result from hiding a failure: it records the structured errors it renders away in the handle's collector so the invocation can refuse an as= capture (expand.ts:383-393). The function-component path reads its collector and refuses the capture (expand.ts:1856-1858). The markdown path installs bodyContentErrors (expand.ts:1555, 1580) but never reads it — introduced write-only in b318c32 under the assumption that "markdown projections render into segments, so nothing is hidden by a string." That assumption is false for the eval-block string projections renderChildren(), render(), and markdown-eval useContent() (expand.ts:1606-1623): their errors come back inside the eval block's string output, so the as= refusal filter (expand.ts:1685-1691), which inspects only the expanded segments, finds nothing.

Recovery semantics

This fix preserves ordinary Effection try/catch behavior.

The affected collect path does not throw: renderChildren(), render(), or markdown-eval useContent() returns a string containing a rendered error comment. Because no exception reached the eval generator, there was no explicit recovery boundary. The collector preserves the structured error so an outer as= capture cannot mistake that lossy string for success.

The throw path is different. Projection throws DocumentationError before projectToString() receives segments or records them in bodyContentErrors. An author-written catch may explicitly recover, and the collector must not make that caught error sticky or later refuse the binding.

The rule is:

A capture never silently swallows an unhandled structured error. A caught operation failure is explicit recovery.

Structured concurrency governs ownership and teardown of the projection's work; it does not make JavaScript exceptions uncatchable. This issue does not introduce cancellation, halt(), or a latent failure that is reasserted after recovery.

Fix shape

In the markdown as= capture path, refuse the capture when bodyContentErrors is non-empty, exactly as the expanded-segment refusal does today: return the recorded segments unchanged, no raise() — they were observed where they were produced, and the consumer boundary (expand.ts:571-577) settles them under the caller's policy.

Map whether the value-component returns path (expand.ts:1626-1668) has the same exposure — a body eval block that string-projects erroneous content inside <Output> before <Return> produces the value — and cover it the same way if so.

Not in scope

  • Uncaptured invocations keep today's behavior: the rendered error comment appears inline in the emitted output (eval-policy O24).
  • No change to <Content />, renderChildren(), render() semantics, throw-policy behavior, ordinary try/catch recovery, or observation counts.

Tests

  • The reproduction above: binding refused, error surfaces exactly once, later siblings continue.
  • The structural <Content /> control stays passing.
  • A value-component variant if the mapping shows it swallows.
  • A caught DocumentationError is explicit recovery: the collector remains empty for that failed string projection, the recovered component may complete, and no later capture refusal reasserts the caught error.
  • Projection-owned work is torn down according to its scope before recovery continues; no work escapes because the error was caught.
  • eval-policy.test.ts O23/O24/O28–O30 stay passing.

Verification

deno task lint
deno task check
deno task test
deno task check:jsr
git diff --check

Relationship to #232

#233 is implemented by its current agent as a separate fix. #232 does not duplicate or absorb this work; it waits for #233 to land, rebases on the collector fix, and then changes only the function-component boundary.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions