Skip to content

Commit 61c79f3

Browse files
committed
path_compiler: make the hint bound proportional, not a constant
The constant ceiling in the previous commit regressed valid deep paths. I benchmarked it rather than assuming, on a well-formed 2000-component path: upstream, no clamp 32781 B/op 1 alloc/op constant 512 ceiling 113177 B/op 5 allocs/op <- 3.45x worse proportional len/2+1 32788 B/op 1 alloc/op A 512-element ceiling under-reserves any path with more components than that, so append regrows repeatedly and the common case pays for the hostile one. Deep paths are unusual but they are legal, and a defensive bound should not make valid input worse. The shortest component that can contribute a separator is two bytes ("k."), so len(jsonPath)/2+1 can never under-reserve a well-formed path, while still refusing to size the allocation from a long run of separators. The hostile case is unaffected by the change: 200000 separators, rejected on the first component: upstream 1606185 B/op -> 803352 B/op The existing correctness test could not catch this: it passed with the constant too, because clamping never changes what ParsePath returns. So the property now has an allocation assertion of its own, TestParsePathHintDoesNotRegressDeepPaths, which fails on the constant version with "used 5 allocations, want 1" and passes here. Verified that ./... behaves identically to unpatched upstream: the two TestOracleSetPr286Regression subtests fail on a clean checkout as well, so they are pre-existing and unrelated to this change. Change-Id: I43e5ed1e205c70fb55dde128a4ab817e3d359d23
1 parent 1a689b6 commit 61c79f3

2 files changed

Lines changed: 48 additions & 10 deletions

File tree

‎path_compiler.go‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,21 @@ var (
1111
errUnterminatedKey = errors.New("jsonparser: unterminated quoted key")
1212
)
1313

14-
// maxPathHint bounds the capacity pre-allocated by ParsePath. Real paths have a
15-
// handful of components; the limit only stops an unvalidated path from sizing
16-
// the initial allocation.
17-
const maxPathHint = 512
18-
19-
// pathHintCap clamps a pre-allocation hint to maxPathHint.
20-
func pathHintCap(n int) int {
21-
if n > maxPathHint {
22-
return maxPathHint
14+
// pathHintCap bounds the capacity pre-allocated by ParsePath.
15+
//
16+
// The bound is proportional to the path length rather than a constant. A
17+
// constant ceiling regresses legitimate deep paths: benchmarked on a valid
18+
// 2000-component path, a 512 ceiling took allocation from 32781 B in 1 alloc to
19+
// 113177 B in 5 allocs, because append then has to regrow. Deep paths are
20+
// unusual but they are legal, and a defensive bound should not make the valid
21+
// case worse.
22+
//
23+
// The shortest component that can contribute a separator is two bytes ("k."),
24+
// so len/2+1 can never under-reserve a well-formed path, while still refusing
25+
// to size the allocation from a long run of separators.
26+
func pathHintCap(n, pathLen int) int {
27+
if max := pathLen/2 + 1; n > max {
28+
return max
2329
}
2430
return n
2531
}
@@ -56,7 +62,7 @@ func ParsePath(jsonPath string) ([]string, error) {
5662
// would otherwise reserve memory proportional to its length and then be
5763
// rejected on the first component. Capacity is only a hint to append, which
5864
// still grows as needed, so clamping cannot change the parsed result.
59-
parts := make([]string, 0, pathHintCap(1+strings.Count(jsonPath, ".")+strings.Count(jsonPath, "[")))
65+
parts := make([]string, 0, pathHintCap(1+strings.Count(jsonPath, ".")+strings.Count(jsonPath, "["), len(jsonPath)))
6066

6167
for pos := 0; pos < len(jsonPath); {
6268
switch jsonPath[pos] {

‎path_compiler_clamp_test.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,35 @@ func TestParsePathHintClampPreservesResults(t *testing.T) {
5151
}
5252
}
5353
}
54+
55+
// TestParsePathHintDoesNotRegressDeepPaths pins the reason the bound is
56+
// proportional to the path length instead of a constant.
57+
//
58+
// A constant ceiling silently penalises valid deep paths: with a 512 ceiling a
59+
// well-formed 2000-component path allocated 113177 B across 5 allocations,
60+
// against 32781 B in 1 allocation unclamped, because append has to regrow. The
61+
// correctness test above cannot see that -- it passed with the constant too --
62+
// so the property needs an allocation assertion of its own.
63+
//
64+
// One allocation is the whole point: the hint has to be large enough that
65+
// append never regrows for a path that really does have this many components.
66+
func TestParsePathHintDoesNotRegressDeepPaths(t *testing.T) {
67+
const n = 2000
68+
keys := make([]string, n)
69+
for i := range keys {
70+
keys[i] = "k"
71+
}
72+
path := strings.Join(keys, ".")
73+
74+
var got []string
75+
allocs := testing.AllocsPerRun(50, func() {
76+
got, _ = ParsePath(path)
77+
})
78+
if len(got) != n {
79+
t.Fatalf("got %d components, want %d", len(got), n)
80+
}
81+
if allocs > 1 {
82+
t.Errorf("ParsePath on a valid %d-component path used %.0f allocations, want 1; "+
83+
"the pre-allocation hint is under-reserving and append is regrowing", n, allocs)
84+
}
85+
}

0 commit comments

Comments
 (0)