Skip to content

Commit 0e916ec

Browse files
fix: prevent infinite recursion when assertions access pending fields (#20)
When an assertion accesses a field that is currently being evaluated (marked as Pending in the cache), jrsonnet would fail with "infinite recursion detected". This differs from go-jsonnet which handles this case gracefully. The fix introduces two mechanisms: 1. Skip triggering new assertions when already inside assertion evaluation (using ASSERTION_DEPTH counter) 2. When inside assertion evaluation and hitting a Pending cache value, throw a special AssertionAccessedPendingField error that is caught and causes the assertion to be skipped (deferred to manifest time) This matches go-jsonnet behavior where assertions that cause circular dependencies are handled without error. Fixes the compactor-worker.libsonnet + arm.libsonnet infinite recursion bug reported in deployment_tools. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 637aa2a commit 0e916ec

File tree

4 files changed

+110
-1
lines changed

4 files changed

+110
-1
lines changed

crates/jrsonnet-evaluator/src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ pub enum ErrorKind {
221221
StackOverflow,
222222
#[error("infinite recursion detected")]
223223
InfiniteRecursionDetected,
224+
/// Internal error: assertion tried to access a field that is currently being evaluated.
225+
/// This is not shown to users; it's caught and handled by the assertion mechanism.
226+
#[error("assertion accessed pending field")]
227+
AssertionAccessedPendingField,
224228
#[error("tried to index by fractional value")]
225229
FractionalIndex,
226230
#[error("attempted to divide by zero")]

crates/jrsonnet-evaluator/src/obj.rs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ use rustc_hash::FxHashMap;
1616
thread_local! {
1717
static SKIP_ASSERTIONS: Cell<bool> = const { Cell::new(false) };
1818
static LENIENT_SUPER: Cell<bool> = const { Cell::new(false) };
19+
// Counter for how many assertions are currently being evaluated.
20+
// When inside assertion evaluation (counter > 0), we skip triggering new assertions
21+
// on field accesses to prevent infinite recursion when an assertion accesses a field
22+
// that's currently being evaluated.
23+
static ASSERTION_DEPTH: Cell<usize> = const { Cell::new(0) };
1924
}
2025

2126
/// Set whether to skip assertion checks (for manifest generation compatibility with Go Tanka)
@@ -28,6 +33,27 @@ fn should_skip_assertions() -> bool {
2833
SKIP_ASSERTIONS.with(std::cell::Cell::get)
2934
}
3035

36+
/// Check if we're currently inside assertion evaluation
37+
fn is_in_assertion() -> bool {
38+
ASSERTION_DEPTH.with(|d| d.get() > 0)
39+
}
40+
41+
/// RAII guard to increment/decrement ASSERTION_DEPTH during assertion evaluation
42+
struct AssertionGuard;
43+
44+
impl AssertionGuard {
45+
fn new() -> Self {
46+
ASSERTION_DEPTH.with(|d| d.set(d.get() + 1));
47+
Self
48+
}
49+
}
50+
51+
impl Drop for AssertionGuard {
52+
fn drop(&mut self) {
53+
ASSERTION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
54+
}
55+
}
56+
3157
/// Set whether to use lenient mode for super field access (return empty object instead of error)
3258
/// This works around go-jsonnet compatibility issues where mixins reference super fields that don't exist yet
3359
pub fn set_lenient_super(lenient: bool) {
@@ -452,6 +478,12 @@ impl ObjValue {
452478
if should_skip_assertions() {
453479
return Ok(());
454480
}
481+
// Skip assertions if we're already inside assertion evaluation.
482+
// This prevents infinite recursion when an assertion accesses a field
483+
// that depends on the object being evaluated.
484+
if is_in_assertion() {
485+
return Ok(());
486+
}
455487
// FIXME: Should it use `self.0.this()` in case of standalone super?
456488
self.run_assertions_raw(self.clone())
457489
}
@@ -731,7 +763,16 @@ impl ObjectLike for OopObject {
731763
return Ok(match v {
732764
CacheValue::Cached(v) => Some(v.clone()),
733765
CacheValue::NotFound => None,
734-
CacheValue::Pending => bail!(InfiniteRecursionDetected),
766+
CacheValue::Pending => {
767+
// During assertion evaluation, if we hit a pending field,
768+
// it means the assertion is trying to access a field that's
769+
// currently being evaluated. In this case, we bubble up a
770+
// special error that tells the assertion to defer.
771+
if is_in_assertion() {
772+
bail!(AssertionAccessedPendingField)
773+
}
774+
bail!(InfiniteRecursionDetected)
775+
}
735776
CacheValue::Errored(e) => return Err(e.clone()),
736777
});
737778
}
@@ -796,7 +837,21 @@ impl ObjectLike for OopObject {
796837
}
797838
if self.assertions_ran.borrow_mut().insert(real_this.clone()) {
798839
for assertion in self.assertions.iter() {
840+
// Use AssertionGuard to mark that we're inside assertion evaluation.
841+
// This prevents infinite recursion when the assertion accesses fields
842+
// that depend on the object being evaluated.
843+
let _guard = AssertionGuard::new();
799844
if let Err(e) = assertion.run(self.sup.clone(), Some(real_this.clone())) {
845+
// If the assertion tried to access a field that's currently being
846+
// evaluated (pending), we skip this assertion. It will be checked
847+
// again during manifest when the field is fully evaluated.
848+
// This matches go-jsonnet behavior for circular dependencies.
849+
if matches!(
850+
e.error(),
851+
crate::error::ErrorKind::AssertionAccessedPendingField
852+
) {
853+
continue;
854+
}
800855
self.assertions_ran.borrow_mut().remove(&real_this);
801856
return Err(e);
802857
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Minimal reproduction of infinite recursion bug with super + assertions
2+
// This pattern is from loki's compactor-worker.libsonnet + arm.libsonnet
3+
//
4+
// The issue: when using super in field definition AND having an assertion
5+
// that accesses that same field via $, jrsonnet detects infinite recursion
6+
// but go-jsonnet handles it correctly.
7+
8+
local base = {
9+
deployment: { spec: { replicas: 1 } },
10+
};
11+
12+
local autoscaling = {
13+
_config+:: {
14+
autoscaling: {
15+
enabled: false,
16+
// This assertion accesses $.deployment, which triggers the recursion
17+
assert (
18+
if self.enabled
19+
then std.get($.deployment.spec, 'replicas') == null
20+
else $.deployment.spec.replicas == 1
21+
) : 'replicas check failed',
22+
},
23+
},
24+
25+
// This field uses super AND the condition depends on _config which has the assertion
26+
deployment: std.mergePatch(
27+
super.deployment,
28+
if $._config.autoscaling.enabled then { spec+: { replicas: null } } else {},
29+
),
30+
};
31+
32+
local arm = {
33+
// This function mimics overrideSuperIfExists from arm.libsonnet
34+
local overrideSuperIfExists(name, override) =
35+
if !( name in super) || super[name] == null || super[name] == {}
36+
then null
37+
else super[name] + override,
38+
39+
deployment: overrideSuperIfExists('deployment', {}),
40+
};
41+
42+
// The combination triggers the bug
43+
base + autoscaling + arm
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"deployment": {
3+
"spec": {
4+
"replicas": 1
5+
}
6+
}
7+
}

0 commit comments

Comments
 (0)