Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions crates/luad-analysis/src/origins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ const MAX_TRANSFER_STEPS: usize = 1_000_000;
const MAX_TABLE_SCAN_STEPS: usize = 64;
const MAX_TABLE_FIELDS: usize = 64;
const MAX_ALTERNATIVES: usize = 8;
/// How many times a block's outgoing state may change before its still-moving slots are
/// widened to `Unknown`.
///
/// The origin lattice has unbounded height: a loop-carried `i = i + 1` merges to
/// `Alternatives[0, ADD(0, 1)]`, whose transfer yields `ADD(Alternatives[..], 1)`, which
/// the next merge adds as a further option. Each pass is strictly larger than the last, so
/// the worklist never reaches a fixpoint and terminates only by exhausting its step budget,
/// which reports every call in the prototype as `analysis-limit`. Widening bounds the
/// iteration instead, so a prototype containing a loop still resolves the expressions that
/// do converge.
const MAX_BLOCK_REVISITS: usize = 8;

/// A lossless literal suitable for structural equality in an origin graph.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
Expand Down Expand Up @@ -289,6 +300,7 @@ fn run_dataflow(
let mut queued: BTreeSet<usize> = worklist.iter().copied().collect();
let mut steps = 0usize;
let mut exhausted = false;
let mut revisits = vec![0usize; cfg.blocks.len()];
while let Some(block_index) = worklist.pop_front() {
queued.remove(&block_index);
let block = &cfg.blocks[block_index];
Expand Down Expand Up @@ -316,6 +328,31 @@ fn run_dataflow(
break;
}
if state != out_states[block_index] {
revisits[block_index] = revisits[block_index].saturating_add(1);
if revisits[block_index] > MAX_BLOCK_REVISITS {
// Widen: every slot still moving at this point is loop-carried and is not
// going to settle, so raise it to the top of the lattice. Slots that have
// already converged keep their exact expression.
for (slot, previous) in state.iter_mut().zip(out_states[block_index].iter()) {
if slot != previous {
let evidence = match (&slot, previous) {
(FlowValue::Origin(next), FlowValue::Origin(prior)) => next
.evidence
.iter()
.chain(prior.evidence.iter())
.cloned()
.collect::<Vec<_>>(),
(FlowValue::Origin(next), _) => next.evidence.clone(),
(_, FlowValue::Origin(prior)) => prior.evidence.clone(),
_ => Vec::new(),
};
*slot = FlowValue::Origin(unknown(
OriginUnknownReason::ControlFlowConflict,
evidence,
));
}
}
}
out_states[block_index] = state;
for edge in &block.successors {
if cfg.blocks[edge.to_block].is_reachable && queued.insert(edge.to_block) {
Expand Down
71 changes: 71 additions & 0 deletions crates/luad-oracle/tests/test_argument_origins_lua51.rs
Original file line number Diff line number Diff line change
Expand Up @@ -990,3 +990,74 @@ sink(function() end)
assert_eq!(val["prototype"], "0/1");
assert_eq!(val["evidence"], serde_json::json!(["proto:0:pc:2"]));
}

fn loop_widening_chunk() -> luad_core::Chunk {
let _ = luad_oracle::require_luac51();
let root = luad_oracle::find_workspace_root();
let source = std::fs::read_to_string(root.join("tests/fixtures/origins_loop_widening.lua"))
.expect("read origins_loop_widening.lua");
luad_oracle::compile_and_parse_lua51(&source, false).expect("compile loop widening fixture")
}

#[test]
fn test_loop_carried_values_widen_rather_than_growing_without_bound() {
let analysis = analyze_chunk_origins(&loop_widening_chunk());
let origins = fixed_origins(&analysis);
assert!(!origins.is_empty(), "fixture must produce fixed arguments");

// The loop-carried accumulator cannot be bounded, so it must be widened to the top of
// the lattice at the loop head. Reaching `ExpressionDepthLimit` instead means the
// merge kept nesting `ADD(Alternatives[..], 1)` one level per iteration until the
// expression bounds truncated it, which is the unbounded-height behaviour widening
// exists to prevent; on a prototype with enough blocks that same growth exhausts the
// worklist step budget and reports every call as `analysis-limit`.
let widened = origins.iter().any(|origin| {
matches!(
&origin.kind,
OriginExpressionKind::Unknown {
reason: OriginUnknownReason::ControlFlowConflict
}
)
});
let ground_out = origins.iter().any(|origin| {
matches!(
&origin.kind,
OriginExpressionKind::Unknown {
reason: OriginUnknownReason::ExpressionDepthLimit
}
)
});
assert!(
widened && !ground_out,
"loop-carried value must widen to control-flow-conflict, not grow to a depth limit"
);

// No argument may be reported as `analysis-limit`.
for origin in &origins {
assert!(
!matches!(
&origin.kind,
OriginExpressionKind::Unknown {
reason: OriginUnknownReason::AnalysisLimit
}
),
"an argument reported analysis-limit; the worklist ran out of steps"
);
}

// An argument that is not loop-carried keeps its exact expression.
let concat = origins
.iter()
.find(|origin| matches!(&origin.kind, OriginExpressionKind::Concat { .. }))
.expect("the post-loop sink argument must resolve to a concat");
let OriginExpressionKind::Concat { parts } = &concat.kind else {
unreachable!("filtered above")
};
assert_eq!(parts.len(), 3, "expected two literals and one field read");
assert!(
matches!(&parts[2].kind, OriginExpressionKind::Field { base, .. }
if matches!(&base.kind, OriginExpressionKind::Parameter { .. })),
"the third part must be a field read off the parameter, got {:?}",
parts[2].kind
);
}
27 changes: 27 additions & 0 deletions tests/fixtures/origins_loop_widening.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Loop-carried values make the origin lattice grow without bound.
--
-- At the loop head the merge of `index` yields Alternatives[0, ADD(0, 1)]. The transfer
-- of `index = index + 1` then yields ADD(Alternatives[..], 1), which the next merge adds
-- as a further option, one nesting level per iteration. Nothing in the expression bounds
-- collapses that, so without widening the worklist never reaches a fixpoint and stops
-- only by exhausting its step budget, which reports every call in the prototype as
-- `analysis-limit` including the ones that converged immediately.
--
-- The argument to the final sink is deliberately ordinary: two literals and a field read
-- off a parameter, none of it loop-carried. It must resolve.

local function sink(...) end

local function loop_counter(params)
local index = 0
local total = 0
while index < 10 do
index = index + 1
total = total + index
end
sink(total)
sink("mkdir -p " .. "/tmp/widen/" .. params.opcode)
return total
end

loop_counter({ opcode = "x" })
Loading