Skip to content

Error contract P1: jinja2::Exception, one-pointer ErrorInfo, codes 15-18, Load/Render boundary (0253) - #597

Merged
rmorozov merged 21 commits into
masterfrom
claude/error-contract-p1-zsexdv
Oct 19, 2026
Merged

rmorozov merged 21 commits into
masterfrom
claude/error-contract-p1-zsexdv

Conversation

@rmorozov

@rmorozov rmorozov commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Requested by Ruslan · project thread

Before: a host callable that threw int escaped Render; std::bad_alloc became UnexpectedException; a host had no way to report a specific code or get its exception back; ErrorInfo was ~128 B by value; LoadFromFile("missing") returned success; a BigInt that reached a template failed as UnexpectedException. A bad_alloc in the middle of Load or Render could leave a broken arena or hash table behind, or terminate the process on MSVC (crashes found by the new test).

After: Load, Render, GetMetadata* and TemplateEnv::LoadTemplate* return every failure in Result, and only std::bad_alloc escapes, with the template still usable. A host throws jinja2::Exception{code} and gets that code back; any other host exception arrives as UnexpectedException with the original kept in ErrorInfo::GetException(). Errors the engine raises (its limits, a BigInt it cannot hold yet) carry their code and no exception. ErrorInfo is one pointer. ErrorCode works with std::error_code.

This is phase P1 of the 0250 error contract (docs/api-2.0.md §2.1, decisions D1-D10 approved 2026-10-18). P2 (render locations, 0254) and P3 (engine error codes, 0255) change no public header. Task 0253 is marked done here.

