fix: release the allocator when an updated value escapes to a query

The allocator of _modify tracks the values it created so that a later
update can modify them in place instead of copying them. When a target
path is a proper prefix of an earlier one, getpath hands such a value to
the update function, which may store it in more than one place; updating
through one of those places then changes all of them.

    $ gojq -cn '{"a":{"b":1}} | (.a.b, .a, .a.p.z) |= {p: ., q: .}'

.a.p and .a.q hold separate values in jq, so only .a.p gains the "z" key,
while gojq adds it to both. Release the allocator whenever a value it
created leaves the update, so that the values are copied on update from
then on. The same applies to an object used as a path element, which
stays reachable through the path after the update.
This commit is contained in:
dualfroz committed 2026-09-13 17:03:33 +02:00
1 parent f2996ce6e7
commit a7ed5ccbe9
3 files changed
+36 -5

No files matched your search

+8
View File
@@ -5502,6 +5502,14 @@
expected: | expected: |
[1,3,6,5,5] [1,3,6,5,5]
- name: update-assignment operator against object with overlapping paths
args:
- -c
- '(.a.b,.a,.a.p.z) |= {p: ., q: .}'
input: '{"a":{"b":1}}'
expected: |
{"a":{"p":{"b":{"p":1,"q":1},"z":{"p":null,"q":null}},"q":{"b":{"p":1,"q":1}}}}
- name: update-assignment operator with optional operator - name: update-assignment operator with optional operator
args: args:
- -c - -c
+6 -5
View File
@@ -1093,7 +1093,7 @@ func (c *compiler) compileModify() {
&code{op: opcall, v: [3]any{funcAllocator, 0, "_allocator"}}, &code{op: opcall, v: [3]any{funcAllocator, 0, "_allocator"}},
&code{op: opstore, v: a}, &code{op: opstore, v: a},
&code{op: opload, v: v}, &code{op: opload, v: v},
&code{op: opfork, v: len(c.codes) + 39}, // reduce [L1] &code{op: opfork, v: len(c.codes) + 40}, // reduce [L1]
&code{op: oppathbegin}, // path(p) &code{op: oppathbegin}, // path(p)
&code{op: opload, v: p}, &code{op: opload, v: p},
&code{op: opcallpc}, &code{op: opcallpc},
@@ -1102,12 +1102,13 @@ func (c *compiler) compileModify() {
&code{op: opstore, v: p}, // as $p (.; &code{op: opstore, v: p}, // as $p (.;
&code{op: opforklabel, v: l}, // label $l | &code{op: opforklabel, v: l}, // label $l |
&code{op: opload, v: v}, // &code{op: opload, v: v}, //
&code{op: opfork, v: len(c.codes) + 36}, // [L2] &code{op: opfork, v: len(c.codes) + 37}, // [L2]
&code{op: oppop}, // (getpath($p) | &code{op: oppop}, // (getpath($p) |
&code{op: opload, v: a}, &code{op: opload, v: a},
&code{op: opload, v: a},
&code{op: opload, v: p}, &code{op: opload, v: p},
&code{op: opload, v: v}, &code{op: opload, v: v},
&code{op: opcall, v: [3]any{internalFuncs["getpath"].callback, 1, "getpath"}}, &code{op: opcall, v: [3]any{funcGetpathWithAllocator, 2, "getpath"}},
&code{op: opload, v: f}, // f) &code{op: opload, v: f}, // f)
&code{op: opcallpc}, &code{op: opcallpc},
&code{op: opload, v: p}, // setpath($p; ...) &code{op: opload, v: p}, // setpath($p; ...)
@@ -1115,8 +1116,8 @@ func (c *compiler) compileModify() {
&code{op: opcall, v: [3]any{funcSetpathWithAllocator, 3, "_setpath"}}, &code{op: opcall, v: [3]any{funcSetpathWithAllocator, 3, "_setpath"}},
&code{op: opstore, v: v}, &code{op: opstore, v: v},
&code{op: opload, v: v}, // ., break $l &code{op: opload, v: v}, // ., break $l
&code{op: opfork, v: len(c.codes) + 34}, // [L4] &code{op: opfork, v: len(c.codes) + 35}, // [L4]
&code{op: opjump, v: len(c.codes) + 38}, // [L3] &code{op: opjump, v: len(c.codes) + 39}, // [L3]
&code{op: opload, v: l}, // [L4] &code{op: opload, v: l}, // [L4]
&code{op: opcall, v: [3]any{funcBreak(""), 0, "_break"}}, &code{op: opcall, v: [3]any{funcBreak(""), 0, "_break"}},
&code{op: opload, v: p}, // append $p to $d [L2] &code{op: opload, v: p}, // append $p to $d [L2]
+22
View File
@@ -1488,6 +1488,17 @@ func (a allocator) allocated(v any) bool {
return ok return ok
} }
// release forgets every allocated address when the value is one of them, so
// that a value handed to a query is copied on update again.
func (a allocator) release(v any) {
switch v.(type) {
case map[string]any, []any:
if a.allocated(v) {
clear(a)
}
}
}
func (a allocator) makeObject(l int) map[string]any { func (a allocator) makeObject(l int) map[string]any {
v := make(map[string]any, l) v := make(map[string]any, l)
if a != nil { if a != nil {
@@ -1514,6 +1525,15 @@ func funcSetpathWithAllocator(v any, args []any) any {
return setpath(v, args[0], args[1], args[2].(allocator)) return setpath(v, args[0], args[1], args[2].(allocator))
} }
// Used in compiler#compileModify: the update function may store the value
// anywhere, so it must not be modified in place afterwards. Registered as
// getpath to keep tracking paths through the call.
func funcGetpathWithAllocator(v any, args []any) any {
w := funcGetpath(v, args[0])
args[1].(allocator).release(w)
return w
}
func setpath(v, p, n any, a allocator) any { func setpath(v, p, n any, a allocator) any {
path, ok := p.([]any) path, ok := p.([]any)
if !ok { if !ok {
@@ -1591,6 +1611,8 @@ func update(v any, path []any, n any, a allocator) (any, error) {
return nil, &expectedArrayError{v} return nil, &expectedArrayError{v}
} }
case map[string]any: case map[string]any:
// A slice bound stays reachable through the path.
a.release(p)
switch v := v.(type) { switch v := v.(type) {
case nil: case nil:
return updateArraySlice(nil, p, path[1:], n, a) return updateArraySlice(nil, p, path[1:], n, a)