Skip to content

Commit 262a7d1

Browse files
committed
feat(compiler): a release through a borrow is reported
A borrowing function's caller keeps its claim: it may read the storage afterwards, and something else is still going to release it. A function that releases such a parameter breaks both, and the symptom is a read of freed memory or a second release. On a tree the second release walks memory that has been handed back and recurses on whatever is in it now, which arrives as a stack overflow nowhere near the cause. The check reports a parameter that is borrowed and released, whether by the free intrinsic, by a runtime symbol that allocates, or by being handed to a callee that takes it owned. It reuses the drop pass's own walk over the values a pointer reaches, so releasing a cast or a field pointer of the parameter counts the same, rather than growing a second copy that would miss a different set of instructions. Errors reached the developer through the struct's `Debug`, which names fields and value ids and says nothing about what is wrong or what would be right. They are sentences now. `own self` also parses. The word required a type annotation and `self` is spelled on its own, so the only way to say a method releases its receiver was to write the receiver's type after it. `mut` gets the same bare form. Both come after the annotated rules in the ordered choice, or `own p: T` would match the name and leave the type behind.
1 parent da4bce3 commit 262a7d1

4 files changed

Lines changed: 305 additions & 5 deletions

File tree

‎crates/compiler/src/borrow_check.rs‎

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use crate::CompilerError;
1919
use crate::CompilerResult;
2020
use std::collections::{HashMap, HashSet};
2121
use zyntax_typed_ast::source::Span;
22+
use zyntax_typed_ast::InternedString;
2223

2324
/// Result of borrow checking
2425
#[derive(Debug)]
@@ -70,6 +71,63 @@ pub enum BorrowError {
7071
borrow: HirId,
7172
location: Option<Span>,
7273
},
74+
/// A parameter the function only borrows is released by it.
75+
///
76+
/// Releasing storage ends the claim on it, and a borrowed parameter
77+
/// leaves that claim with the caller. So a function that releases
78+
/// one has released something it does not own: the caller still
79+
/// believes it holds the storage and may read it, and whatever else
80+
/// is responsible for releasing it will do so a second time.
81+
ReleaseThroughBorrow {
82+
function: InternedString,
83+
parameter: InternedString,
84+
location: Option<Span>,
85+
},
86+
}
87+
88+
/// What a borrow error says to the person who wrote the program.
89+
///
90+
/// Rendered rather than dumped: these reached the developer as the
91+
/// struct's `Debug`, which names fields and value ids and says nothing
92+
/// about what is wrong or what would be right.
93+
impl std::fmt::Display for BorrowError {
94+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95+
match self {
96+
BorrowError::UseAfterMove { .. } => {
97+
write!(f, "a value is used after it was handed to something else")
98+
}
99+
BorrowError::MutableBorrowConflict { .. } => write!(
100+
f,
101+
"a value is borrowed for writing while it is already borrowed"
102+
),
103+
BorrowError::ImmutableBorrowConflict { .. } => write!(
104+
f,
105+
"a value is borrowed for reading while it is borrowed for writing"
106+
),
107+
BorrowError::ReferenceOutlivesReferent { .. } => {
108+
write!(f, "a reference outlives what it refers to")
109+
}
110+
BorrowError::MutationThroughImmutableRef { .. } => {
111+
write!(f, "a value is written through a reference that only reads")
112+
}
113+
BorrowError::MovedWhileBorrowed { .. } => {
114+
write!(f, "a value is handed away while something still borrows it")
115+
}
116+
BorrowError::ReleaseThroughBorrow {
117+
function,
118+
parameter,
119+
..
120+
} => write!(
121+
f,
122+
"`{}` releases `{}`, which it only borrows. The caller still \
123+
holds that storage and may read it, and whatever else is \
124+
responsible for it will release it a second time. A \
125+
parameter that a function releases has to be declared owned.",
126+
function.resolve_global().unwrap_or_default(),
127+
parameter.resolve_global().unwrap_or_default()
128+
),
129+
}
130+
}
73131
}
74132

75133
/// Borrow checking warning
@@ -183,10 +241,95 @@ impl<'a> HirBorrowChecker<'a> {
183241
}
184242
}
185243

