Skip to content

Commit 9f72e2b

Browse files
author
TheDevConnor
committed
feat(typecheck): Add comprehensive static memory ownership tracking
Implements a static memory analyzer that tracks allocations, deallocations, and ownership transfer at compile time to prevent memory safety issues. Key features: - Memory leak detection for unfreed allocations - Double-free prevention - Use-after-free detection within function scope - Ownership transfer tracking via #returns_ownership and #takes_ownership - Defer statement integration for automatic cleanup validation - Transitive ownership warnings for pass-through functions Tracking strategy: - Allocations in #returns_ownership functions are NOT tracked internally (caller inherits responsibility for returned resources) - #takes_ownership parameters are NOT tracked as new allocations (they receive pre-existing resources, not create new ones) - Defer blocks add variables to deferred_frees list, processed at scope exit - Assignment expressions (e.g., a.buf = alloc()) now tracked, not just variable initializers Known limitations: - Struct field allocations tracked at struct level, not per-field - Conditional allocation paths may produce false positives - No stack-vs-heap analysis (returning &local not detected) - Array-of-pointers patterns not fully tracked Fixes: - Remove incorrect allocation tracking for #takes_ownership parameters - Add allocation tracking to assignment expressions - Properly handle defer blocks in ownership transfer logic - Add transitive ownership validation in return statements Documentation updated with how the analyzer works, its capabilities, current limitations, and best practices for users.
1 parent a9f33f7 commit 9f72e2b

7 files changed

Lines changed: 389 additions & 343 deletions

File tree

docs/docs.md

