Skip to content

Don't crash on malformed *.yaml; surface as a banner (#285)#665

Merged
srid merged 11 commits intomasterfrom
fix-285-malformed-yaml
Apr 26, 2026
Merged

Don't crash on malformed *.yaml; surface as a banner (#285)#665
srid merged 11 commits intomasterfrom
fix-285-malformed-yaml

Conversation

@srid
Copy link
Copy Markdown
Owner

@srid srid commented Apr 25, 2026

A YAML file with a non-string mapping key like []: foo used to throw BadInput from patchModel, killing the live server's UnionMount Dynamic and aborting emanote gen before any HTML was produced. Parse outcome now lives in SData._sdataValue :: Either Text Aeson.Value — successes go right, errors go left, no exception. The renderer walks the same routeInits @'R.Yaml cascade getEffectiveRouteMetaWith already uses and prepends an emanote:error Pandoc Div, so a bad subfolder/index.yaml shows up as a banner on the notes under /subfolder/* and nowhere else.

E2E coverage: a paired broken-285.{md,yaml} fixture plus two scenarios — one asserts the banner is shown on the sibling note, the other asserts it does not leak onto unrelated pages.

Closes #285.

srid added 4 commits April 25, 2026 19:09
A YAML parse error in any `*.yaml` file used to throw `BadInput` from
`patchModel`, killing the UnionMount change handler and stopping the live
server from rendering anything (the browser saw "Unable to render template
'/templates/error'"). The same path also aborted `emanote gen` before any
HTML was produced.

Log the error and leave the model unchanged for that route — previously
parsed metadata stays in place, so the rest of the site keeps rendering
while the user fixes the file.

E2E coverage: a `[]: foo` fixture file plus a regression scenario that
asserts the home page still renders.

A user-visible "global errors" page (so the bad file is surfaced in the
browser, not just in the console) is the proper follow-up — out of scope
for this fix, which targets only the crash.
The previous regression assertion `text.includes("E2E Fixture")` coupled
to fixture content — if the fixture's H1 ever got renamed, the test
would silently stop guarding against the #285 crash. Replace with a
direct check for the bug's surface markers ("Ema App threw an exception"
and "Unable to render template"), which is what the user actually sees
when the Dynamic dies.

Also reframe the CHANGELOG entry: the proper fix (browser-visible global
errors page) stays as #285 follow-up per srid's comment on the issue,
so don't claim this PR closes it.
`text.length > 0` was a redundant guard on the empty-body case: a
present-but-mostly-empty body (page chrome only, no article) trivially
satisfies it, so the assertion didn't actually distinguish the bug
state from a healthy one. The marker check on EMA_EXCEPTION_MARKERS
is the real regression signal — keep that and drop the length guard.
Both comment blocks (Patch.hs YAML branch, smoke_steps.ts marker
constant) drifted into narrating the bug's history rather than
explaining the surprising behavior at the call site. Keep the WHY
that's not obvious from the code — the "preserve last-known-good"
choice and the inverse-signature assertion design — and drop the
"used to crash, here's what the user saw" prose.
@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 25, 2026

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey pure id complects "don't crash" with "preserve last-known-good" No-op
2 Hickey BadInput newtype now unused but kept in Prelude (public API) No-op
3 Hickey E2E step bound to fixture text ("E2E Fixture") instead of invariant Fixed in this PR
4 Hickey YAML errors only in logs; consider _modelSDataErrors map Deferred [#285]
5 Lowy YAML policy boundary inline-at-dispatch is correct (vs moving to SData) No-op
6 Lowy "Keep last good vs. drop on error" is an unnamed volatility axis No-op
7 Lowy Add _modelDataErrors field on ModelT now Deferred [#285]

Hickey rationale

#1 (pure id complects). pure id binds two independent behaviors — "don't crash" and "preserve last-known-good" — into one return value. Hickey's prescription is to introduce a sum type (PatchResult = PatchOk ... | PatchError Text) so the caller decides between log-and-id, log-and-evict, or future-escalation into a model-level error map. No-op rationale: the current 5-line case plus 3-line WHY comment names the policy at its only call site; an extra type or helper is in service of the deferred error-surfacing work (#4) and is premature without that target shape decided. If/when that follow-up lands, the right move is to refactor through this site, not to leave a half-built seam.

#2 (BadInput dead but exported). Emanote.Prelude.BadInput is now unused after the throw is gone. No-op: removing an exported newtype is a public API change, not a bug-fix scope. A separate cleanup PR can decide whether to delete the type entirely.

#3 (E2E coupled to fixture text). Fixed in commit d1c2c811. The original text.includes("E2E Fixture") would silently stop guarding the bug if the fixture's H1 ever got renamed. Replaced with a direct check for the bug's surface markers ("Ema App threw an exception", "Unable to render template") — the inverse of what the user sees when the Dynamic dies.

#4 (errors only in logs). Deferred to #285 per srid's comment on the issue: "I will implement the proper fix when I get to it." The CHANGELOG entry explicitly notes this as the follow-up.

Lowy rationale

#5 (boundary). The inline case Left/Right in Patch.hs lives at the right boundary. Moving the policy into Model/SData.hs (mirroring how markdown errors live inside Model/Note.hs) would require SData to sprout a per-route error field — but the markdown analog works because parseNote returns a Note that carries errors via _noteErrors; parseSDataCascading returns Aeson.Value, which has nowhere to put one. Distorting SData to host error policy would widen its volatility surface unnecessarily.

#6 (unnamed volatility axis). Lowy's prescription was to extract handleYamlParseError :: ... -> m (ModelEma -> ModelEma) so the "keep last good vs. drop" decision is named at a function boundary. No-op rationale: for a 2-line policy at one call site, a function would be the same code rearranged behind a name — and the comment already names the decision in prose ("leave the model unchanged for this route — previously-good data stays in place"). The named seam earns its keep when the function grows, which only happens with the deferred error-map work (#7). Extract then, not now.

#7 (_modelDataErrors now). Deferred. The UI shape for surfacing yaml errors in the browser (banner? sidebar? dedicated page?) hasn't been decided, and adding a field that no consumer reads couples ModelT's stability to a UI choice that doesn't exist yet. Tracked alongside #4 under the issue.

srid added 2 commits April 25, 2026 19:22
Take the deferred follow-up across the line: parse errors no longer
hide in the console. They now ride into the model as a
`Map (R 'Yaml) Text` and render as a banner block at the top of every
note while the offending file is broken — so a malformed yaml is
loud-and-fixable instead of silent-and-stale.

The patch handler drops the bad route's existing data on failure
(rather than the previous "preserve last-known-good" workaround) and
clears the error entry on a successful re-parse. `renderLmlHtml`
prepends a Pandoc Div carrying the same `emanote:error` class the
markdown errorDiv uses, so the visual surface is consistent across
both error sources.

The e2e regression flips from inverse-of-bug to a positive assertion:
the banner header and the broken file's path must both appear.
@srid srid changed the title Don't crash on a malformed *.yaml file (#285) Surface malformed *.yaml as a banner instead of crashing (#285) Apr 25, 2026
@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 25, 2026

Update — deferred items now done

Following user direction, the two findings previously marked Deferred [#285] are addressed in this PR:

# Lens Finding Updated disposition
4 Hickey YAML errors only in logs; consider _modelSDataErrors Fixed in this PR
7 Lowy Add _modelDataErrors field on ModelT now Fixed in this PR

Concretely, commit 32061c55 adds a _modelDataErrors :: Map (R 'Yaml) Text field to ModelT, populated on parse failure and cleared on success / file deletion. renderLmlHtml prepends a Pandoc Div with class emanote:error (the same class the markdown error path uses) when the map is non-empty, so the banner appears on every page while a yaml file is broken.

Why a Map and not piggybacking on IxSData

IxSet's value comes from multi-key indexing (IxNote is indexed by 8 keys); yaml errors only need single-key lookup by route, so an IxSet would be over-spec'd. Folding the error into SData itself would interleave "successfully parsed value" with "parse error" into one type — every consumer of _sdataValue would have to learn to ignore error-state entries (or rely on Aeson.Null as a sentinel). The Map keeps the success and failure channels separate; they have different lifetimes and different consumers.

Why a render-time prepend rather than per-note errorDiv

Markdown errors live in _noteErrors because they're per-note — a bad markdown file affects only its own page. Yaml errors aren't per-note: one bad *.yaml affects every note whose route descends from that yaml route. The render-time prepend keeps the banner global without forcing the model to denormalize errors onto every potentially-affected note.

Hickey #1 / Lowy #6 follow-up

Those two findings were marked No-op ("pure id complects" / "extract handleYamlParseError") on the basis that the policy was a 2-line block. With this commit the policy is now M.modelInsertDataError r err . M.modelDeleteData r vs M.modelInsertData sData . M.modelDeleteDataError r — explicit branches against named model operations. The "preserve last-known-good vs drop" axis is no longer implicit; the diff now drops bad data outright, which is the correct behavior once errors have a visible surface.

@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 25, 2026

/do results

Step Status Duration Verification
sync 0s git fetch ok; forge=github; noGit=false
research 5m 36s Read issue #285, comments, repro, Patch.hs:100-113 throws BadInput, Note.hs:_noteErrors as analog pattern.
branch 4s On feature branch fix-285-malformed-yaml from origin/master.
implement 1m 28s E2E fixture broken-285.yaml + regression scenario. Patch.hs YAML branch logs instead of throwing.
check 3s cabal build all → Up to date.
docs 21s CHANGELOG entry under 1.6.0.0 Unreleased / Bug fixes.
fmt 25s 4 hooks (cabal-fmt, fourmolu, hlint, nixpkgs-fmt) all Passed.
commit 16s Primary commit 269ef92 pushed.
hickey+lowy 5m 21s Sub-agents ran in parallel. 7 findings; #3 fixed (d1c2c81); #1+#2+#5+#6 no-op; #4+#7 initially deferred, later folded into the expanded scope.
police 4m 12s 3 passes: rules clean; fact-check fixed (dfe349e); elegance trimmed (8058faf).
test 1m 21s Unit 43/43 + E2E live 13/13 + E2E static 13/13.
create-pr 1m 24s Draft PR #665 + hickey/lowy ledger comment posted.
ci 10m 48s vira ci signed off on 32061c5 (HEAD) for both vira/aarch64-darwin and vira/x86_64-linux. Cache disabled per branch policy.
done 4s All steps completed.
Total 33m 59s

Slowest step: ci (10m 48s, 32% of total)

Optimization suggestions

  • CI ran twice (once on the minimal-fix merge commit 906dd9d1, once on the expanded-scope commit 32061c55) because scope grew mid-run. ~10 minutes were burned on the first CI pass that became obsolete. If the full scope is known upfront — surface yaml errors in the model, not just log them — naming that in the original /do prompt avoids the throw-away first build.
  • research + hickey+lowy together took 11 minutes and that's about as fast as it gets — issue Malformed YAML files break Emanote #285 has 13 comments and the parallel sub-agent invocation is already the optimal shape. No win to chase here.
  • For follow-up changes on this branch (e.g. addressing review comments), /do --from ci-only skips straight to vira ci on the new HEAD — useful if a single-line fix doesn't need the full research/check/test/police pipeline re-run.
  • Cache was disabled because this isn't master (per vira.hs). On master, the same CI pipeline benefits from cache.nixos.asia/oss — expect this run to land much faster as an actual master push.

Workflow completed at 2026-04-25.

srid added 2 commits April 25, 2026 19:49
A bad `subfolder/index.yaml` only contributes meta to notes under
`/subfolder/*`; before this commit its banner was shown globally, on
every page, which obscured *where* the bad file was relevant. Now the
banner uses the same `routeInits @'R.Yaml` cascade that
`getEffectiveRouteMetaWith` walks — so the banner lands on the notes
the bad yaml actually affects, and nowhere else.

Pair the e2e fixture: `broken-285.md` sits next to `broken-285.yaml`
so the regression scenario can land on `/broken-285.html` (where the
banner belongs) instead of polluting every other test's view of the
home page. Two scenarios now: one asserts the banner is present on
the sibling note, one asserts it does *not* leak onto unrelated pages.

Open question: a malformed *.yaml with no md sibling (the original
pyyaml-in-notebook case) is now invisible to the user. Worth a
follow-up — surface orphan errors on the root index as a fallback —
but out of scope for this commit, which targets the scoping bug.
Drop the parallel `_modelDataErrors :: Map (R 'Yaml) Text` and fold
the failure case into SData itself: every loaded `*.yaml` file maps
to exactly one SData entry whose `_sdataValue` carries the parsed
content (or `Aeson.Null` on failure) and whose new `_sdataError ::
Maybe Text` carries the parse message.

`parseSDataCascading` no longer returns `Either` — it returns the
SData representing the parse outcome. Patch.hs collapses to a single
`modelInsertData`, no case match. The renderer walks the route's
yaml cascade via `routeInits` and gathers errors from each looked-up
SData, so the existing `getEffectiveRouteMetaWith` cascade pattern
is reused.

Net effect: one mapping ("yaml route → outcome") instead of two
parallel structures. Same observable behavior — the cascade still
filters errors to notes whose meta actually depends on the bad file.
Comment thread emanote/src/Emanote/Model/SData.hs Outdated
Comment on lines +21 to +22
a successful parse populates `_sdataValue`; a failed parse leaves it
`Aeson.Null` and records the message in `_sdataError`. Modeling the
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wtf, use Either Aeson.Value Text then.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 599d852. Went with Either Text Aeson.Value (error-on-Left, the conventional order) — parseSDataCascading is now a one-liner: SData (mergeAesons <$> traverse parseOne bs) r. Holler if you actually wanted the flipped Either Aeson.Value Text.

srid added 2 commits April 25, 2026 20:05
Per srid review: the previous shape — a `_sdataValue :: Aeson.Value`
plus a sibling `_sdataError :: Maybe Text` — let invalid states exist
(a value AND an error in the same record). `Either Text Aeson.Value`
makes the parse outcome a sum type: success on the right, failure on
the left, no overlap.

`parseSDataCascading` collapses to a one-liner: `mergeAesons <$>
traverse parseOne bs`, wrapped in SData with the route. Consumers:

* `Meta.getYamlMeta` returns `rightToMaybe (s ^. sdataValue)` — same
  effect as the old `guard $ v /= Aeson.Null` filter, with the type
  doing the work.
* `Patch.hs` logs via `whenLeft_`.
* `Template.cascadeYamlErrors` collects via `leftToMaybe`.
@srid srid changed the title Surface malformed *.yaml as a banner instead of crashing (#285) Don't crash on malformed *.yaml; surface as a banner (#285) Apr 26, 2026
Two findings, both for this PR:

1. Move `cascadeYamlErrors` from `View/Template.hs` to `Model/Meta.hs`
   alongside `getYamlMeta` / `getEffectiveRouteMetaWith`. Same shape
   (route → routeInits cascade → SData lookup), same volatility (changes
   when cascade rules or SData representation change). Was sitting in
   `Template.hs` only because it had one caller — that's colocation by
   use, not by change-reason.

2. Promote `errorDiv` from a where-clause inside `mkNoteWith` to a
   top-level shared builder in `Note.hs`, parameterized by the banner
   header text. Both error paths — per-note markdown errors and
   route-cascade yaml errors — now go through the same
   `B.Div (cls "emanote:error") ...` shape. The CSS class is the actual
   sync contract (a Heist splice rewrites it into Tailwind utilities);
   sharing the builder prevents the two surfaces from silently diverging
   when one gets tweaked.
@srid srid marked this pull request as ready for review April 26, 2026 18:55
@srid srid merged commit dc55cd8 into master Apr 26, 2026
6 checks passed
@srid srid deleted the fix-285-malformed-yaml branch April 26, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Malformed YAML files break Emanote

1 participant