How.

  • include/jinja2cpp/exception.h (new, in fwd.h and jinja2cpp.h): exported jinja2::Exception : std::runtime_error with Code() and the C++ origin (File()/Line() from __builtin_FILE/__builtin_LINE default arguments, one layout at every standard). The destructor is out of line in src/error_info.cpp (key function).
  • ErrorCode 15 UserCallableException, 16 IntegerOutOfRange, 17 TemplateRuntimeError, 18 ResourceLimitExceeded, with ToString texts. jinja2::error_category(), make_error_code, std::is_error_code_enum; FileNotFound/TemplateNotFound compare equal to errc::no_such_file_or_directory, 16/18 to value_too_large, InvalidValueType/InvalidTemplateName to invalid_argument.
  • BasicErrorInfo holds one intrusive-refcounted pointer to an immutable payload. Data gains a trailing std::exception_ptr exception (aggregate init stays source compatible). BasicErrorInfo(Data) and every getter keep their signatures and are now noexcept, as are copy and move. New GetException(). The wide conversion keeps relatedLocs and the exception.
  • template.h: BadResultAccess<CharT>. config.h: #error without exceptions; C4275 joins the MSVC suppression macro.
  • src/error_boundary.h: CurrentExceptionToError classifies the exception being handled (bad_alloc rethrown; jinja2::Exception keeps its code; std::length_error is 18; anything else, including non-std::exception types, is 1). Used by TemplateImpl::Load (the autoescape selector from Markup from C++: jinja2::Markup / WMarkup in Value (0180) #590 runs inside it; a selector that throws is a host exception), Render, GetMetadata and TemplateEnvImpl::LoadTemplate (filesystem handlers).
  • Engine errors (src/engine_errors.h): the engine's own limits (string and list repeat caps, the sequence size check, the arena caps, templates per render) throw ResourceLimitError (a std::length_error), reported as 18; a BigInt reaching a template (until 0270) throws IntegerOutOfRangeError (a std::overflow_error), reported as 16. Neither keeps GetException(), as docs/api-2.0.md 2.1 says for errors the engine raises. All HugeSequencesFailFast rows now report 18 (previously 1). A host std::length_error is also 18 and keeps its exception. After merging Settings::Limits: bounds on what one Load or Render may do (0262) #601 one ResourceLimitError serves both Settings::Limits and the built-in caps: a Settings::Limits error names its limit in ExtraParams[1], a built-in cap names none, and Render's separate catch for it is gone.
  • Dropped after merging master: the plan's Value::TryFrom and detail::ThrowIntegerOutOfRange. With BigInt (0269) Value(I) stores an integer outside int64 instead of throwing, so the sibling could only fail on bad_alloc. docs/api-2.0.md updated to match.
  • LoadFromFile of a missing file returns FileNotFound. ToChars reports invalid_argument unless the failure was bad_alloc.
  • The new headers and src/error_info.cpp are in inline namespace v2 (Inline ABI namespace jinja2::v2 and its release gates (0076 PR-2) #604, scripts/abi_namespace.py --check passes).
  • REVIEW.md's "Errors reach the caller as a Result" rule now describes this boundary.

Allocation-failure injection (the R2 proof obligation, plan §9.5). test/allocation_failure_test.cpp is a separate binary, jinja2cpp_alloc_tests, because it replaces the global operator new and would otherwise hide the sanitizers' own from every other test. It fails the Nth allocation, for every N, on a fresh thread so per-thread pools start cold. It covers Load, Render and GetMetadata of six templates: includes, imports, macros, extends, filters, a callable and dict literals. Each run must succeed with the right output or throw bad_alloc, and the template must work again afterwards. When the process dies it prints the failing allocation number (and, on MSVC C++23, a stack). It never fails MSVC's 16-byte checked-iterator proxy allocations, which Debug builds make inside noexcept standard constructors. It found these bugs, fixed here:

  • NodeArena::Allocate listed a new block before its storage existed, so ~NodeArena walked a null block (segfault in Load).
  • robin_hood is not exception safe upstream. operator[], try_emplace and insert_or_assign reserved the slot before building the node; initData changed the table before allocating; copies left half-built tables; a failed rehash kept a new hash multiplier (the verifier found that one). The fixes are a third documented local change in src/robin_hood.h, recorded in the SBOM. 0231's successor map must pass this test too.
  • IsFilterName and the nlohmann metadata parser swallowed bad_alloc, and GetMetadata/GetMetadataRaw copied into expected-lite's noexcept converting constructor, where bad_alloc meant std::terminate.
  • MSVC only: std::list and std::unordered_map allocate when default constructed. OrderedMap's swap-based move was noexcept, so Load of a call with keyword arguments terminated; its moves are now noexcept only where both containers' default constructors are (still noexcept on libstdc++ and libc++). TemplateDict (from master) declared defaulted noexcept moves over an OrderedMap, so Render of a dict literal terminated; they now follow OrderedMap's. ~BlocksStack emplaced the ended blocks of a template's self, so Render of a template with blocks terminated; SelfBlocks::ended is now made with the self, and the destructor only moves into it.

Where it does not run: on a Windows DLL build the library uses its CRT's operator new, so the tests skip. The TSan and MSan runtimes define operator new themselves, so those builds do not build the binary. With json=nlohmann the metadata row skips: nlohmann::basic_json's destructor allocates for non-empty arrays and objects, so a failure there terminates inside a noexcept destructor; that is upstream behaviour, filed as task 0279 (boost and rapid are unaffected). A reference-cycle row (0110's collector) passes but leaks under LSan when an allocation fails mid-render; it is left out and filed as task 0283.

Tests. ErrorContractTest in errors_test.cpp: static_asserts on codes 15-18, sizeof(ErrorInfo) == sizeof(void*), noexcept copy, move and getters, is_base_of<runtime_error, Exception> and BadResultAccess. Behaviour rows: a callable throwing a non-std type gives 1 and rethrows it; bad_alloc propagates; length_error gives 18; a host jinja2::Exception{UndefinedError} gives 11; a throwing list accessor; a throwing autoescape selector; Exception origin; a BigInt in a template (direct and reflected) gives 16 with no exception; error_code equivalence; payload sharing and the wide conversion; LoadFromFile missing (narrow and wide); a throwing filesystem handler (narrow, wide, through an include); ToChars. HugeSequencesFailFast checks that engine limits carry no exception. BigInt rows in basic_tests.cpp expect 16 where master expected 1. fwd_test.cpp declares a function over jinja2::Exception through fwd.h.

Roles and rounds. Main session implemented; ci-triage read the red jobs. Verifier: 1 round. It ran clang-18 C++20 with nlohmann, a GCC shared build, ASan+UBSan, its own robin_hood and allocation fuzz harnesses, TSan on ErrorInfo copies, and C++17 header self-containment. Two blocking findings, both fixed: the robin_hood rehash multiplier, and the alloc test on a Windows DLL. It also found the noexcept expected conversion in GetMetadata, fixed. Local, after merging #601 and #604: ctest 5/5 on GCC Debug, on clang-18 ASan+UBSan and on GCC Release shared json=nlohmann; scripts/preflight.sh --perf passes (instruction gate within +3% on every benchmark). Red CI pushes: 8. (1) dcaffcf: the nlohmann jobs failed the metadata allocation row (the parser's catch-all turned bad_alloc into MetadataParseError; fixed in 1b5a620, 0279 filed). clang-tidy failed because include/jinja2cpp/exception.h borrowed the flags of Boost's filesystem/src/exception.cpp, which lack the generated include dir; the workflow and preflight now pass it. TSan failed to link the alloc tests, which are now not built there. Both Windows static jobs failed jinja2cpp_alloc_tests with its output past the log tail; it now runs after the parity run so the output is visible. The local runs had used only the boost binding, no TSan, and dependencies outside build/_deps. (2) daeed23 never ran: master had moved and the PR conflicted. (3-6) 24e843b, fa92426, fdcb9c4 and 7358fe0: the two Windows static jobs aborted in jinja2cpp_alloc_tests, each push getting one step further (crash report, the Debug proxy allocations, OrderedMap's move, ~BlocksStack). The cloud cannot build with MSVC, so each step needed a CI run. (7) 520411c (the first master merge): clang-tidy bugprone-unhandled-self-assignment on ErrorInfo's copy-and-swap through a temporary, newly on changed lines against the new base; preflight had reported it, and the push went out anyway. Fixed in 862ae2e, green on every job. (8) 685cf51 (the second master merge, #601 and #604): both Windows static jobs aborted in jinja2cpp_alloc_tests on the dict-literal row, in master's TemplateDict noexcept move (MSVC only, so preflight could not see it); fixed in 92b2896. Codex: 2 findings, 2 fixed, 0 declined (a P2 and a P3 on 862ae2e, fixed in 408d958; @codex security review was requested twice but each request ran a code review, so no security review result).


Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE

…253)

- jinja2::Exception (exception.h): std::runtime_error with Code() and the
  C++ origin of the throw (__builtin_FILE/__builtin_LINE), exported, key
  function out of line.
- ErrorCode 15-18 (UserCallableException, IntegerOutOfRange,
  TemplateRuntimeError, ResourceLimitExceeded) with ToString texts;
  jinja2::error_category(), is_error_code_enum and make_error_code, so
  FileNotFound/TemplateNotFound compare equal to errc::no_such_file_or_directory.
- BasicErrorInfo is one pointer to a shared immutable payload that also
  keeps the original exception (GetException()); copies and moves and all
  getters are noexcept. The wide conversion keeps relatedLocs and the
  exception.
- Value(I) and the integral Reflector throw
  jinja2::Exception{IntegerOutOfRange} from an out-of-line
  detail::ThrowIntegerOutOfRange; new Value::TryFrom(I, Value&) noexcept.
- BadResultAccess<CharT>; config.h #error without exceptions.
- Load, Render, GetMetadata and TemplateEnv::LoadTemplate: std::bad_alloc
  propagates; std::length_error and string/list repeat caps become
  ResourceLimitExceeded; host jinja2::Exception keeps its code; anything
  else is UnexpectedException with the exception kept.
- LoadFromFile of a missing file returns FileNotFound; ToChars reports
  invalid_argument for non-allocation failures.
- Allocation-failure injection test (new jinja2cpp_alloc_tests binary)
  and the bugs it found: a NodeArena block listed before its storage
  existed, robin_hood inserts, growth and copies that left a broken table
  after a throw (third local change, documented in the header and SBOM),
  and IsFilterName swallowing bad_alloc.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
…a copies before expected, Windows DLL skip

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
@rmorozov rmorozov self-assigned this Oct 18, 2026
… (0279)

nlohmann::basic_json's destructor allocates for non-empty containers, so an
allocation failure there terminates; filed as task 0279.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
rmorozov pushed a commit that referenced this pull request Oct 18, 2026
…0279)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEfqEiJoJF5wCn3xZSp8zW
…tests last

A header borrows the compile flags of the most similar database entry, and in CI
include/jinja2cpp/exception.h picks Boost's filesystem/src/exception.cpp, which
lacks build/generated/include. The allocation tests move after the parity run
so their output stays in the Windows log tail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
Their runtimes define the global operator new, so the replacements in
allocation_failure_test.cpp fail to link there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
…oundary

Engine limits throw ResourceLimitError (a std::length_error), which the boundary
reports as ResourceLimitExceeded without GetException(), as docs/api-2.0.md 2.1
now says; a host std::length_error still keeps its exception. Files 0282 (cycle
leaks when an allocation fails mid-render) and records 0253 in progress.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
…ss dies

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
MSVC debug containers allocate a proxy in noexcept constructors such as
basic_string(), so failing one is std::terminate in the test's own code
(the MSVC C++23 Debug job's stack: Load's default name argument).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
…structors are

MSVC's std::list and std::unordered_map allocate a sentinel when default
constructed, so the swap-based move threw bad_alloc through noexcept and
terminated (alloc test, Load of a filter call with keyword arguments).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
…uctor does not allocate

~BlocksStack emplaced SelfBlocks::ended, which default-constructs an
unordered_map; MSVC allocates there, so a failed allocation threw out of the
destructor and terminated (alloc test, Render of a template with blocks).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE

Copy link
Copy Markdown
Member Author

@codex review

@codex security review (this PR changes the Load/Render error boundary, the loaders' file handling and the evaluator's limit errors)


Generated by Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T09:54:25.288397Z 862ae2e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 52d55f3a15

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown
Member Author

@codex security review


Generated by Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 52d55f3a15

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

rmorozov pushed a commit that referenced this pull request Oct 19, 2026
…0279)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEfqEiJoJF5wCn3xZSp8zW
Value(I) now stores a BigInt outside int64 instead of throwing, so
Value::TryFrom and detail::ThrowIntegerOutOfRange are dropped. A BigInt that
reaches a template throws IntegerOutOfRangeError (src/engine_errors.h, was
resource_limit.h): code 16 with no exception, as an engine error. The
autoescape selector runs inside the Load boundary. Exception joins fwd.h
and jinja2cpp.h; task 0253 is done.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
…ne-unhandled-self-assignment)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE

