fix(grammars): reject cyclic $ref in JSON-schema grammar to prevent stack-overflow crash (#11020) (#11041)

* fix(grammars): reject cyclic $ref in JSON-schema grammar to prevent stack-overflow crash

JSONSchemaConverter.visit resolved $ref entries by recursively calling
itself with no cycle detection. A client-supplied grammar_json_functions
schema whose $defs contains a self- or mutually-referential $ref (e.g.
{"A": {"$ref": "#/$defs/A"}}) made visit recurse until the goroutine
stack was exhausted, producing a fatal "stack overflow" that kills the
whole process rather than failing the single request. The schema is
converted synchronously in the /v1/chat/completions handler before any
backend call, so this is an unauthenticated remote crash. Fixes #11020.

Track the $ref targets currently on the recursion stack and error out
when one is re-entered, while popping after each descent so sibling
(non-cyclic) reuse of the same $ref is still allowed.

Signed-off-by: Tai An <antai12232931@outlook.com>

* fix(grammars): add a bounded recursion depth and cover llama31 $ref cycles

Addresses the review on #11041. The stack-set approach catches cyclic
$ref chains, but a deeply nested yet acyclic client schema (thousands of
nested arrays/objects) can still recurse through visit until the
goroutine stack is exhausted, which is the same unauthenticated remote
crash surface as #11020.

- Add a bounded depth counter to JSONSchemaConverter.visit (incremented
  with a defer-based cleanup, capped at maxSchemaDepth = 256, far above
  any realistic schema) so an over-deep schema fails the request with an
  ordinary error instead of crashing the process.
- Apply the same cyclic-$ref guard and depth bound to
  LLama31SchemaConverter.visit, the other production grammar entry point
  named in #11020, which previously had no cycle detection at all.
- Regression tests: a deeply nested acyclic schema is rejected while a
  moderately nested one still builds, plus direct/indirect $ref cycle
  and depth tests for the llama31 converter.

Signed-off-by: Tai An <antai12232931@outlook.com>

* test(grammars): make llama31 cycle fixtures valid function-call shapes

The two new llama31 $ref-cycle specs asserted on "cyclic $ref" but the
converter requires each top-level oneOf alternative to carry its
function-name property before descending, so both fixtures failed
earlier with "no function name found in the schema" and never reached
the cycle guard.

Give each fixture a valid llama31 shape: construct the converter with
NewLLama31SchemaConverter("function"), put "function": {"const": "test"}
on the top-level alternative, and hang the cyclic $ref under an
arguments property, so all 29 grammar specs pass and the assertions
genuinely observe the cyclic $ref error.

Signed-off-by: Tai An <antai12232931@outlook.com>

---------

Signed-off-by: Tai An <antai12232931@outlook.com>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
This commit is contained in:
Tai An
2026-07-30 03:06:17 -07:00
committed by GitHub
parent c5ae41a29d
commit 37f2087f97
4 changed files with 198 additions and 10 deletions

View File

@@ -10,9 +10,25 @@ import (
"strings"
)
// maxSchemaDepth bounds how deeply visit may recurse into a client-supplied
// schema. A cyclic $ref is caught by refsInProgress, but a deeply nested yet
// acyclic schema (e.g. thousands of nested arrays/objects) could still recurse
// until the goroutine stack is exhausted and crash the whole process. Rejecting
// the request once this depth is exceeded turns that potential crash into an
// ordinary per-request error. The limit is far above any realistic schema.
const maxSchemaDepth = 256
type JSONSchemaConverter struct {
propOrder map[string]int
rules Rules
// refsInProgress tracks the $ref targets currently on the recursion stack
// so a self- or mutually-referential schema cannot recurse forever. It is
// pushed before descending into a referenced schema and popped afterwards,
// so sibling (non-cyclic) reuse of the same $ref is still allowed.
refsInProgress map[string]bool
// depth is the current recursion depth of visit, bounded by maxSchemaDepth
// to guard against stack exhaustion on deeply nested acyclic schemas.
depth int
}
func NewJSONSchemaConverter(propOrder string) *JSONSchemaConverter {
@@ -26,8 +42,9 @@ func NewJSONSchemaConverter(propOrder string) *JSONSchemaConverter {
rules["space"] = SPACE_RULE
return &JSONSchemaConverter{
propOrder: propOrderMap,
rules: rules,
propOrder: propOrderMap,
rules: rules,
refsInProgress: make(map[string]bool),
}
}
@@ -60,6 +77,11 @@ func (sc *JSONSchemaConverter) addRule(name, rule string) string {
}
func (sc *JSONSchemaConverter) visit(schema map[string]any, name string, rootSchema map[string]any) (string, error) {
sc.depth++
defer func() { sc.depth-- }()
if sc.depth > maxSchemaDepth {
return "", fmt.Errorf("schema nesting exceeds maximum depth %d while building grammar", maxSchemaDepth)
}
st, existType := schema["type"]
var schemaType string
var schemaTypes []string
@@ -115,11 +137,21 @@ func (sc *JSONSchemaConverter) visit(schema map[string]any, name string, rootSch
rule := strings.Join(alternatives, " | ")
return sc.addRule(ruleName, rule), nil
} else if ref, exists := schema["$ref"].(string); exists {
// A client-supplied schema may contain a cyclic $ref (e.g. a $def that
// references itself directly or through a chain). Without this guard the
// recursion below never terminates and exhausts the goroutine stack,
// crashing the whole process rather than just failing the request.
if sc.refsInProgress[ref] {
return "", fmt.Errorf("cyclic $ref detected while building grammar: %s", ref)
}
referencedSchema, err := sc.resolveReference(ref, rootSchema)
if err != nil {
return "", err
}
return sc.visit(referencedSchema, name, rootSchema)
sc.refsInProgress[ref] = true
result, err := sc.visit(referencedSchema, name, rootSchema)
delete(sc.refsInProgress, ref)
return result, err
} else if constVal, exists := schema["const"]; exists {
literal, err := sc.formatLiteral((constVal))
if err != nil {

View File

@@ -605,3 +605,73 @@ var _ = Describe("JSON schema property ordering (issue #10052)", func() {
Expect(keyIndex(grammar, "arguments")).To(BeNumerically("<", keyIndex(grammar, "aaa_unlisted")))
})
})
var _ = Describe("JSON schema grammar cyclic $ref handling", func() {
// A client-supplied grammar_json_functions schema with a cyclic $ref used to
// recurse until the goroutine stack overflowed, crashing the whole process
// instead of failing the single request. The converter must now return an
// error for such schemas rather than recursing forever.
It("returns an error for a directly self-referential $ref", func() {
const schema = `{
"$defs": {"A": {"$ref": "#/$defs/A"}},
"oneOf": [{"type": "object", "properties": {"x": {"$ref": "#/$defs/A"}}}]
}`
_, err := NewJSONSchemaConverter("").GrammarFromBytes([]byte(schema))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cyclic $ref"))
})
It("returns an error for an indirect $ref cycle (A -> B -> A)", func() {
const schema = `{
"$defs": {
"A": {"type": "object", "properties": {"b": {"$ref": "#/$defs/B"}}},
"B": {"type": "object", "properties": {"a": {"$ref": "#/$defs/A"}}}
},
"$ref": "#/$defs/A"
}`
_, err := NewJSONSchemaConverter("").GrammarFromBytes([]byte(schema))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cyclic $ref"))
})
It("still resolves a non-cyclic $ref reused by sibling properties", func() {
const schema = `{
"$defs": {"Leaf": {"type": "string"}},
"type": "object",
"properties": {
"x": {"$ref": "#/$defs/Leaf"},
"y": {"$ref": "#/$defs/Leaf"}
}
}`
grammar, err := NewJSONSchemaConverter("").GrammarFromBytes([]byte(schema))
Expect(err).To(BeNil())
Expect(grammar).ToNot(BeEmpty())
})
// A deeply nested but acyclic schema has no $ref cycle to catch, yet it can
// still recurse deeply enough to exhaust the goroutine stack. The bounded
// depth counter must reject it with an ordinary error instead of crashing.
It("returns an error for a schema nested deeper than the depth limit", func() {
// Wrap a string leaf in enough nested "array"/"items" layers to exceed
// maxSchemaDepth; each layer is one visit() recursion.
const layers = 400
schema := `{"type": "string"}`
for i := 0; i < layers; i++ {
schema = `{"type": "array", "items": ` + schema + `}`
}
_, err := NewJSONSchemaConverter("").GrammarFromBytes([]byte(schema))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("maximum depth"))
})
It("still builds a grammar for a moderately nested acyclic schema", func() {
const layers = 32
schema := `{"type": "string"}`
for i := 0; i < layers; i++ {
schema = `{"type": "array", "items": ` + schema + `}`
}
grammar, err := NewJSONSchemaConverter("").GrammarFromBytes([]byte(schema))
Expect(err).To(BeNil())
Expect(grammar).ToNot(BeEmpty())
})
})

View File

@@ -12,6 +12,14 @@ import (
type LLama31SchemaConverter struct {
fnName string
rules Rules
// refsInProgress tracks the $ref targets currently on the recursion stack so
// a self- or mutually-referential schema cannot recurse forever, mirroring
// the guard in JSONSchemaConverter. This is a production entry point named
// in the same crash report (#11020).
refsInProgress map[string]bool
// depth is the current recursion depth of visit, bounded by maxSchemaDepth
// to guard against stack exhaustion on deeply nested acyclic schemas.
depth int
}
func NewLLama31SchemaConverter(fnName string) *LLama31SchemaConverter {
@@ -22,8 +30,9 @@ func NewLLama31SchemaConverter(fnName string) *LLama31SchemaConverter {
}
return &LLama31SchemaConverter{
rules: rules,
fnName: fnName,
rules: rules,
fnName: fnName,
refsInProgress: make(map[string]bool),
}
}
@@ -74,6 +83,11 @@ func (sc *LLama31SchemaConverter) addRule(name, rule string) string {
}
func (sc *LLama31SchemaConverter) visit(schema map[string]any, name string, rootSchema map[string]any) (string, error) {
sc.depth++
defer func() { sc.depth-- }()
if sc.depth > maxSchemaDepth {
return "", fmt.Errorf("schema nesting exceeds maximum depth %d while building grammar", maxSchemaDepth)
}
st, existType := schema["type"]
var schemaType string
if existType {
@@ -111,11 +125,20 @@ func (sc *LLama31SchemaConverter) visit(schema map[string]any, name string, root
rule := strings.Join(alternatives, " | ")
return sc.addRule(ruleName, rule), nil
} else if ref, exists := schema["$ref"].(string); exists {
// Guard against cyclic $ref chains that would otherwise recurse until
// the goroutine stack is exhausted, crashing the whole process rather
// than failing the single request (#11020).
if sc.refsInProgress[ref] {
return "", fmt.Errorf("cyclic $ref detected while building grammar: %s", ref)
}
referencedSchema, err := sc.resolveReference(ref, rootSchema)
if err != nil {
return "", err
}
return sc.visit(referencedSchema, name, rootSchema)
sc.refsInProgress[ref] = true
result, err := sc.visit(referencedSchema, name, rootSchema)
delete(sc.refsInProgress, ref)
return result, err
} else if constVal, exists := schema["const"]; exists {
literal, err := sc.formatLiteral((constVal))

View File

@@ -43,8 +43,8 @@ const (
// <function=example_function_name>{{"example_name": "example_value"}}</function>
testllama31inputResult1 = `root-0-function ::= "create_event"
freestring ::= (
[^"\\] |
"\\" (["\\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F])
[^"\] |
"\\" (["\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F])
)* space
root-0 ::= "<function=" root-0-function ">{" root-0-arguments "}</function>"
root-1-arguments ::= "{" space "\"query\"" space ":" space string "}" space
@@ -53,8 +53,8 @@ space ::= " "?
root-0-arguments ::= "{" space "\"date\"" space ":" space string "," space "\"time\"" space ":" space string "," space "\"title\"" space ":" space string "}" space
root-1 ::= "<function=" root-1-function ">{" root-1-arguments "}</function>"
string ::= "\"" (
[^"\\] |
"\\" (["\\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F])
[^"\] |
"\\" (["\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F])
)* "\"" space
root-1-function ::= "search"`
)
@@ -74,3 +74,66 @@ var _ = Describe("JSON schema grammar tests", func() {
})
})
})
var _ = Describe("LLama31 schema grammar cyclic $ref and depth handling", func() {
// LLama31SchemaConverter.visit is another production entry point named in
// the crash report (#11020). A cyclic $ref or a deeply nested acyclic schema
// must fail the request rather than recurse until the stack is exhausted.
//
// The llama31 converter expects each top-level oneOf alternative to carry
// its configured function-name property before it descends into arguments,
// so the fixtures below use a valid function-call shape and hang the cyclic
// $ref under `arguments` to make sure the cycle guard is what trips.
It("returns an error for a directly self-referential $ref", func() {
const schema = `{
"$defs": {"A": {"$ref": "#/$defs/A"}},
"oneOf": [
{
"type": "object",
"properties": {
"function": {"const": "test"},
"arguments": {
"type": "object",
"properties": {"x": {"$ref": "#/$defs/A"}}
}
}
}
]
}`
_, err := NewLLama31SchemaConverter("function").GrammarFromBytes([]byte(schema))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cyclic $ref"))
})
It("returns an error for an indirect $ref cycle (A -> B -> A)", func() {
const schema = `{
"$defs": {
"A": {"type": "object", "properties": {"b": {"$ref": "#/$defs/B"}}},
"B": {"type": "object", "properties": {"a": {"$ref": "#/$defs/A"}}}
},
"oneOf": [
{
"type": "object",
"properties": {
"function": {"const": "test"},
"arguments": {"$ref": "#/$defs/A"}
}
}
]
}`
_, err := NewLLama31SchemaConverter("function").GrammarFromBytes([]byte(schema))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cyclic $ref"))
})
It("returns an error for a schema nested deeper than the depth limit", func() {
const layers = 400
schema := `{"type": "string"}`
for i := 0; i < layers; i++ {
schema = `{"type": "array", "items": ` + schema + `}`
}
_, err := NewLLama31SchemaConverter("").GrammarFromBytes([]byte(schema))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("maximum depth"))
})
})