Bound and offload grammar validation for response_format - #4987
windreamer wants to merge 7 commits into
Conversation
A deeply nested response_format JSON schema makes XGrammar's compile time grow exponentially with depth. The compile ran synchronously on the asyncio event loop with no depth, size, or time bound, so a small request body could stall the whole single-process API server for tens of seconds (GHSA-6rm2-wfp4-m82q). - reject grammar sources over 16 KiB and JSON schemas deeper than 128 raw JSON levels (~64 schema levels) with HTTP 400 before compilation; the check lives in _grammar_source so both API-process validation and engine-side compilation inherit it - run validation in a worker thread under a 5 s budget via asyncio.wait_for(asyncio.to_thread(...)) so the event loop, health endpoint, and streaming responses stay responsive
There was a problem hiding this comment.
🟡 Changes recommended
The byte limit can be bypassed with multibyte Unicode, and timed-out worker-thread compilations continue running without cancellation.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds bounded, asynchronous response_format grammar validation to reduce XGrammar resource risks.
Changes:
- Enforces grammar size and JSON nesting limits.
- Moves validation off the asyncio event loop with a timeout.
- Adds unit tests and English/Chinese documentation.
File summaries
| File | Description |
|---|---|
lmdeploy/_guided_decoding.py |
Implements bounds and asynchronous validation. |
lmdeploy/serve/core/async_engine.py |
Awaits request validation. |
tests/test_lmdeploy/test_guided_decoding.py |
Tests limits, timeout, and responsiveness. |
docs/en/advance/structed_output.md |
Documents validation limits. |
docs/zh_cn/advance/structed_output.md |
Documents validation limits in Chinese. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🟡 Changes recommended
The byte limit counts characters, timed-out worker compiles continue running, and one new deep-schema test can fail during setup.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
lmdeploy/_guided_decoding.py:50
MAX_GRAMMAR_SOURCE_BYTESis documented and named in bytes, butlen(source)counts Unicode code points. A schema or regex containing multibyte UTF-8 characters can therefore exceed 16 KiB while passing this check; measure the UTF-8 encoded length so the enforced limit matches the advertised bound.
if len(source) > MAX_GRAMMAR_SOURCE_BYTES:
lmdeploy/_guided_decoding.py:144
asyncio.wait_forcancels the awaitable on timeout, but it cannot stop the underlyingto_threadworker. A timed-out XGrammar compile therefore continues consuming a default-executor slot and CPU; enough concurrent hostile requests can exhaust the workers or keep compiling after the request was rejected, so this is not a hard bound on the validation work. Enforce cancellation/backpressure with a bounded worker that can actually terminate the compile (for example, process isolation).
await asyncio.wait_for(
asyncio.to_thread(_ensure_response_format_compilable, response_format), timeout=GRAMMAR_COMPILE_TIMEOUT
)
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
- count grammar source size in UTF-8 bytes, not code points; with ensure_ascii=False a code-point count understates multibyte sources by up to 4x - run validation in a small dedicated executor instead of the default one: a compile that outlives its request's 5 s timeout cannot be cancelled, and isolating it keeps such stragglers from occupying the interpreter's default executor shared by other offloaded work
There was a problem hiding this comment.
🟡 Changes recommended
Validation failures can leak API-created sessions because cleanup runs before session assignment.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
preprocess ran the response_format check before taking charge of the session, so a rejection raised ValueError while the local session variable was still None and remove_session() no-op'ed, leaking the caller-created session (api_server registers it in SessionManager before calling the engine). Resolve the session and check its conflict state first, then validate; cleanup now covers rejections.
There was a problem hiding this comment.
🟡 Changes recommended
Structural tags can still bypass the JSON-schema nesting-depth limit.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
The depth walker only counted dict/list levels, so in-process callers could smuggle depth through tuples, which json.dumps serializes as arrays, and a cyclic dict spun the walker forever on the synchronous engine-side compile_response_format path (previously it failed with json.dumps' circular-reference ValueError). The walk now counts every container type json.dumps serializes, stops as soon as the cap is exceeded, and rejects cycles on the active path.
Merge same-path cases (depth cap pass/reject, string-path branches, async valid/invalid), drop the redundant over-deep dict case (identical rejection branch as one-past-cap), and keep every branch covered: the tuple and cycle walkers, the string-path size bound, the multibyte byte-count regression, engine-path bounds, pool isolation, timeout, loop liveness, and session cleanup.
|
Replying to the 2026-09-18 07:59 Copilot review — both findings from that round are resolved on the branch:
The request-admission change (session resolved before response_format validation) also carries a regression test asserting cleanup covers rejections. Tests were then consolidated 18 → 13 in 8959f80 without losing branch coverage. |
What
response_formatgrammar sources are now validated within fixed bounds before XGrammar compilation, and validation runs off the asyncio event loop under a hard timeout:propertieswrappers); over-limit requests are rejected with HTTP 400.ensure_response_format_compilablein a worker thread with a 5-second budget (asyncio.wait_for(asyncio.to_thread(...))), so validation can never stall the event loop, health endpoint, or streaming responses._grammar_source, the single choke point shared by API-process validation and engine-side compilation (PyTorchGuidedDecodingManager, TurboMind), so all paths are covered. No engine-side code change is required.Why
Grammar compilation cost grows exponentially with schema nesting depth, and XGrammar (<= 0.2.x) exposes no depth or time limit on that path. Compiling client-supplied schemas synchronously on the event loop without bounds is a resource-safety hazard for the serving process. Details in the security advisory (private until publication).
Tests
13 new offline unit tests (
tests/test_lmdeploy/test_guided_decoding.py, no GPU/tokenizer needed) cover:ValueError; event loop stays responsive (heartbeat) during a real uncached validationDocs: limits section added to
structed_output.md(en/zh).