Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
968b453
fix(lifetime): fail closed for domain-local refs stored through unbou…
godofecht Oct 8, 2026
1b3f190
test(lifetime): reject unbounded parameter-pointee domain escapes (#684)
godofecht Oct 8, 2026
22c4fe2
test(lifetime): reject unbounded parameter-pointee domain escapes (#684)
godofecht Oct 8, 2026
af6964b
test(lifetime): explicit callback field permits pointer-parameter sto…
godofecht Oct 8, 2026
1eae3ef
docs(lifetime): define fail-closed pointer-parameter storage rule (#684)
godofecht Oct 8, 2026
9583fe7
Merge main into #1472 (disjoint changes)
godofecht Oct 8, 2026
3b67986
Merge latest main into #1472, preserving disjoint lifetime validation…
godofecht Oct 9, 2026
d4e6799
Merge remote-tracking branch 'origin/main' into codex/pr1472-rebase
godofecht Oct 10, 2026
81ad007
fix: regenerate bootstrap C for parameter lifetime checks
godofecht Oct 10, 2026
d7a8d02
docs(lifetime): record pointer parameter storage rule
godofecht Oct 10, 2026
170d0cb
test(mlir): record pointer lifetime fixtures
godofecht Oct 10, 2026
46c6000
Merge main into #1472 and preserve lifetime soundness fix
godofecht Oct 10, 2026
490cffc
test(mlir): preserve corpus ordering after #1472 main merge
godofecht Oct 10, 2026
d564d70
docs(lifetime): make unbounded parameter example independently checkable
godofecht Oct 10, 2026
7c452b8
fix(bootstrap): carry parameter-target lifetime check into stage A
godofecht Oct 10, 2026
d683c76
fix(bootstrap): apply full regenerated sem_check delta
godofecht Oct 10, 2026
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
47 changes: 38 additions & 9 deletions compiler/bootstrap/flowc_stage_a.c
Original file line number Diff line number Diff line change
Expand Up @@ -86906,6 +86906,7 @@ const char* sem_node_key(Sem* c, int32_t node);
bool sem_domain_escape_to_static(Sem* c, int32_t stmt, int32_t value, const char* target, int32_t target_type);
bool sem_domain_escape_to_field(Sem* c, int32_t stmt, int32_t value, int32_t lhs, int32_t target_type);
bool sem_domain_escape_to_named_field(Sem* c, int32_t stmt, int32_t value, const char* field_name, int32_t field_domain);
bool sem_domain_escape_to_param_target(Sem* c, int32_t stmt, int32_t value, int32_t lhs);
bool sem_is_heap_alloc_expr(Sem* c, int32_t e);
bool sem_name_is_heap_root(Sem* c, const char* name);
bool sem_expr_is_heap_root(Sem* c, int32_t e);
Expand Down Expand Up @@ -88402,6 +88403,31 @@ bool sem_domain_escape_to_named_field(Sem* c, int32_t stmt, int32_t value, const
return 1;
}

bool sem_domain_escape_to_param_target(Sem* c, int32_t stmt, int32_t value, int32_t lhs) {
if (c->current_domain < 0 || lhs < 0) {
return 0;
}
if (sem_lhs_field_domain(c, lhs) >= 0) {
return 0;
}
int32_t pi = sem_root_parameter_index(c, lhs);
if (pi < 0 || (uint8_t*)(c->current_function_name) == NULL) {
return 0;
}
const char* origin = sem_composite_domain_root(c, value);
if ((uint8_t*)(origin) == NULL) {
return 0;
}
int32_t fi = sem_sm_get(c->function_decls, c->current_function_name);
const char* pname = "parameter";
if (fi >= 0 && pi < sem_fi_nparams(c, fi)) {
pname = sem_fi_param_name(c, fi, pi);
}
sem_sm_put(c->domain_reported, sem_node_key(c, stmt), 1);
sem_err(c, __flowc_str_concatn(9, "lifetime domain escape: `", origin, "` lives in the `", sem_domain_name(c->current_domain), "` domain but is stored through parameter `", pname, "`, whose pointee lifetime is not proven to end within this call ", "(add an explicit field lifetime contract or keep the composite local)", sem_location_suffix(c, stmt)));
return 1;
}

bool sem_is_heap_alloc_expr(Sem* c, int32_t e) {
if (e < 0 || sem_is_function_call(c, e) == 0) {
return 0;
Expand Down Expand Up @@ -90299,7 +90325,7 @@ int32_t sem_check_slice_expr(Sem* c, int32_t e) {
}
int32_t sp = sem_ty_named(c->t, TK_SPAN, __flowc_str_concat(prefix, sem_tstr(c, el)));
tys->elem[sp] = el;
{ __typeof__(tys->size[sp]) __flowc_st15260 = sem_static_length(c, e); tys->size[sp] = __flowc_st15260; }
{ __typeof__(tys->size[sp]) __flowc_st15391 = sem_static_length(c, e); tys->size[sp] = __flowc_st15391; }
return sp;
}

Expand Down Expand Up @@ -90470,7 +90496,7 @@ int32_t sem_check_lambda(Sem* c, int32_t e) {
p = sem_nnext(c, p);
}
SemIntVec* saved_sink = (SemIntVec*)(c->return_sink);
{ __typeof__(c->return_sink) __flowc_st16009 = sem_iv_new(); c->return_sink = __flowc_st16009; }
{ __typeof__(c->return_sink) __flowc_st16140 = sem_iv_new(); c->return_sink = __flowc_st16140; }
sem_check_block(c, sem_ncc(c, e));
c->return_sink = saved_sink;
sem_walk_lambda_captures(c, sem_ncc(c, e), cname, mark);
Expand All @@ -90479,9 +90505,9 @@ int32_t sem_check_lambda(Sem* c, int32_t e) {
c->current_rt_safe_fn = prev_rt;
c->rt_safe_from_domain = prev_rt_dom;
if (sem_nb(c, e) != AST_NONE) {
{ __typeof__(tys->ret[f]) __flowc_st16059 = sem_parse_type(c, sem_pt_of_ast(c, sem_nb(c, e))); tys->ret[f] = __flowc_st16059; }
{ __typeof__(tys->ret[f]) __flowc_st16190 = sem_parse_type(c, sem_pt_of_ast(c, sem_nb(c, e))); tys->ret[f] = __flowc_st16190; }
} else {
{ __typeof__(tys->ret[f]) __flowc_st16068 = sem_tmk(c, TK_VOID); tys->ret[f] = __flowc_st16068; }
{ __typeof__(tys->ret[f]) __flowc_st16199 = sem_tmk(c, TK_VOID); tys->ret[f] = __flowc_st16199; }
}
return f;
}
Expand Down Expand Up @@ -91023,6 +91049,9 @@ int32_t sem_check_assignment(Sem* c, int32_t s) {
}
if (reported == 0) {
reported = sem_domain_escape_to_field(c, s, value, lhs, tt);
}
if (reported == 0) {
reported = sem_domain_escape_to_param_target(c, s, value, lhs);
}
if (reported == 0 && (uint8_t*)(base) != NULL && sem_sm_has(c->static_names, base) == 0) {
reported = sem_domain_escape_to_heap(c, s, value, base, tt);
Expand Down Expand Up @@ -91881,17 +91910,17 @@ void sem_check_function(Sem* c, int32_t fi) {
SemStrMap* prev_origin = (SemStrMap*)(c->span_origin);
SemStrMap* prev_heap = (SemStrMap*)(c->heap_roots);
int32_t prev_ret = c->current_return_type;
{ __typeof__(c->local_storage) __flowc_st22121 = sem_sv_new(); c->local_storage = __flowc_st22121; }
{ __typeof__(c->span_origin) __flowc_st22125 = sem_sm_new(); c->span_origin = __flowc_st22125; }
{ __typeof__(c->heap_roots) __flowc_st22129 = sem_sm_new(); c->heap_roots = __flowc_st22129; }
{ __typeof__(c->current_return_type) __flowc_st22138 = sem_parse_type(c, sem_iv_get(c->fi_ret, fi)); c->current_return_type = __flowc_st22138; }
{ __typeof__(c->local_storage) __flowc_st22264 = sem_sv_new(); c->local_storage = __flowc_st22264; }
{ __typeof__(c->span_origin) __flowc_st22268 = sem_sm_new(); c->span_origin = __flowc_st22268; }
{ __typeof__(c->heap_roots) __flowc_st22272 = sem_sm_new(); c->heap_roots = __flowc_st22272; }
{ __typeof__(c->current_return_type) __flowc_st22281 = sem_parse_type(c, sem_iv_get(c->fi_ret, fi)); c->current_return_type = __flowc_st22281; }
i = 0;
for (; i < (sem_fi_nparams(c, fi)); i = i + 1) {
int32_t pt = sem_parse_type(c, sem_fi_param_type(c, fi, i));
sem_define_var(c, sem_fi_param_name(c, fi, i), pt, 0);
}
SemIntVec* prev_sink = (SemIntVec*)(c->return_sink);
{ __typeof__(c->return_sink) __flowc_st22181 = sem_iv_new(); c->return_sink = __flowc_st22181; }
{ __typeof__(c->return_sink) __flowc_st22324 = sem_iv_new(); c->return_sink = __flowc_st22324; }
sem_check_block(c, sem_iv_get(c->fi_body, fi));
SemIntVec* returns = (SemIntVec*)(c->return_sink);
c->return_sink = prev_sink;
Expand Down
4 changes: 3 additions & 1 deletion compiler/fixtures/mlir/corpus.txt
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,8 @@ f3c0f1780d6a2204f761f20b41abd95fa55ab64422cc48d70e8ccdf84693d9df compiler/fixtur
c1740c9451405142c8b2213c703aad4215387e1ebd05cd0fa9b0790b0b8485e3 compiler/fixtures/typecheck_rules/domain_ld5_unannotated_field.flow
c1740c9451405142c8b2213c703aad4215387e1ebd05cd0fa9b0790b0b8485e3 compiler/fixtures/typecheck_rules/domain_ld5_unannotated_fn.flow
f061d484520905d1693c5567431740b70d9ced000bc134328bd87ac89ce27763 compiler/fixtures/typecheck_rules/domain_ld5_value_field.flow
35859f7f3352ce33a7ab5e3af2756e8b9f3837441c6d96ecfe8867ada086e429 compiler/fixtures/typecheck_rules/domain_param_composite_unbounded.flow
5bab87f416406da897449642647480e4c8fa03b534f103abeaf844ea8b832290 compiler/fixtures/typecheck_rules/domain_param_field_unbounded.flow
7dbb1d9f8ea4ba6558c96a5f2ebc1ce18ad9845b96945bd22f29028cde9c13af compiler/fixtures/typecheck_rules/domain_persistent_pointer_static.flow
da928172a7bf2e2989c92ea345ff50f46bb789d5f758a32f45a09e5a33bf2122 compiler/fixtures/typecheck_rules/domain_persistent_string_array.flow
8f23d03501493b4442f7b6f4e0b443310f191bca68089b540d95aad17d696785 compiler/fixtures/typecheck_rules/domain_request_call_session.flow
Expand Down Expand Up @@ -2332,7 +2334,7 @@ b373ac75acc41ccef616dc57987ae160a7bd338a32b8ab654cdfcc0cff5af601 tests/lang/test
059f890a0b1efc9072df83c03fb496c021a29247f85794579fb9b047f91f245b tests/lang/test_deep_tail_recursion.flow
ba8141636b8ee78c8fcb698cc1cad34563fce9833dfacb0e1eb01945b7f8dfda tests/lang/test_defer_ordering.flow
3e876e834ff5ce20d090be87b8406aa0699f6b72e95372bf64fd562b899b1172 tests/lang/test_defer_return_order.flow
10eeff24b2150ba69649814c940cb174a374d7570d897e9d1b76c47300800046 tests/lang/test_domain_fields.flow
d3c16bbb4072eaa8d87ed81e23a7e1abe2d8918e743c9407ab08a8045d46addc tests/lang/test_domain_fields.flow
16e0270ae14480e4fcdb56f2164744661902ad333d97bbea4d0f7e6926cfc880 tests/lang/test_dsl_detect.flow
46208f128ccd478092a9a0fd23564b8c775fa55cf688df5a2a88fbde8f9e206d tests/lang/test_dual_operator_sugar.flow
84fa41e915704ac6b562b27aef9ef923277f027dafd3dce8190d4bc7de720003 tests/lang/test_dynamic_invariants.flow
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# expect: lifetime domain escape: `scratch` lives in the `frame` domain but is stored through parameter `out`, whose pointee lifetime is not proven to end within this call (add an explicit field lifetime contract or keep the composite local) at line 9, column 5
struct Holder {
view: ptr<i32>
}

@lifetime(frame)
function fill(out: ptr<Holder>) -> void {
let scratch: array<i32, 2> = [7, 8]
out[0] = Holder { view: &scratch }
}

function main() -> i32 { return 0 }
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# expect: lifetime domain escape: `scratch` lives in the `callback` domain but is stored through parameter `out`, whose pointee lifetime is not proven to end within this call (add an explicit field lifetime contract or keep the composite local) at line 9, column 5
struct Holder {
view: ptr<i32>
}

@lifetime(callback)
function fill(out: ptr<Holder>) -> void {
let scratch: array<i32, 4> = [1, 2, 3, 4]
out[0].view = &scratch
}

function main() -> i32 { return 0 }
36 changes: 36 additions & 0 deletions compiler/src/sem_check.flow
Original file line number Diff line number Diff line change
Expand Up @@ -1395,6 +1395,39 @@ function sem_domain_escape_to_named_field(c: ptr<Sem>, stmt: i32, value: i32, fi
return true
}

# A pointer/span parameter names caller-owned storage. Without an explicit
# field lifetime, the callee cannot prove that pointee storage dies with this
# invocation, so a domain-local reference stored through it must fail closed.
function sem_domain_escape_to_param_target(c: ptr<Sem>, stmt: i32, value: i32, lhs: i32) -> bool {
if c.current_domain < 0 or lhs < 0 {
return false
}
# A declared field lifetime is a stronger contract and is handled by LD5.
if sem_lhs_field_domain(c, lhs) >= 0 {
return false
}
let pi: i32 = sem_root_parameter_index(c, lhs)
if pi < 0 or c.current_function_name as ptr<u8> == null {
return false
}
let origin: string = sem_composite_domain_root(c, value)
if origin as ptr<u8> == null {
return false
}
let fi: i32 = sem_sm_get(c.function_decls, c.current_function_name)
let mut pname: string = "parameter"
if fi >= 0 and pi < sem_fi_nparams(c, fi) {
pname = sem_fi_param_name(c, fi, pi)
}
sem_sm_put(c.domain_reported, sem_node_key(c, stmt), 1)
sem_err(c, "lifetime domain escape: `" + origin + "` lives in the `" +
sem_domain_name(c.current_domain) + "` domain but is stored through parameter `" +
pname + "`, whose pointee lifetime is not proven to end within this call " +
"(add an explicit field lifetime contract or keep the composite local)" +
sem_location_suffix(c, stmt))
return true
}

function sem_is_heap_alloc_expr(c: ptr<Sem>, e: i32) -> bool {
if e < 0 or sem_is_function_call(c, e) == false {
return false
Expand Down Expand Up @@ -4043,6 +4076,9 @@ function sem_check_assignment(c: ptr<Sem>, s: i32) -> i32 {
if reported == false {
reported = sem_domain_escape_to_field(c, s, value, lhs, tt)
}
if reported == false {
reported = sem_domain_escape_to_param_target(c, s, value, lhs)
}
# Store through a heap root (#765): a malloc result or a local
# already marked as one lives in the application domain.
if reported == false and base as ptr<u8> != null and sem_sm_has(c.static_names, base) == false {
Expand Down
27 changes: 24 additions & 3 deletions docs/language/domain-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,32 @@ function build() -> Holder {
}
```

## Pointer-parameter targets fail closed

A pointer/span parameter is caller-owned storage whose pointee lifetime is
not bounded by the callee. A domain-local reference therefore cannot be
stored through an **unannotated** parameter target:

```flow expect-error
struct Holder { view: ptr<i32> }

@lifetime(callback)
function fill(out: ptr<Holder>) -> void {
let scratch: array<i32, 4> = [1, 2, 3, 4]
out[0].view = &scratch
}

function main() -> i32 { return 0 }
```

The same rule covers a whole-composite assignment such as
`out[0] = Holder { view: &scratch }`. An explicit field contract at the
same or shorter domain remains legal: for example an
`@lifetime(callback) view: ptr<i32>` field can receive a callback-local
reference through `ptr<BoundHolder>`.

## What is still not checked

- A store into an unannotated field of a struct reached only through a
pointer parameter. The checker cannot prove that struct outlives the
writing frame.
- Copying a local struct that was previously filled with a short-lived
reference (`holder = local`) without a literal on the right-hand side.
- Escape through a closure, a function pointer, the heap, or pointer
Expand Down
14 changes: 14 additions & 0 deletions docs/project/Questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,20 @@ contract, together with rule LD5. #1358 merges after #1369.

**Resolved:** 2026-10-07, decided by the project owner.

### 2026-10-10: Stores through unbounded pointer parameters

**Context:** PR #1472 follows issue #684. A function with a declared
lifetime domain can store a frame-local reference through an unannotated
pointer or span parameter. The callee cannot establish how long the
caller-owned pointee remains live.

**Answer:** Reject that store when the value contains a reference rooted in
the writing frame. An explicit field `@lifetime(D)` contract permits the
store when `D` is the same or shorter-lived than the writer. This applies
to field stores and whole-composite assignment through the parameter.

**Resolved:** 2026-10-10, decided by the project owner.

### 2026-10-07: Last-use moves without a `move` keyword or COW

**Context:** Issue #696 asks for value semantics with compiler-inferred
Expand Down
15 changes: 15 additions & 0 deletions tests/lang/test_domain_fields.flow
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ function fill_from_static() -> i32 {
return 0
}

@lifetime(callback)
function fill_param_same_domain(out: ptr<BoundHolder>) -> i32 {
let scratch: array<i32, 2> = [12, 13]
out[0].view = &scratch
if out[0].view[1] != 13 {
return 6
}
return 0
}

@lifetime(session)
function fill_shorter_field() -> i32 {
let mut h: BoundHolder = BoundHolder { view: null }
Expand Down Expand Up @@ -89,5 +99,10 @@ function main() -> i32 {
if d != 0 {
return d
}
let mut h: BoundHolder = BoundHolder { view: null }
let e: i32 = fill_param_same_domain(&h)
if e != 0 {
return e
}
return 0
}
Loading