Lines changed: 122 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,10 @@ const max = fn<T>(a: T, b: T) T { // Generic function
313313

314314
```luma
315315
const x: int = 5;
316-
x = 10; // Error: `x` is immutable
316+
x = 10; // Error: `x` is immutable
317317
318318
const add -> fn (a: int, b: int) int { return a + b; };
319-
add = something_else; // Error: cannot reassign function binding
319+
add = something_else; // Error: cannot reassign function binding
320320
```
321321

322322
---
@@ -328,14 +328,14 @@ Inside functions, use `let` to declare local variables:
328328
```luma
329329
const main -> fn () int {
330330
let x: int = 10; // Mutable local variable
331-
x = 20; // Can be reassigned
331+
x = 20; // Can be reassigned
332332
333333
let y: int = 5;
334-
y = y + 1; // Can be modified
334+
y = y + 1; // Can be modified
335335
336336
let counter: int = 0;
337337
loop (counter < 10) {
338-
counter = counter + 1; // Mutating in loop
338+
counter = counter + 1; // Mutating in loop
339339
}
340340
341341
return 0;
@@ -1139,169 +1139,154 @@ const main -> fn () int {
11391139
*data = 42;
11401140
11411141
consume_buffer(data); // Ownership transferred
1142-
// Error: data was freed inside consume_buffer
1142+
// Error: data was freed inside consume_buffer
11431143
11441144
return 0;
11451145
}
11461146
```
11471147

11481148
### Static Memory Analysis
11491149

1150-
Luma's compiler includes a static analyzer that tracks memory at compile time:
1150+
Luma's compiler includes a static analyzer that tracks memory at compile time to prevent common memory management errors.
11511151

1152-
- **Allocation/Deallocation Pairs**: Ensures every `alloc()` has a corresponding `free()`
1153-
- **Double-Free Detection**: Prevents freeing the same pointer twice
1154-
- **Memory Leaks**: Identifies allocated memory that's never freed
1155-
- **Use-After-Free**: Detects access to freed memory
1156-
- **Ownership Transfer**: Monitors ownership through attributes
1152+
#### What the Analyzer Tracks
11571153

1158-
**Example:**
1154+
**Verified at Compile Time:**
1155+
- **Memory Leaks**: Detects `alloc()` calls without corresponding `free()`
1156+
- **Double-Free**: Prevents freeing the same pointer twice
1157+
- **Use-After-Free**: Catches access to freed memory within the same function
1158+
- **Ownership Transfer**: Validates `#returns_ownership` and `#takes_ownership` annotations
1159+
- **Defer Statement Cleanup**: Ensures deferred frees execute properly
1160+
1161+
**How It Works:**
11591162

11601163
```luma
11611164
const good_memory_usage -> fn () void {
11621165
let ptr: *int = cast<*int>(alloc(sizeof<int>));
1163-
defer free(ptr); // Analyzer confirms cleanup
1166+
defer free(ptr); // Analyzer confirms cleanup
11641167
*ptr = 42;
1165-
}
1168+
} // No leak reported
11661169
11671170
const bad_memory_usage -> fn () void {
11681171
let ptr: *int = cast<*int>(alloc(sizeof<int>));
11691172
*ptr = 42;
1170-
// Compiler error: memory leak - ptr never freed
1173+
// Compiler error: memory leak - ptr never freed
11711174
}
1172-
```
1173-
1174-
---
1175-
1176-
## Error Handling
1177-
1178-
Luma doesn't have exceptions. Use these patterns:
11791175
1180-
### Pattern 1: Error Return Codes
1181-
1182-
```luma
1183-
const SUCCESS: int = 0;
1184-
const ERROR_NULL_PTR: int = -1;
1185-
const ERROR_OUT_OF_BOUNDS: int = -2;
1186-
1187-
const safe_divide -> fn (a: int, b: int, result: *int) int {
1188-
if (b == 0) {
1189-
return ERROR_OUT_OF_BOUNDS;
1190-
}
1191-
1192-
if (result == cast<*int>(0)) {
1193-
return ERROR_NULL_PTR;
1194-
}
1195-
1196-
*result = a / b;
1197-
return SUCCESS;
1198-
}
1199-
1200-
const main -> fn () int {
1201-
let quotient: int;
1202-
let status: int = safe_divide(10, 2, &quotient);
1203-
1204-
if (status != SUCCESS) {
1205-
outputln("Error: ", status);
1206-
return 1;
1207-
}
1208-
1209-
outputln("Result: ", quotient);
1210-
return 0;
1211-
}
1212-
```
1213-
1214-
### Pattern 2: Nullable Pointers
1215-
1216-
```luma
1217-
const find_element -> fn (arr: *int, size: int, value: int) *int {
1218-
loop [i: int = 0](i < size) : (++i) {
1219-
if (arr[i] == value) {
1220-
let addr: int = cast<int>(arr) + (i * sizeof<int>);
1221-
return cast<*int>(addr);
1222-
}
1223-
}
1224-
return cast<*int>(0); // Not found - return null
1225-
}
1176+
#returns_ownership
1177+
const create_buffer -> fn (size: int) *int {
1178+
let buffer: *int = cast<*int>(alloc(size));
1179+
return buffer; // Ownership transferred to caller
1180+
} // No leak reported - caller is responsible
12261181
12271182
const main -> fn () int {
1228-
let numbers: [int; 5] = [10, 20, 30, 40, 50];
1229-
let found: *int = find_element(cast<*int>(&numbers), 5, 30);
1230-
1231-
if (found == cast<*int>(0)) {
1232-
outputln("Not found");
1233-
} else {
1234-
outputln("Found: ", *found);
1235-
}
1236-
1183+
let data: *int = create_buffer(100);
1184+
defer free(data); // Caller properly handles ownership
12371185
return 0;
12381186
}
12391187
```
12401188

1241-
### Pattern 3: Result Struct
1189+
**Ownership Tracking:**
12421190

1243-
```luma
1244-
const Result -> struct {
1245-
success: bool,
1246-
value: int,
1247-
error_code: int
1248-
};
1191+
The analyzer understands three ownership patterns:
12491192

1250-
const safe_operation -> fn (x: int) Result {
1251-
if (x < 0) {
1252-
return Result {
1253-
success: false,
1254-
value: 0,
1255-
error_code: -1
1256-
};
1257-
}
1258-
1259-
return Result {
1260-
success: true,
1261-
value: x * 2,
1262-
error_code: 0
1263-
};
1264-
}
1193+
1. **`#returns_ownership` functions**: Allocations inside are NOT tracked as leaks because ownership transfers to the caller
1194+
2. **`#takes_ownership` functions**: Parameters marked with this receive ownership and are responsible for cleanup
1195+
3. **`defer` statements**: Deferred cleanup is tracked and validated at function exit
12651196

1266-
const main -> fn () int {
1267-
let result: Result = safe_operation(-5);
1268-
1269-
if (!result.success) {
1270-
outputln("Error: ", result.error_code);
1271-
return 1;
1272-
}
1273-
1274-
outputln("Result: ", result.value);
1275-
return 0;
1276-
}
1277-
```
1197+
**Transitive Ownership:**
12781198

1279-
### Best Practices
1280-
1281-
**Always check return values:**
12821199
```luma
1283-
// Bad
1284-
let ptr: *void = alloc(size);
1285-
*cast<*int>(ptr) = 42; // Might crash if allocation failed
1286-
1287-
// Good
1288-
let ptr: *void = alloc(size);
1289-
if (ptr == cast<*void>(0)) {
1290-
return ERROR_ALLOCATION;
1291-
}
1292-
defer free(ptr);
1293-
*cast<*int>(ptr) = 42;
1294-
```
1295-
1296-
**Use consistent error codes:**
1297-
```luma
1298-
const SUCCESS: int = 0;
1299-
const ERR_NULL_PTR: int = -1;
1300-
const ERR_OUT_OF_BOUNDS: int = -2;
1301-
const ERR_ALLOCATION: int = -3;
1302-
```
1303-
1304-
---
1200+
#returns_ownership
1201+
const create_arena_sized -> fn (size: int) Arena {
1202+
let a: Arena;
1203+
a.buf = alloc(size); // Not tracked - inside #returns_ownership
1204+
return a;
1205+
}
1206+
1207+
const create_arena -> fn () Arena {
1208+
return create_arena_sized(1024);
1209+
// Warning: Should add #returns_ownership annotation
1210+
// for API clarity (ownership is being passed through)
1211+
}
1212+
```
1213+
1214+
#### Current Limitations
1215+
1216+
**Known Edge Cases:**
1217+
1218+
The analyzer currently has limitations in these areas:
1219+
1220+
1. **Struct Field Granularity**: When tracking `a.buf = alloc(...)`, the analyzer tracks the entire struct `a`, not the specific field `a.buf`. This works for single-pointer structs but may cause issues with:
1221+
```luma
1222+
const Container -> struct {
1223+
data1: *int,
1224+
data2: *int
1225+
};
1226+
1227+
let c: Container;
1228+
c.data1 = alloc(10); // Tracked as "c"
1229+
c.data2 = alloc(20); // Also tracked as "c" - potential confusion
1230+
free(c.data1); // Marks "c" as freed, but c.data2 still allocated
1231+
```
1232+
1233+
2. **Conditional Allocations**: The analyzer may report false positives for conditional paths:
1234+
```luma
1235+
let ptr: *int;
1236+
if (condition) {
1237+
ptr = alloc(sizeof<int>);
1238+
}
1239+
// May warn even if you don't need to free in else branch
1240+
```
1241+
1242+
3. **Allocations in Loops**: Each loop iteration's allocations should be independent, but edge cases may exist:
1243+
```luma
1244+
loop [i: int = 0](i < 10) : (++i) {
1245+
let temp: *int = alloc(4);
1246+
// Use temp...
1247+
free(temp); // Should work correctly
1248+
}
1249+
```
1250+
1251+
4. **Early Returns with Defer**: While generally working, complex control flow with multiple early returns may need testing:
1252+
```luma
1253+
const process -> fn () int {
1254+
let a: *int = alloc(sizeof<int>);
1255+
defer free(a);
1256+
1257+
if (error) { return -1; } // Defer should fire
1258+
if (warning) { return 0; } // Defer should fire
1259+
return 1; // Defer should fire
1260+
}
1261+
```
1262+
1263+
5. **Stack vs Heap**: The analyzer doesn't currently detect returning pointers to stack variables:
1264+
```luma
1265+
const dangerous -> fn () *int {
1266+
let local: int = 42;
1267+
return &local; // NOT DETECTED - returns dangling pointer
1268+
}
1269+
```
1270+
1271+
6. **Arrays of Pointers**: Complex allocation patterns may not be fully tracked:
1272+
```luma
1273+
let arr: [*int; 5];
1274+
loop [i: int = 0](i < 5) : (++i) {
1275+
arr[i] = alloc(sizeof<int>); // Each needs individual free
1276+
}
1277+
```
1278+
1279+
#### Best Practices
1280+
1281+
To work effectively with the analyzer:
1282+
1283+
1. **Use ownership annotations consistently**: Mark all functions that allocate and return resources with `#returns_ownership`
1284+
2. **Use defer for cleanup**: Always pair allocations with `defer free()` for automatic cleanup
1285+
3. **One allocation per variable**: Avoid reassigning pointer variables after allocation without freeing
1286+
4. **Clear ownership semantics**: Document which functions own their pointer parameters vs. borrowing them
1287+
5. **Test early returns**: Ensure defer statements properly handle all exit paths
1288+
1289+
The analyzer is conservative - it may report false positives to prevent missed leaks. When in doubt, it will warn about potential issues rather than silently allowing them.
13051290

13061291
## Performance
13071292

0 commit comments

Comments
 (0)