Copy link
Copy Markdown
Member Author

@codex review

(After merging master: Value::TryFrom dropped now that BigInt landed, a BigInt reaching a template reports IntegerOutOfRange as an engine error, the autoescape selector runs inside the Load boundary, Exception added to fwd.h/jinja2cpp.h.)


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 862ae2e49c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/template_impl.h
Comment thread REVIEW.md Outdated
…); REVIEW.md names engine_errors.h

The selector ran inside Load's try, so an ErrorInfo it threw was taken for
the parser's own and returned as is. It now runs in its own try that goes
through CurrentExceptionToError.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
One ResourceLimitError (src/engine_errors.h) serves Settings::Limits, named by
its field, and the built-in caps, without one; CurrentExceptionToError reports
both as ResourceLimitExceeded with the field as extraParams[1] when present,
so Render's own catch for it is gone. The built-in sequence cap throws it
instead of std::length_error. exception.h, engine_errors.h and
error_boundary.h are wrapped in v2 by scripts/abi_namespace.py.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
#601 brought TemplateDict with defaulted noexcept moves over an
OrderedMap, whose moves allocate on MSVC; a bad_alloc there in a dict
literal's render terminated (jinja2cpp_alloc_tests, Windows static jobs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
@rmorozov
rmorozov marked this pull request as ready for review October 19, 2026 16:40
Load rose 0.4-1.4% on every case at 92b2896, all in Allocate: the
exception-safe new-block path and the ResourceLimitError message string
were built inline into it. Both are now out of line (AddBlock,
ThrowTooLarge); Load is 1-2.5% below 5fe00b9 on every case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sRZrdyAGVT1hBgARAkNAE
@rmorozov
rmorozov merged commit 2acdacb into master Oct 19, 2026
41 checks passed
rmorozov pushed a commit that referenced this pull request Oct 19, 2026
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTQTriEsFrrBgcYNKDUUF4
rmorozov added a commit that referenced this pull request Oct 19, 2026
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.

2 participants