244+
self.check_releases_of_borrowed_params(func);
245+
186246
self.function_contexts.insert(func_id, context);
187247
Ok(())
188248
}
189249

250+
/// A parameter the function only borrows must not be released by it.
251+
///
252+
/// The caller of a borrowing function keeps its claim, so it may
253+
/// read the storage afterwards and something else is still going to
254+
/// release it. A function that releases a borrowed parameter breaks
255+
/// both of those, and the symptom is a read of freed memory or a
256+
/// second release, neither of which says where it came from.
257+
///
258+
/// A parameter declared owned is exactly how a function says it
259+
/// takes that responsibility, so this reports only the ones that
260+
/// have not.
261+
fn check_releases_of_borrowed_params(&mut self, func: &HirFunction) {
262+
use crate::drop_insert::{derived_values, symbol_role, SymbolRole};
263+
use crate::hir::{HirCallable, Intrinsic, ParamOwnership};
264+
265+
for (index, param) in func.signature.params.iter().enumerate() {
266+
if !matches!(
267+
param.ownership,
268+
ParamOwnership::Borrowed | ParamOwnership::BorrowedMut
269+
) {
270+
continue;
271+
}
272+
// The value the body refers to, which is not `param.id`:
273+
// building the SSA form mints a fresh value for each
274+
// parameter and the signature keeps its own. Rooting the
275+
// walk at the signature's id finds nothing, and a check that
276+
// finds nothing reads exactly like a check that passed.
277+
let Some(root) = func.values.values().find_map(|v| {
278+
matches!(v.kind, HirValueKind::Parameter(i) if i as usize == index).then_some(v.id)
279+
}) else {
280+
continue;
281+
};
282+
// Every name the parameter reaches, so releasing a cast or a
283+
// field pointer of it counts the same as releasing it.
284+
let reached = derived_values(func, root);
285+
for block in func.blocks.values() {
286+
for inst in &block.instructions {
287+
let HirInstruction::Call { callee, args, .. } = inst else {
288+
continue;
289+
};
290+
if !args.iter().any(|a| reached.contains(a)) {
291+
continue;
292+
}
293+
let releases = match callee {
294+
HirCallable::Intrinsic(Intrinsic::Free) => true,
295+
HirCallable::Symbol(name) => {
296+
matches!(symbol_role(name), Some(SymbolRole::Allocates(_)))
297+
}
298+
// A callee that takes the argument owned is
299+
// being handed the claim, which a borrower has
300+
// not got to give.
301+
HirCallable::Function(id) => self
302+
.module
303+
.functions
304+
.get(id)
305+
.map(|callee| {
306+
callee
307+
.signature
308+
.params
309+
.iter()
310+
.zip(args.iter())
311+
.any(|(p, a)| {
312+
reached.contains(a) && p.ownership == ParamOwnership::Owned
313+
})
314+
})
315+
.unwrap_or(false),
316+
_ => false,
317+
};
318+
if releases {
319+
self.errors.push(BorrowError::ReleaseThroughBorrow {
320+
function: func.name,
321+
parameter: param.name,
322+
location: None,
323+
});
324+
// One report per parameter. A release inside a
325+
// loop is the same mistake said many times.
326+
break;
327+
}
328+
}
329+
}
330+
}
331+
}
332+
190333
/// Get blocks in execution order (basic topological sort)
191334
fn get_block_order(&self, func: &HirFunction) -> Vec<HirId> {
192335
let mut order = Vec::new();
@@ -574,7 +717,7 @@ pub fn validate_borrow_check(result: &BorrowCheckResult) -> CompilerResult<()> {
574717
if result.errors.is_empty() {
575718
Ok(())
576719
} else {
577-
let error_msgs: Vec<String> = result.errors.iter().map(|e| format!("{:?}", e)).collect();
720+
let error_msgs: Vec<String> = result.errors.iter().map(|e| e.to_string()).collect();
578721
Err(CompilerError::Analysis(format!(
579722
"Borrow check failed with {} errors:\n{}",
580723
result.errors.len(),

‎crates/compiler/src/drop_insert.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ fn run_function(func: &mut HirFunction, facts: &ModuleFacts) -> DropStats {
487487
/// that boxes in a loop allocates once per iteration and releases
488488
/// nothing.
489489
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490-
enum SymbolRole {
490+
pub(crate) enum SymbolRole {
491491
/// Returns storage the caller owns, released by the named symbol.
492492
Allocates(&'static str),
493493
/// Reads through a pointer argument without keeping it, so passing
@@ -497,7 +497,7 @@ enum SymbolRole {
497497

498498
/// The role of a runtime symbol, or `None` where the pass knows nothing
499499
/// about it and must assume the worst.
500-
fn symbol_role(name: &str) -> Option<SymbolRole> {
500+
pub(crate) fn symbol_role(name: &str) -> Option<SymbolRole> {
501501
match name {
502502
"zyntax_box_bool" | "zyntax_box_f32" | "zyntax_box_f64" | "zyntax_box_i32"
503503
| "zyntax_box_i64" | "zyntax_box_opaque" => Some(SymbolRole::Allocates("zyntax_box_free")),
@@ -895,7 +895,7 @@ fn analyze_site(func: &HirFunction, site: &MallocSite, facts: &ModuleFacts) -> S
895895
/// Only the shapes that carry the same pointer are followed: putting it
896896
/// into an aggregate, taking it back out, and casting it. Following more
897897
/// would widen the live range without making anything reclaimable.
898-
fn derived_values(func: &HirFunction, root: HirId) -> std::collections::HashSet<HirId> {
898+
pub(crate) fn derived_values(func: &HirFunction, root: HirId) -> std::collections::HashSet<HirId> {
899899
let mut set = std::collections::HashSet::new();
900900
set.insert(root);
901901
// Blocks are unordered here, so a single sweep can miss a chain

‎crates/zynml/ml.zyn‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2139,7 +2139,7 @@ fn_param_comma = { "," ~ param:fn_param }
21392139
// Stating neither leaves the type to decide: a pointer is borrowed, and
21402140
// anything held by value is copied.
21412141
fn_param = {
2142-
param_owned | param_mut |
2142+
param_owned | param_owned_bare | param_mut | param_mut_bare |
21432143
param_typed_default | param_default | param_typed | param_simple
21442144
}
21452145

@@ -2157,6 +2157,24 @@ param_mut = { "mut" ~ name:identifier ~ ":" ~ ty:type_expr }
21572157
ownership: ParamOwnership::BorrowedMut,
21582158
}
21592159

2160+
// The same two, for a parameter whose type is not written. A method's
2161+
// receiver is the one that matters: `self` is spelled on its own and
2162+
// takes its type from the impl, so requiring an annotation to say `own`
2163+
// meant `own self` did not parse and the only spelling was
2164+
// `own self: TheType`. Both come after the annotated forms, since an
2165+
// ordered choice would otherwise take the name and leave the `:` behind.
2166+
param_owned_bare = { "own" ~ name:identifier }
2167+
-> TypedParameter {
2168+
name: intern(name),
2169+
ownership: ParamOwnership::Owned,
2170+
}
2171+
2172+
param_mut_bare = { "mut" ~ name:identifier }
2173+
-> TypedParameter {
2174+
name: intern(name),
2175+
ownership: ParamOwnership::BorrowedMut,
2176+
}
2177+
21602178

21612179
// x: Type = default_value
21622180
param_typed_default = { name:identifier ~ ":" ~ ty:type_expr ~ "=" ~ default:expr }
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
//! Releasing a parameter the function only borrows is an error.
2+
//!
3+
//! A borrowing function's caller keeps its claim: it may read the
4+
//! storage afterwards, and something else is still going to release it.
5+
//! A function that releases such a parameter breaks both, and the
6+
//! symptom is a read of freed memory or a second release. Neither says
7+
//! where it came from, and on a tree the second release walks memory
8+
//! that has been handed back and recurses on whatever is now in it,
9+
//! which arrives as a stack overflow nowhere near the cause.
10+
//!
11+
//! Declaring the parameter owned is how a function says it takes that
12+
//! responsibility. This is what makes the difference between the two a
13+
//! message rather than a crash.
14+
15+
use std::path::Path;
16+
use zynml::{ZynML, ZynMLConfig};
17+
18+
fn load(src: &str) -> Result<(), String> {
19+
let plugins = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/target/zrtl");
20+
let cfg = ZynMLConfig {
21+
plugins_dir: plugins.to_string_lossy().to_string(),
22+
// The profile that enforces ownership. The tiered profiles do
23+
// not, so asking them would prove nothing either way.
24+
runtime_profile: zynml::ZynMLRuntimeProfile::Classic,
25+
..ZynMLConfig::default()
26+
};
27+
let mut z = ZynML::with_config(cfg).map_err(|e| format!("{e:?}"))?;
28+
z.load_source(src).map_err(|e| format!("{e:?}"))?;
29+
Ok(())
30+
}
31+
32+
const NODE: &str = r#"
33+
import prelude
34+
35+
@reference
36+
struct Node { left: Node, right: Node, item: i64 }
37+
"#;
38+
39+
/// A method that frees its receiver without saying it consumes it.
40+
#[test]
41+
fn a_method_that_frees_a_borrowed_receiver_is_reported() {
42+
let err = load(&format!(
43+
"{NODE}
44+
impl Node {{
45+
def make(item: i64): Node {{ return Node {{ left: null, right: null, item: item }} }}
46+
def dispose(self) {{ free(self as Ptr<i8>) }}
47+
}}
48+
49+
def main(): i64 {{
50+
let n: Node = Node::make(7)
51+
n.dispose()
52+
return 0
53+
}}"
54+
))
55+
.expect_err("freeing a borrowed receiver should be refused");
56+
57+
assert!(
58+
err.contains("dispose"),
59+
"the report should name the function it is in, got: {err}"
60+
);
61+
assert!(
62+
err.contains("only borrows") || err.contains("declared owned"),
63+
"the report should say what is wrong and what would be right, got: {err}"
64+
);
65+
}
66+
67+
/// The same method, declared to consume its receiver, is accepted.
68+
///
69+
/// This is the half that makes the test above about `own` rather than
70+
/// about freeing.
71+
#[test]
72+
fn declaring_the_receiver_owned_is_accepted() {
73+
load(&format!(
74+
"{NODE}
75+
impl Node {{
76+
def make(item: i64): Node {{ return Node {{ left: null, right: null, item: item }} }}
77+
def dispose(own self: Node) {{ free(self as Ptr<i8>) }}
78+
}}
79+
80+
def main(): i64 {{
81+
let n: Node = Node::make(7)
82+
n.dispose()
83+
return 0
84+
}}"
85+
))
86+
.expect("a receiver declared owned may be released");
87+
}
88+
89+
/// A free function releasing a borrowed pointer is the same mistake.
90+
#[test]
91+
fn a_free_function_that_frees_a_borrowed_pointer_is_reported() {
92+
let err = load(
93+
r#"
94+
import prelude
95+
import simd
96+
97+
def dispose(p: Ptr<i8>) { free(p) }
98+
99+
def main(): i64 {
100+
let p: Ptr<i8> = alloc_i8(16)
101+
dispose(p)
102+
return 0
103+
}
104+
"#,
105+
)
106+
.expect_err("freeing a borrowed pointer should be refused");
107+
assert!(err.contains("dispose"), "should name the function: {err}");
108+
}
109+
110+
/// And a function that merely reads through a borrowed pointer is not
111+
/// reported, so the rule is about releasing rather than about pointers.
112+
#[test]
113+
fn reading_through_a_borrow_is_not_a_release() {
114+
load(
115+
r#"
116+
import prelude
117+
import simd
118+
119+
def total(p: Ptr<i8>, n: i64): i64 {
120+
let q: Ptr<i64> = p as Ptr<i64>
121+
let mut sum: i64 = 0
122+
let mut i: i64 = 0
123+
while i < n {
124+
sum = sum + q[i]
125+
i = i + 1
126+
}
127+
return sum
128+
}
129+
130+
def main(): i64 {
131+
let p: Ptr<i8> = alloc_i8(64)
132+
let s: i64 = total(p, 4)
133+
free(p)
134+
return s
135+
}
136+
"#,
137+
)
138+
.expect("reading through a borrow is not releasing it");
139+
}

0 commit comments

Comments
 (0)