From f553e219ee718e65be5751a08184b5b154916f2f Mon Sep 17 00:00:00 2001 From: sanohiro Date: Tue, 10 Nov 2026 13:27:01 +0900 Subject: [PATCH 1/2] feat: transfer owned captures into structured tasks --- HANDOFF.md | 9 + crates/align_codegen_llvm/src/lib.rs | 157 +++++--- crates/align_driver/src/lib.rs | 2 +- .../align_driver/tests/task_owned_captures.rs | 350 ++++++++++++++++++ crates/align_mir/src/lib.rs | 36 +- crates/align_mir/src/print.rs | 5 +- crates/align_mir/src/validate_hir.rs | 8 +- crates/align_mir/src/validate_hir_tests.rs | 40 ++ crates/align_sema/src/callback_access.rs | 6 + crates/align_sema/src/lib.rs | 121 ++++-- docs/design-notes.md | 10 + docs/guide/10-closures-and-parallelism.md | 36 +- docs/guide/ja/10-closures-and-parallelism.md | 29 +- docs/impl/180-structured-task-progress.md | 4 + docs/impl/19-hir-validation-ledger.md | 4 +- docs/impl/228-task-owned-results.md | 4 + docs/impl/259-task-owned-captures.md | 110 ++++++ docs/language-spec.md | 17 +- docs/open-questions.md | 6 +- draft.md | 24 +- 20 files changed, 880 insertions(+), 98 deletions(-) create mode 100644 crates/align_driver/tests/task_owned_captures.rs create mode 100644 docs/impl/259-task-owned-captures.md diff --git a/HANDOFF.md b/HANDOFF.md index b4ec1c0e5..55ccae05f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,6 +2,15 @@ ## Current direction +The owner's 2026-11-10 request selects R41's admitted-file startup witness. +[Plan259](docs/impl/259-task-owned-captures.md) supplies owned captures in a +literal `spawn` and file-bearing results, allowing the same opened file and +bounded window to move through worker reads. Shared views remain read-only; +ordinary stored/pipeline lambdas remain Copy-only. Provider owners cover file +identity, source/alias invalidation, cleanup, imports and cache replay. LLM +integration, native full-answer comparison and startup measurements remain +consumer-owned; media and voice require no adoption in this provider change. + The owner's 2026-10-30 request check selects R109's concrete byte-access guard residual. [Plan257](docs/impl/257-byte-access-guard-fusion.md) fuses fixed-width read/write guards and updates both proof consumers together. Current LLM module diff --git a/crates/align_codegen_llvm/src/lib.rs b/crates/align_codegen_llvm/src/lib.rs index 7a7b961d8..68c6d916a 100644 --- a/crates/align_codegen_llvm/src/lib.rs +++ b/crates/align_codegen_llvm/src/lib.rs @@ -4394,7 +4394,7 @@ fn lower_prepared_module<'c>( generated_funcs.insert(id, thunk); } - // Pass 1c: a closure thunk per lifted function used as a *capturing* closure. The env-ABI + // Pass 1c: an environment thunk per lifted function used as a closure or direct task. The env-ABI // helper loads the captured values out of `env` and forwards them // as the lifted function's trailing capture parameters: `lifted(explicit…, env.0, env.1, …)`. let mut closure_thunks: std::collections::BTreeMap> = @@ -4402,7 +4402,8 @@ fn lower_prepared_module<'c>( for f in program.fns.iter().filter(|function| scope.defines(function)) { for b in &f.blocks { for s in &b.stmts { - if let Stmt::Let(_, Rvalue::Closure { lifted, capture_tys, .. }) = s { + if let Stmt::Let(_, Rvalue::Closure { lifted, capture_tys, .. } + | Rvalue::SpawnTask { lifted, capture_tys, .. }) = s { closure_thunks.entry(lifted.clone()).or_insert_with(|| capture_tys.clone()); } } @@ -5778,46 +5779,35 @@ fn task_slot_type<'c>(ctx: &'c Context, result: BasicTypeEnum<'c>) -> inkwell::t ctx.struct_type(&[result, ctx.bool_type().into()], false) } -// Spawn consumes one freshly lowered literal closure in the current block. Authenticate its -// actual callable ABI, not merely the repr-only Fn tag whose return is the successful payload. +// The task owns its direct inputs; no Copy Fn intermediate can duplicate an owning environment. fn task_request_is_valid( program: &Program, declarations: &HashMap, function: &Function, - preceding: &[Stmt], result: ValueId, request: &Rvalue, ) -> Result { - let Rvalue::SpawnTask { tg, closure, capture_tys, r, fallible } = request else { + let Rvalue::SpawnTask { tg, lifted, captures, capture_tys, r, fallible } = request else { return Ok(false); }; if !align_sema::task_result_type( *r, &program.structs, &program.tuples, &program.enums, &program.tagged_types, ) || function.value_tys.get(result as usize).copied() != ty_to_scalar(*r).map(Ty::Task) || preflight_operand_ty(function, tg) != Some(Ty::ArenaHandle) + || captures.len() != capture_tys.len() + || captures.iter().zip(capture_tys).any(|(operand, ty)| { + preflight_operand_ty(function, operand) != Some(*ty) + || (align_sema::ty_capture_is_move(*ty, &program.structs, &program.tuples, + &program.enums, &program.tagged_types) + && !align_sema::task_owned_capture_type(*ty, &program.structs, + &program.tuples, &program.enums, &program.tagged_types)) + || align_sema::ty_mentions_resource(*ty, &program.structs, &program.tuples, + &program.enums, &program.tagged_types) + }) { return Ok(false); } - let Some(Ty::Fn(fid)) = preflight_operand_ty(function, closure) else { return Ok(false) }; - let Some(signature) = program.fn_types.get(fid as usize) else { return Ok(false) }; - if !signature.params.is_empty() || !source_ty_matches(signature.ret, *r, program)? { - return Ok(false); - } - let Operand::Value(closure_id) = closure else { return Ok(false) }; - let mut definitions = preceding.iter().filter_map(|statement| match statement { - Stmt::Let(value, definition) if value == closure_id => Some(definition), - _ => None, - }); - let Some(definition) = definitions.next() else { return Ok(false) }; - if definitions.next().is_some() { - return Ok(false); - } - let target = match definition { - Rvalue::FnAddr { target, .. } if capture_tys.is_empty() => target, - Rvalue::Closure { lifted, capture_tys: actual, .. } if actual == capture_tys => lifted, - _ => return Ok(false), - }; - let Some(declaration) = declarations.get(target) else { return Ok(false) }; + let Some(declaration) = declarations.get(lifted) else { return Ok(false) }; let expected = if *fallible { let Some(error) = program.enums.iter().position(|definition| definition.name == "Error") .and_then(|index| u32::try_from(index).ok()) else { return Ok(false) }; @@ -5826,12 +5816,40 @@ fn task_request_is_valid( } else { *r }; - Ok(declaration.signature.params == *capture_tys + Ok(declaration.classes.as_slice() == [ProgramDeclarationClass::Stored] + && declaration.signature.params == *capture_tys + && declaration.signature.modes.len() == capture_tys.len() && declaration.signature.modes.iter().all(|mode| *mode == align_ast::ParamMode::ByValue) && declaration.signature.cleanup == if task_result_owned(*r, program) { hir::ReturnCleanupAbi::DynamicBit } else { hir::ReturnCleanupAbi::None } && source_ty_matches(declaration.signature.ret, expected, program)?) } +fn task_environment_identity( + program: &Program, + declaration: &ProgramDeclaration, + lifted: &ProgramCall, + capture_tys: &[Ty], +) -> Result { + let count = u32::try_from(capture_tys.len()).map_err(|_| callable_target_error(lifted))?; + let signature = ProgramSignature { + params: Vec::new(), + modes: Vec::new(), + ret: declaration.signature.ret, + borrow: xml_closure_borrow_summary(&declaration.signature.borrow, 0, count) + .ok_or_else(|| callable_target_error(lifted))?, + region: xml_closure_region_summary(&declaration.signature.region, 0, count) + .ok_or_else(|| callable_target_error(lifted))?, + cleanup: declaration.signature.cleanup, + drop_state_effects: Vec::new(), + }; + Ok(GeneratedId::Closure { + lifted: lifted.clone(), + explicit_signature: canonical_signature(&signature, program)?, + drop_state_effects: drop_state_effect_tags(&declaration.signature.drop_state_effects), + captures: capture_tys.iter().map(|ty| canonical_ty(*ty, program)).collect::, _>>()?, + }) +} + fn callable_preflight( program: &Program, exports: &[String], @@ -5841,7 +5859,7 @@ fn callable_preflight( let mut generated = Vec::new(); for function in program.fns.iter().filter(|function| scope.defines(function)) { for block in &function.blocks { - for (statement_index, statement) in block.stmts.iter().enumerate() { + for statement in &block.stmts { let Stmt::Let(value, rvalue) = statement else { continue; }; @@ -6070,6 +6088,9 @@ fn callable_preflight( if declaration.classes.as_slice() != [ProgramDeclarationClass::Stored] || capture_tys.len() > declaration.signature.params.len() || captures.len() != capture_tys.len() + || capture_tys.iter().any(|ty| align_sema::ty_capture_is_move( + *ty, &program.structs, &program.tuples, &program.enums, &program.tagged_types, + )) { return Err(callable_target_error(lifted).into()); } @@ -6150,6 +6171,7 @@ fn callable_preflight( }); } Rvalue::SpawnTask { + lifted, capture_tys, r, fallible, @@ -6162,11 +6184,13 @@ fn callable_preflight( )); } if !task_request_is_valid( - program, &declarations, function, &block.stmts[..statement_index], - *value, rvalue, + program, &declarations, function, *value, rvalue, )? { return Err(CodegenError::Lowering("spawn result/callable metadata invalid".into())); } + let declaration = declarations.get(lifted) + .ok_or_else(|| callable_target_error(lifted))?; + generated.push(task_environment_identity(program, declaration, lifted, capture_tys)?); if task_result_owned(*r, program) { generated.push(GeneratedId::TaskDrop { result: canonical_ty(*r, program)? }); } @@ -10441,7 +10465,7 @@ fn stack_header_plan(f: &Function) -> StackHeaderPlan { Rvalue::StaticDescriptorView { ptr, .. } => { reject_header_operand(ptr, &load_defs, &owner, &mut bad); } - Rvalue::Closure { captures, .. } => { + Rvalue::Closure { captures, .. } | Rvalue::SpawnTask { captures, .. } => { for op in captures { reject_header_operand(op, &load_defs, &owner, &mut bad); } @@ -14825,14 +14849,17 @@ impl<'c, 'a> FnGen<'c, 'a> { .map_err(|e| self.err(e))?; cs.try_as_basic_value().basic().expect("tg_begin returns a pointer") } - Rvalue::SpawnTask { tg, closure, capture_tys, r, fallible } => { + Rvalue::SpawnTask { tg, lifted, captures, capture_tys, r, fallible } => { let i64t = self.ctx.i64_type(); let tgv = self.operand(tg)?.into_pointer_value(); - let clos = self.operand(closure)?.into_struct_value(); - let thunk = self.builder.build_extract_value(clos, 0, "thunk").map_err(|e| self.err(e))?; - let frame_env = self.builder.build_extract_value(clos, 1, "fenv").map_err(|e| self.err(e))?.into_pointer_value(); - // Snapshot the captures into a fresh env in the task-group region (so a deferred - // task reads its own captures, not a frame slot reused by a later `spawn`). + let declaration = self.callable_preflight.declarations.get(lifted) + .ok_or_else(|| callable_target_error(lifted))?; + let identity = task_environment_identity(self.program, declaration, lifted, capture_tys)?; + let thunk = self.generated_funcs.get(&identity) + .ok_or_else(|| self.err("task environment thunk was not collected"))? + .as_global_value().as_pointer_value(); + // Store each already-evaluated input directly into fresh group-owned staging. + // The runtime invokes the typed thunk once; the worker owns moved inputs. let env: BasicValueEnum = if capture_tys.is_empty() { self.ctx.ptr_type(AddressSpace::default()).const_null().into() } else { @@ -14856,9 +14883,13 @@ impl<'c, 'a> FnGen<'c, 'a> { .build_call(self.runtime(RuntimeKey::TgAlloc), &[tgv.into(), i64t.const_int(size, false).into(), i64t.const_int(align, false).into()], "env") .map_err(|e| self.err(e))? .try_as_basic_value().basic().expect("tg_alloc returns a pointer").into_pointer_value(); - self.builder - .build_memcpy(re, align as u32, frame_env, align as u32, i64t.const_int(size, false)) - .map_err(|e| self.err(e))?; + for (index, capture) in captures.iter().enumerate() { + let index = u32::try_from(index).map_err(|_| self.err("task capture index overflow"))?; + let field = self.builder.build_struct_gep(env_struct, re, index, "task.capture") + .map_err(|e| self.err(e))?; + let value = self.operand(capture)?; + self.builder.build_store(field, value).map_err(|e| self.err(e))?; + } re.into() }; // The result slot (a `box` in the region — the `Task` handle). @@ -38968,13 +38999,13 @@ fn main() -> i32 = 0 for block in &mut function.blocks { for statement in &mut block.stmts { let Stmt::Let(value, request) = statement else { continue }; - if let Rvalue::SpawnTask { tg, r, fallible, capture_tys, closure } = request { + if let Rvalue::SpawnTask { tg, r, fallible, capture_tys, lifted, .. } = request { match mutation { 0 => *r = Ty::Str, 1 => function.value_tys[*value as usize] = Ty::Task(Scalar::Bool), 2 => *fallible = true, 3 => capture_tys.push(Ty::Bool), - 4 => *closure = Operand::Const(Const::Unit), + 4 => *lifted = program_call("missing_task_target"), 6 => *request = Rvalue::HeapAlloc(tg.clone(), Operand::Const(Const::Unit)), _ => continue, } @@ -38998,6 +39029,45 @@ fn main() -> i32 = 0 } } + #[test] + fn task_owned_capture_mir_rejects_forged_transfer_and_copy_closure() -> Result<(), String> { + let base = mir("fn main(){owner:=\"owned\".clone();task_group{task:=spawn(fn{owner});wait();task.get();()}}\n"); + emit_llvm_ir(&base, &BuildTarget::Native, Profile::Release, false, &[], None) + .map_err(|error| error.to_string())?; + for mutation in 0..5 { + let mut bad = base.clone(); + let mut changed = false; + for function in &mut bad.fns { + for block in &mut function.blocks { + for statement in &mut block.stmts { + let Stmt::Let(_, request) = statement else { continue }; + let Rvalue::SpawnTask { lifted, captures, capture_tys, .. } = request else { continue }; + match mutation { + 0 => { captures.clear(); } + 1 => { *captures.first_mut().ok_or("capture")? = Operand::Const(Const::Unit); } + 2 => { *capture_tys.first_mut().ok_or("capture type")? = Ty::Raw; } + 3 => { *lifted = program_call("missing_task_target"); } + _ => { + *request = Rvalue::Closure { + lifted: lifted.clone(), captures: captures.clone(), capture_tys: capture_tys.clone(), + signature: Box::new(align_mir::FnSignatureFacts { + param_modes: Vec::new(), return_borrow: hir::ReturnBorrowSummary::None, + return_region: hir::ReturnRegionSummary::None, + return_cleanup: hir::ReturnCleanupAbi::DynamicBit, + }), + }; + } + } + changed = true; + } + } + } + assert!(changed); + assert!(emit_llvm_ir(&bad, &BuildTarget::Native, Profile::Release, false, &[], None).is_err(), "{mutation}"); + } + Ok(()) + } + #[test] fn task_owned_metadata_rejects_reader_and_callback_forgery() -> Result<(), String> { for (owned, source) in [ @@ -39051,7 +39121,8 @@ fn main() -> i32 = 0 0, Rvalue::SpawnTask { tg: Operand::Const(Const::Unit), - closure: Operand::Const(Const::Unit), + lifted: program_call("invalid_task"), + captures: vec![Operand::Const(Const::Unit)], capture_tys: vec![Ty::ArenaHandle], r: i32_ty, fallible: false, diff --git a/crates/align_driver/src/lib.rs b/crates/align_driver/src/lib.rs index c4fad45c3..f08f4060d 100644 --- a/crates/align_driver/src/lib.rs +++ b/crates/align_driver/src/lib.rs @@ -7585,7 +7585,7 @@ fn referenced_program_calls( Rvalue::FnAddr { target, .. } => { targets.insert(target.clone()); } - Rvalue::Closure { lifted, .. } => { + Rvalue::Closure { lifted, .. } | Rvalue::SpawnTask { lifted, .. } => { targets.insert(lifted.clone()); } Rvalue::ParMapParallel { func, stages, .. } => { diff --git a/crates/align_driver/tests/task_owned_captures.rs b/crates/align_driver/tests/task_owned_captures.rs new file mode 100644 index 000000000..93c3fb2f1 --- /dev/null +++ b/crates/align_driver/tests/task_owned_captures.rs @@ -0,0 +1,350 @@ +//! Plan259: task inputs transfer once; aliases expire and admitted files retain identity. +#![cfg(unix)] +#[path = "helpers/owned_fixture.rs"] +mod owned_fixture; +use align_driver::{BuildTarget, Profile}; +use align_span::SourceMap; +use std::path::Path; +type TestResult = Result>; +fn execute(stage: &Path, programs: &[align_mir::Program], label: &str, rt_lto: bool, native: Option<&Path>) -> TestResult { + let mut objects = native.into_iter().map(Path::to_path_buf).collect::>(); + let mut libraries = Vec::new(); + for (index, program) in programs.iter().enumerate() { + let object = stage.join(format!("{label}-{index}.o")); + align_driver::emit_object_file( + program, + &object, + BuildTarget::Native, + Profile::Release, + &[], + rt_lto, + )?; + objects.push(object); + for library in &program.link_libs { + if !libraries.contains(library) { + libraries.push(library.clone()); + } + } + } + let binary = stage.join(label); + align_driver::link_objects( + &align_driver::CDriver::default(), + &objects + .iter() + .map(|path| path.as_path()) + .collect::>(), + &binary, + &libraries, + Profile::Release, + )?; + let output = std::process::Command::new(&binary).output()?; + assert!( + output.status.success() && output.stdout.is_empty() && output.stderr.is_empty(), + "{label}: {output:?}" + ); + Ok(()) +} +fn programs(stage: &Path, source: &str, per_unit: bool) -> TestResult> { + let path = stage.join("main.align"); + std::fs::write(&path, source)?; + let entry = path.to_str().ok_or("non-UTF8 fixture path")?; + let mut sources = SourceMap::new(); + if per_unit { + let built = align_driver::build_per_unit(&mut sources, entry, source); + assert!( + !built.diags.has_errors(), + "{}", + align_driver::format_diagnostics(&sources, &built.diags) + ); + Ok(built.units.into_iter().map(|unit| unit.mir).collect()) + } else { + let checked = align_driver::check(&mut sources, entry, source); + assert!( + !checked.diags.has_errors(), + "{}", + align_driver::format_diagnostics(&sources, &checked.diags) + ); + Ok(vec![ + align_mir::lower_program_checked(&checked.hir, false, Some(&sources)) + .map_err(|error| format!("{error:?}"))?, + ]) + } +} + +fn run_both(stage: &Path, source: &str, label: &str) -> TestResult { + for per_unit in [false, true] { + let mir = programs(stage, source, per_unit)?; + execute(stage, &mir, &format!("{label}-{per_unit}"), per_unit, None)?; + } + Ok(()) +} + + +const DATA: &str = r#"module main +import model +extern "C" fn align_rt_requested_live_reset() +extern "C" fn align_rt_requested_live_bytes()->i64 +extern "C" fn align_rt_requested_live_peak()->i64 +fn fail(value: model.Row)->Result = Err(error(17)) +fn early()->Result<(),Error> { + first:=model.make() + second:=model.make() + task_group {spawn(fn {first});spawn(fn {fail(second)});return Err(error(19))} +} +fn run()->Result<(),Error> { + mut value:=model.make() + mut round:=0 + loop { + if round==4 {break} + task_group { + t:=spawn(fn {model.identity(value)}) + wait() + value=t.get() + } + if value.name!="row" || value.values[1]!=29 {return Err(error(1))} + round=round+1 + } + task_group { + a:="text".clone() + mut bytes:=buffer(2);bytes.append("GH".bytes()) + list:=[17,29].to_array() + pair:=("pair".clone(),23) + optional:Option:=Some("optional".clone()) + result:Result:=Ok("result".clone()) + choice:=model.Choice.Text("sum".clone()) + first:=spawn(fn {a}) + second:=spawn(fn {mut local:=bytes;model.append(local);local}) + third:=spawn(fn {list}) + fourth:=spawn(fn {pair}) + fifth:=spawn(fn {optional else {"missing".clone()}}) + sixth:=spawn(fn {result}) + seventh:=spawn(fn {choice}) + wait()? + text:=first.get();output:=second.get();numbers:=third.get();two:=fourth.get() + opt:=fifth.get();res:=sixth.get();sum:=seventh.get() + if text!="text" || output.len()!=3 || numbers[1]!=29 || two.1!=23 || opt!="optional" || res!="result" {return Err(error(2))} + match sum {Text(v)=>{if v!="sum" {return Err(error(3))}},Off=>{return Err(error(4))}} + } + task_group { + x:=model.make();y:=model.make() + spawn(fn {x});spawn(fn {fail(y)});ignored:=wait();() + } + match early() {Err(_)=>{},Ok(_)=>{return Err(error(5))}} + value=task_group { + owned:=model.make() + outer:=spawn(fn {local:=owned;task_group {inner:=spawn(fn {local});wait();inner.get()}}) + wait();outer.get() + } + if value.name!="row" {return Err(error(6))} + task_group { + one:=model.make();two:=model.make() + task:=spawn(fn {if true {model.attempt(one)} else {fail(two)}}) + wait().map_err(fn e:Error {e})? + } + Ok(()) +} +fn main()->i32 { + unsafe {align_rt_requested_live_reset()} + match run() {Err(e)=>{match e {Code(c)=>print(c),_=>print(-1)};return 1},Ok(_)=>{}} + live:=unsafe {align_rt_requested_live_bytes()} + if live!=0 || unsafe {align_rt_requested_live_peak()}<=0 {print(live);return 2} + 0 +} +"#; +const MODEL: &str = r#"module model +pub Row {name:string,values:array} +pub Choice {Text(string),Off} +pub fn make()->Row=Row {name:"row".clone(),values:[17,29].to_array()} +pub fn identity(value:T)->T=value +pub fn attempt(value:Row)->Result = Ok(value) +pub fn append(borrow mut value:buffer) {value.append("!".bytes())} +"#; + +#[test] +fn owned_capture_roots_cleanup_and_imports() { + owned_fixture::run("owned_capture_roots_cleanup_and_imports", |stage| { + let result = (|| -> TestResult { + std::fs::write(stage.join("model.align"), MODEL)?; + run_both(stage, DATA, "owned-inputs") + })(); + assert!(result.is_ok(), "{result:?}"); + }); +} + +#[test] +fn owned_capture_rejects_aliases_borrows_and_non_send_graphs() { + owned_fixture::run("owned_capture_rejects_aliases_borrows_and_non_send_graphs", |stage| { + for (name, source, expected) in [ + ("caller-alias", "fn main(){owner:=[1,2].to_array();view:=owner[..];task_group{spawn(fn{owner.len()});print(view[0]);wait()}}", "invalidated borrow"), + ("alias-first", "fn main(){owner:=[1,2].to_array();view:=owner[..];task_group{spawn(fn{print(view[0]);owner.len()});wait()}}", "invalidated borrow"), + ("owner-first", "fn main(){owner:=[1,2].to_array();view:=owner[..];task_group{spawn(fn{print(owner.len());view[0]});wait()}}", "invalidated borrow"), + ("nested-alias", "Row{values:array}\nfn main(){owner:=Row{values:[1,2].to_array()};view:=owner.values[..];task_group{spawn(fn{owner});print(view[0]);wait()}}", "invalidated borrow"), + ("twice", "fn main(){owner:=\"owned\".clone();task_group{spawn(fn{owner.len()});spawn(fn{owner.len()});wait()}}", "moved"), + ("after-wait", "fn main(){owner:=\"owned\".clone();task_group{spawn(fn{owner.len()});wait()};print(owner)}", "moved"), + ("borrowed", "fn run(borrow owner:buffer){task_group{spawn(fn{owner.len()});wait()}}\nfn main(){}", "borrowed parameter"), + ("exclusive", "fn run(borrow mut owner:buffer){task_group{spawn(fn{owner.len()});wait()}}\nfn main(){}", "borrowed parameter"), + ("arena", "fn main(){arena{owner:=[1,2].to_array();task_group{spawn(fn{owner.len()});wait()}}}", "arena-owned"), + ("fixed-owned", "fn main(){owner:=[\"owned\".clone()];task_group{spawn(fn{owner.len()});wait()}}", "non-transferable owned"), + ("stored", "fn main(){owner:=\"owned\".clone();f:=fn{owner.len()};print(f())}", "a lambda cannot capture"), + ("nested-stored", "fn main(){owner:=\"owned\".clone();task_group{spawn(fn{local:=owner;f:=fn{local.len()};f()});wait()}}", "a lambda cannot capture"), + ("pipeline", "fn main(){owner:=\"owned\".clone();task_group{spawn(fn{local:=owner;[1,2].map(fn x {x+local.len()}).sum()});wait()}}", "a lambda cannot capture"), + ("view-graph", "Row{text:string,view:str}\nfn main(){row:=Row{text:\"a\".clone(),view:\"b\"};task_group{spawn(fn{row});wait()}}", "non-transferable owned"), + ("inactive-view", "Choice{Good(string),Bad(str)}\nfn main(){row:=Choice.Good(\"a\".clone());task_group{spawn(fn{match row{Good(v)=>v.len(),Bad(v)=>v.len()}});wait()}}", "non-transferable owned"), + ("returned-view", "fn main(){owner:=\"owned\".clone();task_group{spawn(fn{view:str:=owner;view});wait()}}", "self-contained data"), + ("pending-reader", "fn main(){owner:=[1,2].to_array();view:=owner[..];task_group{spawn(fn{view[0]});spawn(fn{owner.len()});wait()}}", "capture is modified"), + ] { + let path = stage.join(format!("{name}.align")); + std::fs::write(&path, source).unwrap(); + for per_unit in [false, true] { + let mut sources = SourceMap::new(); + let checked = if per_unit { + align_driver::check_per_unit(&mut sources, path.to_str().unwrap(), source).diags + } else { + align_driver::check(&mut sources, path.to_str().unwrap(), source).diags + }; + let diagnostics = align_driver::format_diagnostics(&sources, &checked); + assert!(checked.has_errors() && diagnostics.contains(expected), "{name}/{per_unit}: expected {expected}\n{diagnostics}"); + } + } + }); +} + +const FILE_MODEL: &str = r#"module file_model +pub Window {pack:file,bytes:buffer,count:i64} +pub fn read(value:Window,offset:i64)->Result { + pack:=value.pack + mut bytes:=value.bytes + count:=pack.pread_into(bytes,0,4,offset)? + Ok(Window {pack:pack,bytes:bytes,count:count}) +} +pub fn fail(pack:file)->Result = Err(error(37)) +pub fn identity(value:T)->T=value +"#; + +const FILE_SOURCE: &str = r#"module main +import std.fs +import file_model +extern "C" fn r41_replace_name()->i32 +extern "C" fn r41_descriptor_count()->i32 +extern "C" fn align_rt_requested_live_reset() +extern "C" fn align_rt_requested_live_bytes()->i64 +extern "C" fn align_rt_requested_live_peak()->i64 +fn early(path:str)->Result<(),Error> { + pack:=fs.open_ro(path)? + task_group {spawn(fn {pack});return Err(error(19))} +} +fn failed(path:str)->Result<(),Error> { + first:=fs.open_ro(path)? + second:=fs.open_ro(path)? + task_group {spawn(fn {file_model.fail(first)});spawn(fn {second});wait()?} + Ok(()) +} +fn run()->Result<(),Error> { + admitted:=fs.open_ro("@INPUT@")? + if unsafe {r41_replace_name()}!=0 {return Err(error(1))} + mut window:=file_model.Window {pack:admitted,bytes:buffer(4),count:0} + mut index:=0 + loop { + if index==4 {break} + task_group { + task:=spawn(fn {file_model.read(window,index*4)}) + wait().map_err(fn e:Error {e})? + window=task.get() + } + expected:=match index {0=>4,1=>4,2=>2,_=>0} + if window.count!=expected {return Err(error(2))} + if index<3 && window.bytes.bytes().u8(0)!=(97+index*4) as u8 {return Err(error(3))} + index=index+1 + } + // Return the same descriptor directly, then transfer it through a tagged capture. + pack:=window.pack + returned:=task_group {t:=spawn(fn {pack});wait();t.get()} + tagged:Result:=Ok(returned) + final_file:=task_group {t:=spawn(fn {tagged});wait()?;t.get()} + mut bytes:=buffer(2) + count:=final_file.pread_into(bytes,0,2,0)? + if count!=2 || bytes.bytes().u8(0)!=97 {return Err(error(4))} + match early("@INPUT@") {Err(_)=>{},Ok(_)=>{return Err(error(5))}} + match failed("@INPUT@") {Err(_)=>{},Ok(_)=>{return Err(error(6))}} + task_group { + first:=fs.open_ro("@INPUT@")? + second:=fs.open_ro("@INPUT@")? + spawn(fn {file_model.fail(first)});spawn(fn {second});ignored:=wait();() + } + // Actual pread failure and success cleanup share the ordinary file/buffer owners. + directory:=fs.open_ro("@DIR@")? + input:=file_model.Window {pack:directory,bytes:buffer(4),count:0} + task_group {spawn(fn {file_model.read(input,0)});match wait(){Err(_)=>{},Ok(_)=>{return Err(error(7))}}} + Ok(()) +} +fn main()->i32 { + task_group {spawn(fn {1});wait()} + before:=unsafe {r41_descriptor_count()} + unsafe {align_rt_requested_live_reset()} + match run(){Err(e)=>{match e{Code(c)=>print(c),_=>print(-1)};return 1},Ok(_)=>{}} + if unsafe {r41_descriptor_count()}!=before {return 2} + live:=unsafe {align_rt_requested_live_bytes()} + if live!=0 || unsafe {align_rt_requested_live_peak()}<=0 {print(live);return 3} + 0 +} +"#; + +#[test] +fn file_capture_identity_errors_and_cleanup() { + owned_fixture::run("file_capture_identity_errors_and_cleanup", |stage| { + let result = (|| -> TestResult { + std::fs::write(stage.join("file_model.align"), FILE_MODEL)?; + let input = stage.join("admitted"); + let replacement = stage.join("replacement"); + let source = FILE_SOURCE + .replace("@INPUT@", input.to_str().ok_or("input path")?) + .replace("@DIR@", stage.to_str().ok_or("directory path")?); + // C owns only the test's pathname substitution and fd observation, never task work. + let native_source = stage.join("probe.c"); + std::fs::write(&native_source, format!(r#" +#include +#include +#include +int32_t r41_replace_name(void) {{ return rename({:?}, {:?}); }} +int32_t r41_descriptor_count(void) {{ + int32_t count = 0; + for (int fd = 0; fd < 4096; ++fd) {{ if (fcntl(fd, F_GETFD) >= 0) ++count; }} + return count; +}} +"#, replacement.to_str().ok_or("replacement path")?, input.to_str().ok_or("input path")?))?; + let native = stage.join("probe.o"); + let mut compiler = std::process::Command::new(align_driver::CDriver::default().program()); + compiler.args(["-O2", "-c"]); + if cfg!(target_arch = "x86_64") { compiler.arg("-march=native"); } + if cfg!(target_arch = "aarch64") { compiler.arg("-mcpu=native"); } + let compiled = compiler + .arg(&native_source).arg("-o").arg(&native).output()?; + assert!(compiled.status.success(), "{compiled:?}"); + for per_unit in [false, true] { + let mir = programs(stage, &source, per_unit)?; + std::fs::write(&input, b"abcdefghij")?; + std::fs::write(&replacement, b"ZZ")?; + execute(stage, &mir, &format!("file-{per_unit}"), per_unit, Some(&native))?; + } + let cache = align_driver::CacheContext::at(stage.join("cache")); + for round in 0..2 { + let mut sources = SourceMap::new(); + let mut built = align_driver::build_package(&mut sources, + stage.join("main.align").to_str().ok_or("entry path")?, &source, + &cache, align_driver::UnitReuse::Allowed); + assert!(!built.diags.has_errors(), "{}", align_driver::format_diagnostics(&sources, &built.diags)); + assert!(built.units.iter().all(|unit| unit.frontend.as_ref() + .is_some_and(|frontend| frontend.hit == (round == 1)))); + let mut mir = Vec::new(); + for index in 0..built.units.len() { + mir.push(built.materialize(index).map_err(|error| format!("{error:?}"))?.clone()); + } + std::fs::write(&input, b"abcdefghij")?; + std::fs::write(&replacement, b"ZZ")?; + execute(stage, &mir, &format!("cached-{round}"), false, Some(&native))?; + } + Ok(()) + })(); + assert!(result.is_ok(), "{result:?}"); + }); +} diff --git a/crates/align_mir/src/lib.rs b/crates/align_mir/src/lib.rs index 79cb5158d..e26a97ceb 100644 --- a/crates/align_mir/src/lib.rs +++ b/crates/align_mir/src/lib.rs @@ -1107,13 +1107,12 @@ pub enum Rvalue { ArenaBegin, /// Open a `task_group`; the value is its handle (a `*TaskGroup`). TgBegin, - /// Publish a task (`spawn`): snapshot the closure's captures into a fresh env in the - /// task-group region, allocate the result slot there, and register the task. Yields the slot - /// pointer (the `Task` handle). `tg` = the task-group handle, `closure` = the `{fn, env}` - /// value, `capture_tys` give the env layout (empty = non-capturing), `r` = the result scalar. + /// Transfer captures directly into a fresh task-group environment and publish one invocation. + /// No reusable Fn value carries owned inputs. Yields the result slot (`Task` handle). SpawnTask { tg: Operand, - closure: Operand, + lifted: ProgramCall, + captures: Vec, capture_tys: Vec, r: Ty, fallible: bool, @@ -10625,24 +10624,31 @@ fn lower_expr_recursive(b: &mut Builder, e: &hir::Expr) -> Operand { unreachable!("spawn result is a Task") }; let r_ty = align_sema::scalar_to_ty(s); - let capture_tys: Vec = match &closure.kind { - hir::ExprKind::Closure { captures, .. } => { - captures.iter().map(|c| c.ty).collect() + let (lifted, captures) = match &closure.kind { + hir::ExprKind::Closure { lifted, captures } => (lifted.as_str(), captures.as_slice()), + hir::ExprKind::FnValue(lifted) => (lifted.as_str(), &[][..]), + _ => { + b.terminate(Term::Unreachable); + return Operand::Const(Const::Unit); } - _ => Vec::new(), }; - lower_required_binding!( - b, - clos = lower_expr(b, closure), - Operand::Const(Const::Unit) - ); + let capture_tys = captures.iter().map(|capture| capture.ty).collect(); + let mut operands = Vec::with_capacity(captures.len()); + for capture in captures { + operands.push(lower_required!(b, lower_expr(b, capture), Operand::Const(Const::Unit))); + } + // Snapshot every input before retiring any source; publication may start a worker. + for capture in captures { + null_moved_source(b, capture); + } let tg = Operand::Value(*b.task_groups.last().expect("spawn outside a task_group")); let v = b.fresh_value(e.ty); b.push(Stmt::Let( v, Rvalue::SpawnTask { tg, - closure: clos, + lifted: ProgramCall::from_validated(lifted), + captures: operands, capture_tys, r: r_ty, fallible: *fallible, diff --git a/crates/align_mir/src/print.rs b/crates/align_mir/src/print.rs index e2f4d4d56..e7dd3fcd6 100644 --- a/crates/align_mir/src/print.rs +++ b/crates/align_mir/src/print.rs @@ -403,8 +403,9 @@ fn rvalue_str(rv: &Rvalue) -> String { } Rvalue::ArenaBegin => "arena_begin".to_string(), Rvalue::TgBegin => "tg_begin".to_string(), - Rvalue::SpawnTask { closure, fallible, .. } => { - format!("spawn_task{} {}", if *fallible { " fallible" } else { "" }, operand_str(closure)) + Rvalue::SpawnTask { lifted, captures, fallible, .. } => { + format!("spawn_task{} {}({})", if *fallible { " fallible" } else { "" }, lifted, + captures.iter().map(operand_str).collect::>().join(", ")) } Rvalue::TgWaitResult { tg, fallible } => { format!("tg_wait{} {}", if *fallible { " fallible" } else { "" }, operand_str(tg)) diff --git a/crates/align_mir/src/validate_hir.rs b/crates/align_mir/src/validate_hir.rs index d3cf885db..9b194af51 100644 --- a/crates/align_mir/src/validate_hir.rs +++ b/crates/align_mir/src/validate_hir.rs @@ -13283,7 +13283,13 @@ impl<'a> BodyValidator<'a> { .zip(captures) { let Some(flow) = self.expr_flow(capture) else { return false }; - if !self.body_ty_matches(flow.ty, *expected) || !self.ty_copy_ok(flow.ty, context) { + let owned = spawn.is_some() && align_sema::task_owned_capture_type( + flow.ty, &self.program.structs, &self.program.tuples, + &self.program.enums, &self.program.tagged_types, + ); + if !self.body_ty_matches(flow.ty, *expected) + || !(self.ty_copy_ok(flow.ty, context) || (owned && self.body_ty_ok(flow.ty))) + { return false; } } diff --git a/crates/align_mir/src/validate_hir_tests.rs b/crates/align_mir/src/validate_hir_tests.rs index fb80f2f75..659e4c7e7 100644 --- a/crates/align_mir/src/validate_hir_tests.rs +++ b/crates/align_mir/src/validate_hir_tests.rs @@ -21940,6 +21940,46 @@ fn deflate_codec_hir_signature_matrix() -> Result<(), String> { Ok(()) } +#[test] +fn task_owned_capture_hir_consumption_is_replayed() -> Result<(), String> { + let base = checked_source_program("fn main(){owner:=\"owned\".clone();task_group{task:=spawn(fn{owner});wait();task.get();()};()}\n"); + for per_unit in [false, true] { + lower_program_checked(&base, per_unit, None).map_err(|error| format!("{error:?}"))?; + } + for mutation in 0..4 { + let mut bad = base.clone(); + let main = bad.fns.iter_mut().find(|f| f.name == "main").ok_or("main")?; + let group = main.body.stmts.iter_mut().find_map(|stmt| match stmt { + hir::Stmt::Expr(hir::Expr { kind: hir::ExprKind::TaskGroup(group), .. }) => Some(group), + _ => None, + }).ok_or("group")?; + let spawn = group.stmts.iter().find_map(|stmt| match stmt { + hir::Stmt::Let { init, .. } if matches!(init.kind, hir::ExprKind::Spawn { .. }) => Some(init.clone()), + _ => None, + }).ok_or("spawn")?; + let hir::ExprKind::Spawn { closure, .. } = &spawn.kind else { return Err("spawn kind".into()) }; + let hir::ExprKind::Closure { captures, .. } = &closure.kind else { return Err("closure".into()) }; + match mutation { + 0 => group.stmts.insert(1, hir::Stmt::Expr(captures.first().ok_or("capture")?.clone())), + 1 => group.stmts.insert(1, hir::Stmt::Expr(spawn)), + 2 => group.stmts.insert(0, hir::Stmt::Expr(*closure.clone())), + _ => { + let statement = group.stmts.iter_mut().find_map(|stmt| match stmt { + hir::Stmt::Let { init, .. } if matches!(init.kind, hir::ExprKind::Spawn { .. }) => Some(init), + _ => None, + }).ok_or("spawn mutation")?; + let hir::ExprKind::Spawn { closure, .. } = &mut statement.kind else { return Err("spawn kind".into()) }; + let hir::ExprKind::Closure { captures, .. } = &mut closure.kind else { return Err("closure".into()) }; + captures.first_mut().ok_or("capture")?.ty = Ty::Bool; + } + } + for per_unit in [false, true] { + assert!(lower_program_checked(&bad, per_unit, None).is_err(), "{mutation}/{per_unit}"); + } + } + Ok(()) +} + #[test] fn task_owned_hir_wait_move_and_payload_replay() -> Result<(), String> { let valid=checked_source_program("fn main() {task_group {t:=spawn(fn {\"owned\".clone()});wait();t.get();()};()}\n"); diff --git a/crates/align_sema/src/callback_access.rs b/crates/align_sema/src/callback_access.rs index fac0d9ca3..644a16634 100644 --- a/crates/align_sema/src/callback_access.rs +++ b/crates/align_sema/src/callback_access.rs @@ -940,6 +940,12 @@ impl MoveCheck<'_> { self.check_callback_requirements(lifted, captures, closure.span); let mut roots = BorrowRoots::new(); let mut map = |root: &hir::MutableAccessRoot| -> BorrowRoots { + let (hir::MutableAccessRoot::Contained(index) | hir::MutableAccessRoot::Storage(index)) = root; + if captures.get(*index as usize).is_some_and(|capture| super::task_owned_capture_type( + capture.ty, self.structs, self.tuples, self.enums, self.tagged_types, + )) { + return BorrowRoots::new(); + } match root { hir::MutableAccessRoot::Contained(i) => captures .get(*i as usize) diff --git a/crates/align_sema/src/lib.rs b/crates/align_sema/src/lib.rs index 017cb8067..c4828233f 100644 --- a/crates/align_sema/src/lib.rs +++ b/crates/align_sema/src/lib.rs @@ -4901,7 +4901,7 @@ fn independent_builder_read( } /// A task result owns its complete graph independently of the worker and group. -/// Dynamic headers break layout cycles; an inline cycle or any borrowed/native authority rejects. +/// Dynamic headers break layout cycles; borrowed authority and non-transferable native owners reject. pub fn task_result_type( ty: Ty, structs: &[StructDef], @@ -4911,9 +4911,34 @@ pub fn task_result_type( ) -> bool { if !matches!(ty, Ty::Int(_) | Ty::Float(_) | Ty::Bool | Ty::Char | Ty::Unit | Ty::Struct(_) | Ty::Tuple(_) | Ty::Enum(_) | Ty::String | Ty::Buffer - | Ty::DynArray(_) | Ty::DynStructArray(_, Layout::Aos)) || ty_to_scalar(ty).is_none() { + | Ty::DynArray(_) | Ty::DynStructArray(_, Layout::Aos) | Ty::File) || ty_to_scalar(ty).is_none() { return false; } + task_transfer_graph(ty, structs, tuples, enums, tagged_types) +} + +/// An owning task input must have ordinary Move transport and a self-contained worker-safe graph. +/// Capture roots may include existing Option/Result carriers; result roots have a separate rule. +pub fn task_owned_capture_type( + ty: Ty, + structs: &[StructDef], + tuples: &[hir::TupleDef], + enums: &[hir::EnumDef], + tagged_types: &[hir::TaggedType], +) -> bool { + !matches!(ty, Ty::Array(..) | Ty::StructArray(..)) + && ty_is_move(ty, structs, tuples, enums, tagged_types) + && needs_drop_flag(ty, structs, tuples, enums, tagged_types) + && task_transfer_graph(ty, structs, tuples, enums, tagged_types) +} + +fn task_transfer_graph( + ty: Ty, + structs: &[StructDef], + tuples: &[hir::TupleDef], + enums: &[hir::EnumDef], + tagged_types: &[hir::TaggedType], +) -> bool { // First inspect the complete reachable graph, including pointees behind dynamic headers. // Inline-cycle validation is separate: memoizing a node reached through a header can hide // an inline cycle when that same node is also reached by an inline sibling edge. @@ -4924,7 +4949,7 @@ pub fn task_result_type( let mut children = Vec::new(); let mut indirect = false; match ty { - _ if independent_copy_leaf(ty) || matches!(ty, Ty::String | Ty::Buffer) => {}, + _ if independent_copy_leaf(ty) || matches!(ty, Ty::String | Ty::Buffer | Ty::File) => {}, Ty::Struct(id) => { let Some(record) = structs.get(id as usize) else { return false }; children.extend(record.fields.iter().map(|field| field.ty)); @@ -29788,6 +29813,17 @@ impl<'a> EscapeCheck<'a> { fn check_spawn_capture(&mut self, closure: &Expr, group: Region, depth: u32) { let check = |this: &mut Self, capture: &Expr| { + if task_owned_capture_type( + capture.ty, this.structs, this.tuples, this.enums, this.tagged_types, + ) { + if this.call_transfer_contains_arena_owned(capture, depth) { + this.diags.error( + "an arena-owned value cannot be moved into a spawned task".to_string(), + capture.span, + ); + } + return; + } if capture.ty == Ty::ArenaHandle { this.diags.error( "a spawned task cannot capture a region capability (arena allocation is lexical and non-Send)" @@ -42364,6 +42400,11 @@ impl<'a> MoveCheck<'a> { let mut value = MoveValueFact::default(); if let ExprKind::Closure { captures, .. } = &closure.kind { for capture in captures { + if task_owned_capture_type( + capture.ty, self.structs, self.tuples, self.enums, self.tagged_types, + ) { + continue; + } // Inline scalar arrays and ordinary scalar/record copies own their snapshot. // Only values containing borrowed backing retain external storage protection. if ty_may_borrow(capture.ty, self.structs, self.tuples, self.enums, self.tagged_types) { @@ -47268,9 +47309,12 @@ impl<'a> MoveCheck<'a> { | ExprKind::ResultOk(value) | ExprKind::ResultErr(value) => vec![value], ExprKind::EnumValue { payload, .. } => payload.iter().collect(), - // Closure captures are Copy aliases. They participate in completion/header staging but - // never null their source binding. + // Stored closures only read captures. The direct Spawn owns their transfer. ExprKind::Closure { .. } => Vec::new(), + ExprKind::Spawn { closure, .. } => match &closure.kind { + ExprKind::Closure { captures, .. } => captures.iter().collect(), + _ => Vec::new(), + }, _ => Vec::new(), } } @@ -47673,6 +47717,16 @@ impl<'a> MoveCheck<'a> { self.finish_successful_aggregate_action(expression, moved); } ExprKind::Spawn { closure, .. } => { + if let ExprKind::Closure { captures, .. } = &closure.kind { + self.finish_successful_call_arguments(captures, None, false, moved); + // Moving into a worker ends caller backing authority, including aliases + // captured by this same task. Check both capture orders after all transfers. + for capture in captures { + if !self.is_move_ty(capture.ty) { + self.validate_pipeline_capture_use(capture); + } + } + } self.register_task_capture(expression, closure); self.parallel_transfer_roots.extend(self.borrows.expand_byte_validation_roots(self.borrow_sources(closure))); let required = self @@ -47933,6 +47987,7 @@ impl<'a> MoveCheck<'a> { | ExprKind::LogNew { .. } | ExprKind::XmlParse { .. } | ExprKind::Closure { .. } + | ExprKind::Spawn { .. } ) } @@ -55659,7 +55714,7 @@ impl<'a, 't> Checker<'a, 't> { } // Lift with the annotated parameter types as the expected signature; the return type is // inferred from the body. - let Some((name, ret, captures)) = self.lift_lambda(params, body, ¶m_tys, None, span) else { + let Some((name, ret, captures)) = self.lift_lambda(params, body, ¶m_tys, None, span, false) else { return err; }; // A type error in the annotations or the body has already been reported — don't pile on a @@ -55756,10 +55811,10 @@ impl<'a, 't> Checker<'a, 't> { self.diags.error("a spawned task takes no parameters (`spawn(fn { … })`)".to_string(), arg.span); return err; } - let Some((name, ret, captures)) = self.lift_lambda(params, body, &[], None, arg.span) else { + let Some((name, ret, captures)) = self.lift_lambda(params, body, &[], None, arg.span, true) else { return err; }; - // The group owns the slot, but the copied result retains no group/capture authority. + // The group owns the slot, but the result retains no group/capture authority. // Fallible tasks initialize it only on Ok; the existing wait proof gates every read. let result = expand_tagged_ty(self.finalize(ret), self.tagged_types); let admitted = |scalar| task_result_type( @@ -55780,9 +55835,9 @@ impl<'a, 't> Checker<'a, 't> { } }, }; - // The closure value (the `{thunk, env}` machinery is reused as-is; the lifted function may - // return `Result` — the thunk just forwards it). Its `Ty::Fn` tag uses the `ok` scalar as - // the return (a repr-only tag — a closure value is a pointer pair regardless). + // This HIR literal belongs only to Spawn; MIR transports its target/captures directly. + // Its Fn tag names the ok scalar, while the lifted function and generated thunk retain + // the actual return type, including Result and its cleanup ABI. let fid = fresh_fn_type(self.fn_types, Vec::new(), scalar_to_ty(ok), FnEffect::Unknown); let cty = Ty::Fn(fid); let closure = if captures.is_empty() { @@ -56561,6 +56616,7 @@ impl<'a, 't> Checker<'a, 't> { &[callback_param], Some(callback_ret), argument.span, + false, ) else { return err(); }; @@ -61973,7 +62029,7 @@ impl<'a, 't> Checker<'a, 't> { } StageFn::Lambda { params, body, span } => { let expected_ret = if is_pred { Some(Ty::Bool) } else { None }; - let lifted = self.lift_lambda(params, body, &[elem], expected_ret, *span)?; + let lifted = self.lift_lambda(params, body, &[elem], expected_ret, *span, false)?; (lifted.1 != Ty::Error).then_some(lifted) } } @@ -61987,9 +62043,8 @@ impl<'a, 't> Checker<'a, 't> { /// analysis and codegen treat it exactly like a named function — including the `par_map` Pure /// requirement. The fused-loop lowering is then identical to a named stage function. /// - /// Slice ① cut: **non-capturing** only — the lambda body sees its parameters and top-level - /// functions, but not enclosing locals (capturing those is a follow-up; such a reference - /// surfaces as an undefined-variable error here). + /// Ordinary lambdas capture Copy values only. Spawn explicitly admits transferable owned + /// captures for this one lift; nested ordinary lambdas retain their own Copy-only mode. fn lift_lambda( &mut self, params: &[ast::LambdaParam], @@ -61997,6 +62052,7 @@ impl<'a, 't> Checker<'a, 't> { expected_params: &[Ty], expected_ret: Option, span: Span, + owned_captures: bool, ) -> Option<(String, Ty, Vec)> { if params.len() != expected_params.len() { self.diags.error( @@ -62154,11 +62210,17 @@ impl<'a, 't> Checker<'a, 't> { let mut capture_ops = Vec::new(); for (cname, pid, enc_id) in &captured { let ty = locals[*pid as usize].ty; - if ty_capture_is_move(ty, self.structs, self.tuples, self.enums, self.tagged_types) { - self.diags.error( - format!("a lambda cannot capture the owned value '{cname}' yet (capture supports copy values like int/float/bool/char)"), - span, - ); + if ty_capture_is_move(ty, self.structs, self.tuples, self.enums, self.tagged_types) + && !(owned_captures && task_owned_capture_type( + ty, self.structs, self.tuples, self.enums, self.tagged_types, + )) + { + let message = if owned_captures { + format!("a spawned task cannot capture non-transferable owned value '{cname}'") + } else { + format!("a lambda cannot capture the owned value '{cname}' yet (capture supports copy values like int/float/bool/char)") + }; + self.diags.error(message, span); } param_ids.push(*pid); capture_ops.push(Expr { kind: ExprKind::Local(*enc_id), ty, span }); @@ -62256,7 +62318,7 @@ impl<'a, 't> Checker<'a, 't> { /// types are known after `check_pipeline`/the initial value). fn resolve_fn(&mut self, arg: &ast::Expr, expected_params: &[Ty], expected_ret: Option, label: &str, span: Span) -> Option<(String, Ty, Vec)> { if let ast::ExprKind::Lambda { params, body } = &arg.kind { - let lifted = self.lift_lambda(params, body, expected_params, expected_ret, arg.span)?; + let lifted = self.lift_lambda(params, body, expected_params, expected_ret, arg.span, false)?; return (lifted.1 != Ty::Error).then_some(lifted); } let Some(named) = self.pipeline_fn_name(arg) else { @@ -93986,6 +94048,23 @@ fn exit_branch(flag: bool) -> i64 { } } + #[test] + fn task_owned_transfer_roots_keep_native_and_arena_boundaries() { + for ty in [Ty::String, Ty::Buffer, Ty::File, + Ty::Option(Scalar::Buffer), Ty::Result(Scalar::File, Scalar::Bool)] + { + assert!(task_owned_capture_type(ty, &[], &[], &[], &[]), "{ty:?}"); + assert!(ty_capture_is_move(ty, &[], &[], &[], &[])); + } + for ty in [Ty::Raw, Ty::ArenaHandle, Ty::HttpServer, Ty::ProcessSignalSubscription, + Ty::Array(Scalar::String, 2), Ty::Option(Scalar::Str)] + { + assert!(!task_owned_capture_type(ty, &[], &[], &[], &[]), "{ty:?}"); + } + assert!(task_result_type(Ty::File, &[], &[], &[], &[])); + assert!(!task_result_type(Ty::Option(Scalar::Buffer), &[], &[], &[], &[])); + } + #[test] fn fixed_move_struct_array_cannot_be_captured_by_value() { let src = diff --git a/docs/design-notes.md b/docs/design-notes.md index 6fe64e7fb..80e8a47cc 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -871,6 +871,16 @@ cleanup for untaken results across waits. One canonical Drop plan handles both o and every error path joins before cleanup. This completes the reserved result lifecycle without introducing a second parallel model or hidden output copies. +Owned structured-task inputs ([Plan259](impl/259-task-owned-captures.md)) use the same +by-value ownership rule as ordinary calls. A literal task runs once, so it can consume a +self-contained owner; a repeatedly called function value cannot. MIR therefore publishes the +lifted target and capture operands directly, without a reusable owning Fn intermediate. +The transfer ends caller backing authority before a worker can run. Ordinary parameter Drop +and owned-result cleanup cover the two sides of the call. The existing positional file handle +has no borrowed variant or hidden cursor, so moving it preserves admitted source identity; +returning it permits bounded read-window reuse. This does not make arbitrary native owners, +GPU resources or process-global signal leases transferable, and it claims no startup speedup. + ## The loop philosophy Align has exactly one loop construct, and it is deliberately narrow: `loop { ... break value }`. diff --git a/docs/guide/10-closures-and-parallelism.md b/docs/guide/10-closures-and-parallelism.md index 073c15da2..db4cdb902 100644 --- a/docs/guide/10-closures-and-parallelism.md +++ b/docs/guide/10-closures-and-parallelism.md @@ -105,7 +105,41 @@ pool can delay its start. A captured view shares its original bytes: keep that b and alive until `wait`. Dropping or reassigning the task handle does not end this protection. Tasks may read shared views and modify storage they create themselves. -Unlike `par_map`, spawned tasks may perform I/O and other impure work. `wait()` also helps execute its group's pending tasks, allowing nested groups to progress even when every worker is occupied. It cannot resolve a blocking cycle created by the program. Task results may be self-contained primitive values, strings, buffers, ordinary dynamic arrays, records, tuples or sums, optionally through `Result`. They may contain owned data but cannot contain views, pointers, callables or native/resource handles. After a successful wait, Copy `get()` may repeat; owned `get()` consumes the handle and transfers the result once. Results can outlive the group. Group exit joins all tasks and drops untaken results, including after a failed wait. Shared captures remain read-only. For contiguous output, create one owned band per task and collect the named handles in band order. +Unlike `par_map`, spawned tasks may perform I/O and other impure work. `wait()` also helps execute its group's pending tasks, allowing nested groups to progress even when every worker is occupied. It cannot resolve a blocking cycle created by the program. Task results may be self-contained primitive values, strings, buffers, files, ordinary dynamic arrays, records, tuples or sums, optionally through `Result`. They may contain owned data but cannot contain views, pointers, callables or native/resource handles other than `file`. After a successful wait, Copy `get()` may repeat; owned `get()` consumes the handle and transfers the result once. Results can outlive the group. Group exit joins all tasks and drops untaken results, including after a failed wait. Shared captures remain read-only. For contiguous output, create one owned band per task and collect the named handles in band order. + +A task literal may also move a self-contained owned value into its worker; bare fixed arrays of owned elements remain excluded. The original +binding and views into its backing cannot be used afterward. The worker may mutate its own +storage or return it; inputs it does not return are dropped when the worker finishes. +Borrowed parameters and arena-owned storage cannot transfer this way. Ordinary stored +closures and pipeline lambdas still capture Copy values only. + +An already-open `file` can move into a task without reopening its path: + +```align +import std.fs + +fn read_window(pack: file) -> Result { + mut window := buffer(4096) + pack.pread_into(window, 0, 4096, 0)? + Ok(window) +} + +fn main() -> Result<(), Error> { + pack := fs.open_ro("model.pack")? + data := task_group { + task := spawn(fn { read_window(pack) }) + wait()? + task.get() + } + print(data.len()) + Ok(()) +} +``` + +The same file owner may be returned directly or in a record alongside a filled buffer, +using the ordinary consuming `get()`. This permits bounded window reuse while retaining +file identity. GPU resources and signal subscriptions remain confined to their owning thread; +exclusive-borrow capture is not supported. Tasks that can fail return `Result`. Propagate a group failure with `wait()?`: diff --git a/docs/guide/ja/10-closures-and-parallelism.md b/docs/guide/ja/10-closures-and-parallelism.md index 2d7db3acc..67452186e 100644 --- a/docs/guide/ja/10-closures-and-parallelism.md +++ b/docs/guide/ja/10-closures-and-parallelism.md @@ -102,7 +102,34 @@ fn main() -> Result<(), Error> { タスクが1つだけでも、`spawn` が戻る前にワーカーが実行できる状態になります。ただし、ワーカーが使用中なら開始は遅れます。キャプチャしたビューは元のデータを共有するため、`wait` まで元のデータを変更・解放できません。タスクハンドルを捨てたり再代入したりしても、この保護は続きます。タスクは共有ビューを読み、自分で作ったデータを変更できます。 -`par_map` と異なり、spawn したタスクは I/O などの非純粋な処理を実行できます。`wait()` も自分のグループの未実行タスクを処理するため、すべてのワーカーが使用中でも入れ子のグループが進行できます。ただし、プログラムが作った循環した待ち合わせを解消するものではありません。タスクの結果には、外部の記憶域を借用しないプリミティブ値、string、buffer、通常の動的配列、レコード、タプル、sum 型を使えます。`Result` で包むこともできます。所有データを含められますが、ビュー、ポインタ、関数値、ネイティブハンドルや resource は含められません。wait の成功後、Copy 値の `get()` は繰り返し呼べます。所有値の `get()` はハンドルを消費し、結果を一度だけ移します。結果はグループ終了後も使えます。グループ終了時には全タスクを待ち、wait が失敗した場合も未取得の結果を解放します。共有キャプチャは引き続き読み取り専用です。連続した出力を並列生成するには、各タスクで区間ごとの所有値を作り、名前を付けたハンドルを区間順に取得します。 +`par_map` と異なり、spawn したタスクは I/O などの非純粋な処理を実行できます。`wait()` も自分のグループの未実行タスクを処理するため、すべてのワーカーが使用中でも入れ子のグループが進行できます。ただし、プログラムが作った循環した待ち合わせを解消するものではありません。タスクの結果には、外部の記憶域を借用しないプリミティブ値、string、buffer、file、通常の動的配列、レコード、タプル、sum 型を使えます。`Result` で包むこともできます。所有データを含められますが、ビュー、ポインタ、関数値、file 以外のネイティブハンドルや resource は含められません。wait の成功後、Copy 値の `get()` は繰り返し呼べます。所有値の `get()` はハンドルを消費し、結果を一度だけ移します。結果はグループ終了後も使えます。グループ終了時には全タスクを待ち、wait が失敗した場合も未取得の結果を解放します。共有キャプチャは引き続き読み取り専用です。連続した出力を並列生成するには、各タスクで区間ごとの所有値を作り、名前を付けたハンドルを区間順に取得します。 + +タスクのリテラルには、外部の記憶域を借用しない所有値も移せます。ただし、所有要素の固定長配列を単独でキャプチャすることはできません。移送後は元の変数や、その記憶域を指すビューを使えません。ワーカーは自分が所有する記憶域を変更したり、所有値を結果として返したりできます。返さない入力はワーカーの終了時に解放します。借用パラメータや arena 所有の記憶域は移せません。通常の関数値やパイプラインのラムダは、引き続き Copy 値だけをキャプチャします。 + +すでに開いた `file` は、パスを開き直さずにタスクへ移せます。 + +```align +import std.fs + +fn read_window(pack: file) -> Result { + mut window := buffer(4096) + pack.pread_into(window, 0, 4096, 0)? + Ok(window) +} + +fn main() -> Result<(), Error> { + pack := fs.open_ro("model.pack")? + data := task_group { + task := spawn(fn { read_window(pack) }) + wait()? + task.get() + } + print(data.len()) + Ok(()) +} +``` + +同じファイル所有者を直接返すことも、読み込んだ buffer と一緒にレコードで返すこともできます。通常の所有値と同じく、`get()` で一度だけ受け取ります。ファイルの同一性を保ちながら、サイズを制限した読み取りバッファを再利用できます。GPU resource や signal subscription は引き続き所有スレッドに限定され、排他的借用のキャプチャには対応しません。 失敗する可能性があるタスクは `Result` を返します。グループのエラーを伝播させるには `wait()?` と書きます。 diff --git a/docs/impl/180-structured-task-progress.md b/docs/impl/180-structured-task-progress.md index a5f708bdd..8ab518355 100644 --- a/docs/impl/180-structured-task-progress.md +++ b/docs/impl/180-structured-task-progress.md @@ -1,5 +1,9 @@ # Structured task progress during caller work +[Plan259](259-task-owned-captures.md) extends this contract with self-contained +owned captures and file-bearing task results. Its input-transfer ledger supersedes +the earlier Copy-only capture and blanket native-result exclusions below. + [Plan228](228-task-owned-results.md) extends this foundation with self-contained owned task results and conditional untaken cleanup; capture and wait authority remain unchanged. Its ledger supersedes the Copy-only result limit below. diff --git a/docs/impl/19-hir-validation-ledger.md b/docs/impl/19-hir-validation-ledger.md index 5f14d7aee..8662d25a5 100644 --- a/docs/impl/19-hir-validation-ledger.md +++ b/docs/impl/19-hir-validation-ledger.md @@ -1035,13 +1035,13 @@ The result formula in every row is followed by the universal | `MathOp` | `env[fn_,operands.len]`; `child[operands in order]`; `post[the exact MathFn row above; copy]`. | | `FnValue` | `env[name]`: non-empty NUL-free exact bytes and one non-extern `SIG(name)`. Outside `SPAWN`, the target is stored `Source`, `Monomorph`, or `Lifted { capture_count: 0 }`, or imported; its exact concrete scalar modes/parameters/return map to the in-range stored `FnTy`. In `SPAWN(fallible,ok)`, the target is stored `Lifted { capture_count: 0 }` and obeys the contextual signature rule above. A local binding may receive a fresh `FnTy` ordinal; its initializer and local type correlate by `FABI`, while effect/borrow/region cells remain am-b4 facts. `child[]; post[result(Fn(id)),copy; callable fact belongs to am-b3 and is consumed by am-c]`. | | `SqliteCallbackDescriptor` | `env[kind,target,descriptor_id,signature,effect,return_borrow,return_region,return_cleanup,parallel_transfer_params,family_version]`: `kind` is exactly ScalarFunction; `target` is one non-extern stored/imported Source, Monomorph, or `Lifted { capture_count: 0 }` declaration with nonempty NUL-free encoded identity and no open/dynamic target set; `family_version == 1`; every stored signature/effect/return/parallel-transfer fact exactly equals that declaration's complete validated fact. `parallel_transfer_params` is the recomputed canonical sorted unique in-range borrow-capable root set produced through direct/concrete-function-value calls, with every compatible argument/capture selected for an unresolved indirect target; callback parameter 0 is absent. ScalarFunction requires exact `fn(pkg.db.sqlite.function_args) -> Result` and effect Pure or Impure. `descriptor_id` is the canonical nominal identity over kind/version/target/signature/effect/return/parallel-transfer facts. `child[]; post[result exact nominal pkg.db.sqlite.scalar_function, copy static descriptor; no FnValue/Closure/environment is formed; callable fact is consumed by generated-callback preflight]`. | -| `Closure` | `env[lifted,captures.len]`: NUL-free `lifted` resolves exactly one stored function with `FnOrigin::Lifted { capture_count }`, `capture_count as usize == captures.len()`, and `capture_count > 0`; it is therefore non-exportable. `child[captures in order]`; `post[capture types equal the lifted trailing parameter types; outside SPAWN, explicit parameter modes/types and return equal result FnTy; in SPAWN, the contextual signature rule above applies; every capture is Copy and borrowed into one non-escaping environment; callable fact belongs to am-b3]`. | +| `Closure` | `env[lifted,captures.len]`: NUL-free `lifted` resolves exactly one stored function with `FnOrigin::Lifted { capture_count }`, `capture_count as usize == captures.len()`, and `capture_count > 0`; it is therefore non-exportable. `child[captures in order]`; `post[capture types equal the lifted trailing parameter types; outside SPAWN, explicit parameter modes/types and return equal result FnTy; in SPAWN, the contextual signature rule above applies; outside SPAWN every capture is Copy; in SPAWN an ordinary Move capture must satisfy the shared task_owned_capture_type complete graph (Plan259); source ownership, arena refusal and caller backing expiry are independently replayed; callable fact belongs to am-b3]`. | | `CallFnValue` | `env[args.len]`; `child[callee,args in order]`; `post[callee.ty == Fn(id); argument count/modes/types equal FT(id); ByValue Move arguments are consumed, Out is a writable Slice place, and each Borrow/BorrowMut argument follows the enabled stable-place contract; an immediate BorrowedIndex is valid only at a Borrow position, snapshots its root before its index child, emits bounds at that argument position only on index fallthrough, and revalidates the root after every later fallthrough argument at the call action; an overlapping invalidation rejects; a terminating index has no guard/descriptor/later argument/action, and a terminating later argument has no pointer/action; result == FT(id).ret; return and BorrowMut-retention provenance map through exact actuals, with joined/unresolved fallback preserved]`. | | `TaskGroup` | `env[]`; `child[block]`; `post[result == block/context-selected divergence type; one structured task region; all spawned tasks are joined exactly once on every fallthrough/exit path]`. | | `EnumValue` | `env[enum_id,variant,payload.len]`: in-range enum/variant and exact payload arity. `child[payload in order]`; `post[each payload type equals its declared ordinal; result Enum(enum_id); active Move payload transfers once]`. | | `Match` | `env[arms.len,borrowed_place]`: arms are non-empty. `borrowed_place` is absent for the existing owning path, or exactly `{ root_local, root_struct_path, sum_ty, mode, owner_fact }` for a stable place with a direct shared/exclusive borrow fact. The local ordinal and struct-field path identify the complete scrutinee place; a descendant borrow fact never promotes an owning parent. `owner_fact` is a sorted vector of live `BorrowRootFact { kind, ordinal, path }` records for that same place, with only `Local`, `Param`, or `ParamStorage` kinds; ended roots, iteration temporaries, spans, pointers, and process-local hashes reject. The validator independently replays the producer borrow/move flow and compares the complete record, including mode, generation, path, payload eligibility, and cleanup-local exclusion, rather than trusting `borrowed_place`. The sum type is one user `Enum(id)`, `Option(T)`, or `Result(T,E)`, and its reachable payload types satisfy the settled borrowed-projection admissibility grammar when `borrowed_place` is present: Copy scalars/views, `string`, ordinary `DynArray` and AoS `DynStructArray`, and finite acyclic structs/tags recursively. Array elements satisfy the same closed grammar; fixed and specialized arrays, tuples, other collections, resources, and opaque Move shapes reject. A borrowed arm binding cannot be used as a nested borrowed scrutinee; this is an explicit ownership diagnostic, not an owning fallback. `child[scrutinee,arms in order]`; `post[scrutinee uses the exact sum table; each MatchArm uses that table; no tag repeats across arms, at most one wildcard occurs at its preserved source position, and coverage is exhaustive by all tags or that wildcard; all fallthrough arm bodies have one result type under the structural body-type relation (including recursively matching fresh `FnTy` ids), while divergent arms are context-polymorphic; scrutinee evaluated once; owning or borrowed branch ownership joins exactly]`. | | `ResultMapErr` | `env[]`; `child[result,f]`; `post[result.ty == Result(ok,err); f.ty == Fn(id) with one ByValue err parameter and return err2; FT summary/effect apply; result Result(ok,err2); Ok ownership passes unchanged and Err ownership transfers through f]`. | -| `Spawn` | `env[fallible]`; `child[closure with SPAWN(fallible,ok)]`; `post[inside task_group; ok satisfies the shared task_result_type self-contained data graph (Plan228), including all inactive/zero-length payloads; closure.ty == Fn(id) with zero explicit parameters and stored return ok; Pure/Impure allowed by current task rule; the exact lifted target returns ok when false or Result(ok,builtin Error) when true; result Task(ok); closure/environment transfers to task storage]`. | +| `Spawn` | `env[fallible]`; `child[closure with SPAWN(fallible,ok)]`; `post[inside task_group; ok satisfies the shared task_result_type self-contained graph including file (Plans228/259), including all inactive/zero-length payloads; closure.ty == Fn(id) with zero explicit parameters and stored return ok; Pure/Impure allowed by current task rule; the exact lifted target returns ok when false or Result(ok,builtin Error) when true; result Task(ok); direct lifted target and capture operands transfer to task storage; no reusable owning MIR Fn value]`. | | `TaskGet` | `env[]`; `child[task]`; `post[task.ty == Task(T) for the same complete self-contained result domain as Spawn; task has recomputed `TaskProof { group, born_generation }`; that group remains active, `born_generation >= valid_from`, and `completed_generation == Some(current_generation)` on this exact path; that completion proves every Wait registered before its establishing success resolved Ok, while a later no-task unresolved Wait is irrelevant; an inner group's Wait cannot discharge it; failed-check diagnostic uses that group's fallibility; Copy T is copied without consuming the Move task handle and repeated get retains TaskProof; owned T consumes the handle exactly once and lowers to effectful TaskTake, which clears slot ownership at extraction; result content/storage/borrow provenance is independent of the group and captures, while child evaluation and TaskProof checks remain]`. | | `Wait` | `env[]`; `child[]`; `post[inside task_group; result Unit for infallible group or Result(Unit,builtin Error) for a fallible group exactly as producer records; joins registered tasks once]`. | | `Call` | `env[func,type_args]`: `print` is a declaration-free core builtin with no type arguments, exactly one printable HIR argument (`Int`, `Float`, `Bool`, `Char`, or `Str`; source `String` is already a `StrBorrow`), and result `Unit`; `hash64` and `hash128` are declaration-free core builtins with no type arguments, exactly one `Str` or `slice` argument, and result respectively `u64` or `(u64,u64)`. Otherwise non-empty NUL-free func resolves one `SIG`; an extern target requires current lexical `unsafe_depth > 0`. Empty type_args require a non-Monomorph target. Non-empty type_args are concrete graph-valid types; encoding them with the single producer/validator-owned `mangle_mono_suffix(type_args)` (the current `mangle_mono("", type_args)` bytes) yields a non-empty `$...` suffix, func must equal a non-empty base plus that exact suffix, and the stored target must have `FnOrigin::Monomorph`. HIR stores neither the discarded generic template nor its bounds, so this row makes no uncheckable template/bound claim. `child[args in order]`; `post[arity/modes/types match the concrete SIG; an immediate BorrowedIndex is valid only at a Borrow position, snapshots its root before its index child, emits bounds at that argument position only on index fallthrough, and revalidates the root after every later fallthrough argument at the call action; an overlapping invalidation rejects; a terminating index has no guard/descriptor/later argument/action, and a terminating later argument has no pointer/action; Move/Out behavior and return/BorrowMut-retention provenance are exact; result SIG.ret for declaration-backed calls; a source spelling equal to a RuntimeKey still resolves ProgramCall]`. | diff --git a/docs/impl/228-task-owned-results.md b/docs/impl/228-task-owned-results.md index c8c8247ca..2de794ee5 100644 --- a/docs/impl/228-task-owned-results.md +++ b/docs/impl/228-task-owned-results.md @@ -1,5 +1,9 @@ # Owned data results from structured tasks +[Plan259](259-task-owned-captures.md) extends this contract with self-contained +owned captures and file-bearing task results. Its input-transfer ledger supersedes +the earlier Copy-only capture and blanket native-result exclusions below. + Align-llm Request152 needs contiguous output bands without packing bytes into scalar task results. The user explicitly selected this request after the initial assessment. This is the future owned-result producer already reserved by the diff --git a/docs/impl/259-task-owned-captures.md b/docs/impl/259-task-owned-captures.md new file mode 100644 index 000000000..8582ba87f --- /dev/null +++ b/docs/impl/259-task-owned-captures.md @@ -0,0 +1,110 @@ +# Owned captures in structured tasks + +Status: implemented provider capability; consumer integration and performance qualification remain separate. + +The 2026-11-10 align-llm R41 witness needs to transfer an already admitted +file and an independent read window to a worker. Reopening its pathname does +not preserve admitted file identity. Plan228 supplies owned results but +deliberately leaves captures Copy-only. This is the deferred ownership +capability, not a scheduler or file-read performance defect. + +All three request registers were inspected. Media AM-R08 retains immutable +captures and caller-owned GPU resources. Voice V008 deliberately excludes +transferring its process-global signal lease. Neither asks to widen native +resource sendability. Media/voice are unavailable as local checkouts; their +GitHub registers were read at blobs `4098dbadd4e297b82617f07954031d8f62073ba1` +and `a8504384d0464a92a0dab802fee79ea07db3df35`. Consumer code, pins and adoption +remain outside this work. + +## Public contract + +| Surface | Contract | +| --- | --- | +| Syntax | Existing `spawn(fn { ... }) -> Task` consumes its literal task once. No new syntax, implicit clone, path reopen, task API or ambient option. Ordinary stored closures and repeatedly invoked pipeline callbacks retain Copy-only capture. | +| Capture ownership | Copy captures retain Plan180. A complete owned local of a worker-transferable type moves into the task at spawn, in first-use capture order. The worker receives it as an ordinary owning by-value parameter. The caller cannot read, borrow, move or capture the old owner again. A borrowed parameter cannot be moved into a task. | +| Transfer domain | Plan228's complete self-contained data graph, plus the existing `file` owner, at both capture and result boundaries. Owned capture roots are already-formed ordinary Move values with that complete graph, including direct Option/Result; bare owned fixed-array roots retain their existing capture exclusion. Task result roots retain Plan228's restrictions except for file. Share the graph walk, not the distinct root predicates. Existing type formation remains authoritative. All reachable fields, inactive variants and zero-length elements participate. Independent Copy data and owned string/buffer/scalar-array/AoS-array storage compose through existing records, tuples, sums and tagged carriers. | +| File | Transfer the same owned `file` descriptor wrapper, with no reopen, duplication or new allocation. `file` has no borrowed variant or implicit cursor. Existing positional read/write errors and short-read semantics remain. Files may return directly or in an already-admitted record through the existing owned-result lifecycle, allowing bounded window reuse without losing admitted identity. | +| Exclusions | Owned captures containing borrowed views, raw pointers, callables, boxes, task/region/native/resource owners other than `file`, malformed graphs or inline cycles reject. Arena-owned storage cannot cross an owning parameter boundary; preserve that rejection for task transfer. No exclusive-borrow capture or shared mutable capture is introduced. Signal subscriptions, GPU resources and HTTP owners remain non-Send. | +| Access | A moved owner grants the worker ordinary owned access, including local mutable rebinding and synchronous helpers. Shared Copy views remain read-only and protected through the covering join. Capturing a moved owner and an alias into its backing must reject in either capture order. Existing aliases cannot be used by the caller after transfer. | +| Return/error | `Result` keeps the one successful-wait boundary and lowest-spawn-index error. A file-bearing result follows Plan228's consuming get and untaken cleanup. Returning views of moved captures rejects. No new error code or cancellation mechanism. | +| Drop | Exactly one owner exists after publication. The lifted worker function drops unreturned captures on success, error, early return and nested-group exit. All registered tasks execute and join even if a handle or wait result is ignored. Group cleanup drops untaken owned results; taken results belong to the caller. Hard process termination keeps its no-unwind policy. | +| Allocation/ABI | Explicit input/output allocations remain visible. Reuse the task-group environment/result bookkeeping and runtime registration ABI. No new native symbol or public layout. Source slots are cleared after complete capture evaluation and before publication; task environment headers are one-time transfer staging, not independently dropped owners. | +| Validation/identity | Source checking, checked-HIR replay and MIR preflight must agree. HIR admits a Move-bearing literal only directly under Spawn. MIR SpawnTask carries its lifted target and capture operands directly; ordinary MIR Closure remains Copy-only, so no reusable owning Fn value exists. Authenticate exact capture operands, complete transfer types, actual callback return/fallibility and cleanup ABI before LLVM. Existing canonical capture/result type graphs identify generated helpers; implementation identity invalidates changed artifacts. | +| Consumers/performance | Provider acceptance proves exact bytes, file identity, cleanup, bounded window reuse and task progress. No startup speed claim is made. Native full-answer comparison and measured whole-startup/first-response benefit belong to align-llm after provider delivery. | + +## Implementation closure matrix + +| Axis | Implementation boundary and owner checks | +| --- | --- | +| Formation | Share the complete transfer-graph classifier across source, HIR and MIR validation. `task_owned_captures` crosses string, buffer, arrays, records, tuples, sums, direct Option/Result, file and nested file records; rejects borrowed/native siblings, inactive invalid variants and bare owned fixed-array captures. One-shot lifting is an explicit per-lift mode; nested stored/pipeline lambdas must not inherit it. Reuse Plan228 graph-cycle owners. | +| Construction / move-in / source nulling | `check_spawn` explicitly selects one-shot lifting; ordinary `lift_lambda` keeps Copy-only admission. MoveCheck consumes captures and ends their owned backing generations as at an owning call, rejecting borrowed sources/use-after-move and caller aliases. MIR stages all captures before clearing sources and publishes only the complete environment. Owners cover both capture orders and multiple owned inputs. | +| Move-out / replacement / return | Existing function and task-result transfer owns returned captures. Direct and record file results, repeated generations, loop-carried replacement and returned buffers run with exact bytes. Repeated get, repeated spawn, use-after-move and borrowed returns reject. | +| Drop / ownership provenance | Worker parameter cleanup and Plan228 untaken slots close normal, error, ignored handle, ignored/failed wait, implicit join, early return, `?`, replacement and nested group paths. Count live allocations and open descriptors; arena-backed captures must fail rather than be freed by a worker. | +| Access / control | Distinguish moved self-contained capture authority from shared capture effects. Parameterized owners cross if/match/else/`?`/map_err, branch and loop joins, zero/one/multiple generations, ordinary mutable helpers and shared-view overlap in either capture order. Existing task access and wait-proof owners retain competing-spawn and stale/inner-wait controls. | +| Independent HIR / malformed MIR | Corrupt capture type, origin, consuming position and ordinary callable use. Checked-HIR closure validation admits owned graphs only under Spawn. MIR preflight authenticates the direct SpawnTask target/operands/signature and refuses Move-bearing ordinary closures without LLVM panic. Existing task result producer checks cover file result cleanup. | +| Imports / monomorphization / cache | Direct/imported owning helpers and generic record carriers run through whole/per-unit checks and native execution; cold/hit interfaces preserve ownership and generated helper identity. Existing generic-lambda exclusions remain. | +| File identity / I/O | Provider fixture opens one path, replaces/unlinks the name, transfers the admitted file, and reads the original bytes. Cross short read, EOF, read failure, subsequent caller failure and joined cleanup. Bounded repeated read windows return file plus buffer without reopening. | +| Regression | `task_owned_captures`, `task_owned_results`, `task_group`, `task_progress`, `task_callback_access`, ordinary `lambda`, affected sema/HIR/MIR classifier owners, bounded PR gate and workspace Clippy. No GPU/service changes or consumer qualification are part of these checks. | + +The capture producer, single-call proof, worker cleanup and file-bearing result +form one useful capability. Splitting admission from transfer/cleanup would be +unsound, and file input without file return cannot support reusable admitted +bounded windows. If this exceeds 1,000 handwritten lines, retaining that one +lifecycle boundary avoids duplicated proof and dormant intermediate features. + +## Author-side source pass + +Before this capability, `lift_lambda` rejected `ty_capture_is_move` before publishing captured +trailing ByValue parameters. Spawn consumes a literal directly, unlike ordinary +Copy function values. HIR's closure checker already knows its Spawn context. +MoveCheck reads every Closure capture; the Spawn transfer must own +consumption, without making repeatedly called ordinary closures consuming. + +`task_callback_access` maps callback writes to captured caller roots. Only the +proved self-contained moved inputs may be exempt from that shared-view refusal; +other captures and transitive callback requirements keep their checks. + +Replace the existing intermediate frame closure for every Spawn with direct +target/capture operands in SpawnTask. Lowering stages all operands, clears source +ownership, and constructs the group environment before publication. The shared +typed environment thunk invokes the lifted function once; ordinary parameter +cleanup owns release. The helper identity uses the actual callback return type +(including its Result) and cleanup ABI, never the HIR Task's ok-only Fn tag. +Ordinary MIR Closure remains Copy-only. This removes a reusable owning Fn value +instead of adding a second single-use validation state machine. + +Ordinary local moves preserve alias storage generations. Spawn must additionally +end transferred generations, as `finish_successful_call_arguments` does, so +caller aliases cannot race the worker or survive its Drop. Escape replay uses +`call_transfer_contains_arena_owned`; merely outliving the group is insufficient. + +`RwFile` owns one fd and no cursor. Its existing null-safe destructor and +Plan228's canonical Drop support already provide the release primitive. A file +result still needs the same complete graph authentication as other owned data. +No raw-handle transport or runtime worker is added. + +## Review and verification + +The independent preimplementation strategy review required ordinary owning-call +alias invalidation, explicit arena-provenance rejection, separate input/result +root predicates, and direct task operands instead of an owning Copy Fn. All four +are reflected above. The draft, condensed spec, Settled text, design rationale, +HIR ledger and concurrency guide EN/JA agree; Plans180/228 retain their historical +scope with a superseding reference. + +The author extracted the `must`/`exact`/`every`/`before`/`reject`/`required` +obligations from this ledger and checked them against the diff: + +| Obligation | Implementation and discriminating owner | +| --- | --- | +| Complete transfer domain, borrowed/native/arena refusal | `task_owned_capture_type`, `task_transfer_graph`, `check_spawn_capture`; sema `task_owned_transfer_roots_keep_native_and_arena_boundaries`, driver `owned_capture_rejects_aliases_borrows_and_non_send_graphs`, existing MIR `task_owned_complete_graph` | +| Expire all inputs and aliases before publication, both orders | MoveCheck's Spawn completion and MIR Spawn lowering; driver rejection matrix and `owned_capture_roots_cleanup_and_imports` | +| Exactly one cleanup owner through every ordinary exit | Existing parameter Drop and owned task slots; driver data/file lifecycle owners assert zero live allocations after a positive peak and restored descriptor count | +| Checked-HIR and exact MIR callback ABI before LLVM | `closure_matches`, `task_request_is_valid`, `task_environment_identity`; HIR `task_owned_capture_hir_consumption_is_replayed` and codegen `task_owned_capture_mir_rejects_forged_transfer_and_copy_closure` | +| Byte identity, bounded reuse, imports and cache | `file_capture_identity_errors_and_cleanup` reads the original opened inode after pathname replacement, crosses short/EOF/error, and executes whole/per-unit plus cold/hit interfaces | +| Worker progress and unchanged shared captures | Existing `task_progress`, `task_callback_access`, `task_group`, `task_owned_results` and `lambda` owners | + +The guide example passes both source-checking modes. Baseline rejects the R41 +admitted-file witness at capture admission; the implemented native witness reads +successfully. No benchmark is required because this capability makes no startup +speed claim. Consumer full-answer and startup measurements remain pending. diff --git a/docs/language-spec.md b/docs/language-spec.md index 485d05541..9cd6487cf 100644 --- a/docs/language-spec.md +++ b/docs/language-spec.md @@ -1442,6 +1442,17 @@ wait; result access separately requires success. An inner wait cannot clear oute Existing borrow invalidation on owner moves remains. Task-local storage and copies containing no borrowed backing remain independently writable. Helper and callback invocations obey the same rule. +A literal consumed by `spawn` may move a complete ordinary Move local with a self-contained +value graph, including `file` and direct Option/Result capture roots. Bare owned fixed-array +roots retain their capture exclusion. Evaluate all captures in +first-use order, then transfer ownership before publication. The worker receives ordinary owning +parameters; the caller's owner and backing aliases expire, including a shared alias captured by the +same task. Borrowed parameters, arena-owned storage and other native/resource owners reject. +Worker input cleanup covers every ordinary exit; returned owners use the task result lifecycle. +A file retains its original descriptor without reopen or duplication. Ordinary stored closures and +repeated pipeline callbacks remain Copy-only; exclusive-borrow capture stays excluded. +[Plan259](impl/259-task-owned-captures.md) defines the complete input/output transfer boundary. + `Task.get()` is valid only after a successful `wait()` on every reachable path for its task generation. In a fallible group, the Wait Result may be handled directly or through a bare local, copy/reassignment, block tail, `map_err`, or value-producing `if`/`match`/`else`/`loop`; only an @@ -1461,11 +1472,11 @@ Each Task is a Move handle whose compiler-known origin names its spawning group reassignment, block tails, and value-producing control flow preserve that origin; calls, returns, captures, imports, and aggregate reconstruction do not. `get()` checks that exact still-active group. A nested group preserves outer facts: its own `wait()` cannot authorize an outer Task, while -handling an outer Wait Result inside it updates the outer group. Task results are self-contained primitive values, string, buffer, ordinary dynamic scalar +handling an outer Wait Result inside it updates the outer group. Task results are self-contained primitive values, string, buffer, file, ordinary dynamic scalar arrays, AoS record arrays, records, tuples or sums. Their complete already-formed graphs may -compose independent Copy data and owned string/buffer/array storage through records, tuples, +compose independent Copy data, file ownership and owned string/buffer/array storage through records, tuples, sums, Option/Result and fixed arrays. Inactive and zero-length payloads also participate. -Views, pointers, callables, boxes, task/region/native/resource handles and inline cycles reject; +Views, pointers, callables, boxes, task/region/resource handles, native handles other than file, and inline cycles reject; dynamic headers may break recursive layout cycles. Root fixed arrays, vectors/masks, RNG, Option and arbitrary Result stay excluded. `Result` retains the task-error boundary. For Copy R, `get()` copies R and may repeat. For Move R, it consumes the handle and transfers diff --git a/docs/open-questions.md b/docs/open-questions.md index 3e73b9f6f..a1c62bfc9 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -1439,7 +1439,8 @@ join; protection belongs to the group and follows existing borrow invalidation r of the Task handle. A fallible join ends memory protection on either outcome while result access still requires success. The waiter participates in its own queued work; no bounded start latency or arbitrary cross-task blocking-cycle progress is promised. [Plan180](impl/180-structured-task-progress.md) -owns the safety, interface and scheduler records. Exclusive/owned captures and owned results stay deferred. +owns the safety, interface and scheduler records. Plan228 supplies owned results; Plan259 +supplies owned input transfer and file results. Exclusive-borrow captures remain deferred. > **Current status:** first-class closures ①–③ and `task_group` ④a–④c are shipped, including real > threads, scoped capture environments, `spawn`/`wait()`/`get()`, and fallible `wait()?`. The plan @@ -1471,10 +1472,11 @@ Decomposition: **④a** scope + the task region + `spawn` (fresh region env per adoption):** Mimalloc free-list sharding as prior art if/when this region's bump allocator needs to serve concurrent `spawn`/`alloc` calls across the ④b-2 real threads without a single global lock. - **Owned `R` (Plan228)** uses the existing result slot plus an initialized flag. On success the worker stores R then initializes the flag. For Move R, `get()` consumes the Task, transfers the owner, and clears that flag. At group exit, after joining all work, initialized untaken results from every wait generation drop in spawn order before region release. A taken result follows ordinary Drop and can outlive the group. No mandatory-get rule, implicit clone, owned capture or exclusive capture is added. +- **Owned inputs (Plan259)**: the literal consumed by `spawn` may move an already-formed ordinary Move local with the complete self-contained task-data graph, including `file` and direct Option/Result capture roots; bare owned fixed-array roots remain excluded. All inputs are snapshotted before source ownership and caller aliases expire and work becomes eligible. The worker receives ordinary owning parameters and drops unreturned inputs on every ordinary exit; returned owners follow the existing result lifecycle. Borrowed parameters, arena-owned storage, views inside owned graphs and non-transferable native/resource owners reject. A moved file preserves descriptor identity and may return directly or in an admitted record. Ordinary stored/pipeline lambdas stay Copy-only, and exclusive-borrow captures remain excluded. MIR carries the direct task target and inputs without a reusable owning Fn environment. **④c-2 plan — the `wait()?` error boundary (the last task_group slice).** A task may **fail**: its closure returns `Result`. `wait()?` joins all, and if any task failed, propagates the `Err` from the lowest spawn index out of the enclosing function (deterministic across parallel completion order; documented). After `wait()?`, `get()` yields the `Ok` `R`. Implementation, in order: - **Prerequisite — `Result`-returning spawn closures.** At this plan's 2026-06-23 boundary, a `Result`-returning lambda could not be a `Ty::Fn` value (`FnTy.ret` was scalar-only). Since a spawned lambda is *consumed by `spawn`* (never a free first-class value), `check_spawn` **lifts the literal lambda directly** (via `lift_lambda`, whose result type may legitimately be `Ty::Result(ok, builtin Error)`) instead of routing through a `Ty::Fn` value — and the `Spawn` node carries the lifted name + captures + the `Ok` scalar + a `fallible` flag, like `Closure` does. The task's Err type is the exact builtin `Error` used by the enclosing fallible function, so `spawn(fn { fallible()? ; Ok(x) })` type-checks without a written return type. -- **`get()` requires a *successful* `wait()`.** For a fallible group, a bare `wait()` whose `Result` is ignored does **not** make `get()` safe — an `Err` task never stored its slot, so the slot is uninitialized. Each Wait Result carries its exact compiler-only group/proof-epoch/Wait-id/covered-generation fact through bare locals, copy/reassignment, block tail, `map_err`, and value-producing control. `?`, exhaustive Result `match`, or Result `else` resolves that exact id on its Ok continuation, including inside a nested group. Every earlier Wait for the same drained generation must also be proved Ok: a second empty `wait()?` cannot hide an unresolved or failed first Wait. Err advances the proof epoch and invalidates every Task/Wait proof it covered. Every Spawn advances the current task generation and stales earlier Wait proofs; with an unresolved Wait it also invalidates covered Tasks, while after successful completion the next successful Wait reauthorizes old and new handles. A later no-task Wait cannot revoke completion already established for that generation, even when its Result is left unhandled. Loop headers join entry and reachable body fallthrough to a fixed point using stable syntax-site tokens before accepted breaks form the exit, so an earlier iteration's unresolved or failed Wait cannot disappear. Calls, returns, closure captures, imported values, and aggregate reconstruction do not transport the proof; passing a Copy Result leaves the caller's original local intact but gives the callee/return no proof. A Task is a Move handle with a separate compiler-only originating-group/born-generation proof, transferred through local moves/reassignment and value-producing control but not opaque boundaries. `get()` checks that exact still-active generation range: an inner Wait cannot authorize an outer Task, while an already-successful outer generation remains readable inside a nested group. Group exit clears every proof naming the exited group, including one attached to its Result block value, and preserves proofs for still-active outer groups; handling the cleared inner Result outside cannot authorize an outer Task. For an infallible group `wait()` returns `()` and enables `get()` immediately. Task results are self-contained primitives, string, buffer, ordinary dynamic scalar arrays, AoS record arrays, records, tuples and sums, optionally wrapped in the task's builtin-Error Result. Their complete already-formed graph includes independent Copy data and owned string/buffer/array backing; every field, inactive variant and zero-length element participates. Borrowed views, raw pointers, callables, boxes, task/region/native/resource handles and inline cycles reject. Dynamic ownership headers may break layout cycles. Root fixed arrays/vectors/masks/RNG/Option/arbitrary Result stay excluded. Copy get preserves the handle and may repeat; owned get consumes it once. [Plan207](impl/207-task-copy-record-results.md) owns the Copy foundation and [Plan228](impl/228-task-owned-results.md) completes owned result publication, transfer, independent provenance and untaken cleanup. +- **`get()` requires a *successful* `wait()`.** For a fallible group, a bare `wait()` whose `Result` is ignored does **not** make `get()` safe — an `Err` task never stored its slot, so the slot is uninitialized. Each Wait Result carries its exact compiler-only group/proof-epoch/Wait-id/covered-generation fact through bare locals, copy/reassignment, block tail, `map_err`, and value-producing control. `?`, exhaustive Result `match`, or Result `else` resolves that exact id on its Ok continuation, including inside a nested group. Every earlier Wait for the same drained generation must also be proved Ok: a second empty `wait()?` cannot hide an unresolved or failed first Wait. Err advances the proof epoch and invalidates every Task/Wait proof it covered. Every Spawn advances the current task generation and stales earlier Wait proofs; with an unresolved Wait it also invalidates covered Tasks, while after successful completion the next successful Wait reauthorizes old and new handles. A later no-task Wait cannot revoke completion already established for that generation, even when its Result is left unhandled. Loop headers join entry and reachable body fallthrough to a fixed point using stable syntax-site tokens before accepted breaks form the exit, so an earlier iteration's unresolved or failed Wait cannot disappear. Calls, returns, closure captures, imported values, and aggregate reconstruction do not transport the proof; passing a Copy Result leaves the caller's original local intact but gives the callee/return no proof. A Task is a Move handle with a separate compiler-only originating-group/born-generation proof, transferred through local moves/reassignment and value-producing control but not opaque boundaries. `get()` checks that exact still-active generation range: an inner Wait cannot authorize an outer Task, while an already-successful outer generation remains readable inside a nested group. Group exit clears every proof naming the exited group, including one attached to its Result block value, and preserves proofs for still-active outer groups; handling the cleared inner Result outside cannot authorize an outer Task. For an infallible group `wait()` returns `()` and enables `get()` immediately. Task results are self-contained primitives, string, buffer, file, ordinary dynamic scalar arrays, AoS record arrays, records, tuples and sums, optionally wrapped in the task's builtin-Error Result. Their complete already-formed graph includes independent Copy data, file ownership and owned string/buffer/array backing; every field, inactive variant and zero-length element participates. Borrowed views, raw pointers, callables, boxes, task/region/resource handles, native handles other than file, and inline cycles reject. Dynamic ownership headers may break layout cycles. Root fixed arrays/vectors/masks/RNG/Option/arbitrary Result stay excluded. Copy get preserves the handle and may repeat; owned get consumes it once. [Plan207](impl/207-task-copy-record-results.md) owns the Copy foundation and [Plan228](impl/228-task-owned-results.md) completes owned result publication, transfer, independent provenance and untaken cleanup. - **Per-`task_group` `fallible` flag** (a stack like `wait_state`): set when a `Result`-returning task is spawned. `wait()`'s type is `Result<(), Error>` when the group is fallible, else `()` (so infallible groups stay `()` — no spurious `Result`). - **Full-Error reporting via private task slots.** Each fallible task gets a private `err_slot`. The per-`R` trampoline stores `R` and returns 0 on Ok, or stores the complete builtin `Error` and returns 1 on Err. `align_rt_tg_wait` joins all workers and returns null on success or the lowest-spawn-index errored slot pointer. The slots are disjoint region allocations, so no shared mutable error cell is introduced. - **`wait()?`**: codegen builds `Result<(), Error>` from the pointer-returning `tg_wait` result (`Ok(())` for null, otherwise copy the full Error from that slot); `?` propagates as usual. `get()` (already `wait`-gated by ④c-1) then reads the Ok slot. diff --git a/draft.md b/draft.md index 1f71748c0..26c663327 100644 --- a/draft.md +++ b/draft.md @@ -2149,11 +2149,11 @@ calls, returns, captures, imports, and aggregate reconstruction do not transport the originating group, not merely the innermost active group. Entering a nested group preserves outer group facts: an inner `wait()` cannot authorize an outer Task, while handling an outer fallible Wait Result inside the inner group updates the outer group's success fact. Task result R is self-contained data: primitive Int/Float/Bool/Char/Unit, -string, buffer, ordinary dynamic scalar arrays, AoS record arrays, records, tuples or sums. +string, buffer, file, ordinary dynamic scalar arrays, AoS record arrays, records, tuples or sums. Every reachable field and payload participates, including inactive variants and zero-length array elements. Their complete already-formed graphs may compose independent Copy data and -owned string/buffer/array storage through records, tuples, sums, Option/Result and fixed arrays. -Views, raw pointers, callables, boxes, task/region/native/resource handles and inline cycles reject; +owned string/buffer/array storage and file ownership through records, tuples, sums, Option/Result and fixed arrays. +Views, raw pointers, callables, boxes, task/region/resource handles, native handles other than file, and inline cycles reject; dynamic array headers may break recursive layout cycles. Root fixed arrays, vectors/masks, RNG, Option and arbitrary Result remain excluded. A task may instead return `Result` through the same successful-wait boundary. Ordinary field/payload formation restrictions still apply. @@ -2169,7 +2169,7 @@ handling that Result outside cannot authorize an enclosing Task. Proofs for stil groups remain. Unlike a `par_map` lambda (which must be Pure), a spawned task **may** be impure — that is the -point: it performs I/O. Captures are snapshots of Copy values. A copied view descriptor still +point: it performs I/O. Copy captures are value snapshots. A copied view descriptor still shares backing, so a task may read that backing but cannot modify it or access it exclusively. The caller cannot modify, invalidate or release overlapping captured backing until the covering join completes. Protection belongs to the group even when its Task handle is discarded; an inner @@ -2180,9 +2180,21 @@ copies containing no borrowed backing are independent. These rules include trans callback calls. By-value capture also requires worker-sendability: a `region` capability is rejected before the spawned environment is published. +A literal consumed by `spawn` may also move an already-formed owning local whose complete +value graph is self-contained task data or `file`. Direct Option/Result capture roots are allowed; +ordinary result-root restrictions and bare owned fixed-array capture exclusions still apply. All captures are evaluated in first-use order before +ownership transfers and work is published. The worker receives ordinary owning parameters and may +mutate or return them. The caller's old owner and aliases into its backing expire at this boundary, +including aliases captured by the same task in either capture order. Borrowed parameters, +arena-owned storage and non-transferable native/resource owners cannot move into a task. +The worker drops unreturned inputs on every ordinary exit; returned inputs follow the existing +owned-result lifecycle. Moving a file preserves its descriptor identity without reopening or +duplicating it. Ordinary stored closures and repeated pipeline callbacks keep Copy-only captures; +there is no exclusive-borrow capture. [Plan259](docs/impl/259-task-owned-captures.md) owns this boundary. + A spawned lambda *escapes* (it outlives the `spawn` call, running later on the task), so it is -represented as a first-class closure with an environment holding its captured values — distinct -from a pipeline lambda, which never escapes and is inlined. The compiler's escape analysis chooses +lowered directly to a task target and an environment holding its captured values, without a +reusable owning function value. A pipeline lambda never escapes and is inlined. The compiler's escape analysis chooses the representation. Non-escaping pipeline captures need no separate environment allocation; spawned tasks retain their captures in an environment **owned by the `task_group` scope**. The environment is a region allocation, like an `arena {}` allocation, and is freed when that From d62b95c8d347cf65d2945db9f3450f01bd5d84a5 Mon Sep 17 00:00:00 2001 From: sanohiro Date: Tue, 10 Nov 2026 13:34:14 +0900 Subject: [PATCH 2/2] fix: authenticate ownership of direct task inputs --- crates/align_codegen_llvm/src/lib.rs | 66 ++++++++++++++++++++++++---- crates/align_mir/src/producer.rs | 17 ++++++- crates/align_sema/src/lib.rs | 7 +-- docs/impl/259-task-owned-captures.md | 12 ++++- 4 files changed, 87 insertions(+), 15 deletions(-) diff --git a/crates/align_codegen_llvm/src/lib.rs b/crates/align_codegen_llvm/src/lib.rs index 68c6d916a..19845a8cb 100644 --- a/crates/align_codegen_llvm/src/lib.rs +++ b/crates/align_codegen_llvm/src/lib.rs @@ -39068,6 +39068,49 @@ fn main() -> i32 = 0 Ok(()) } + #[test] + fn task_owned_capture_provenance_rejects_borrowed_inputs() -> Result<(), String> { + let mut accepted = Vec::new(); + for ty in [Ty::Buffer, Ty::File] { + let spelling = if ty == Ty::Buffer { "buffer" } else { "file" }; + let base = mir(&format!("import std.fs\nfn launch(borrow other:{spelling},owner:{spelling}){{task_group{{spawn(fn{{local:=owner;()}});wait()}}}}\nfn main(){{}}\n")); + validate_mir_producers(&base).map_err(|error| error.to_string())?; + for shape in 0..3 { + let mut bad = base.clone(); + let function = bad.fns.iter_mut().find(|function| function.name.as_str() == "launch") + .ok_or("launch function")?; + let slot = *function.params.first().ok_or("borrowed input slot")?; + let loaded = u32::try_from(function.value_tys.len()).map_err(|error| error.to_string())?; + function.value_tys.push(ty); + let block = function.blocks.iter_mut().find(|block| block.stmts.iter().any(|statement| + matches!(statement, Stmt::Let(_, Rvalue::SpawnTask { .. })))).ok_or("spawn block")?; + let index = block.stmts.iter().position(|statement| + matches!(statement, Stmt::Let(_, Rvalue::SpawnTask { .. }))).ok_or("spawn index")?; + let Some(Stmt::Let(_, Rvalue::SpawnTask { captures, .. })) = block.stmts.get_mut(index) + else { return Err("spawn shape".into()) }; + *captures.first_mut().ok_or("capture")? = match shape { + 0 => Operand::Arg(0), + 1 => Operand::Value(loaded), + _ => Operand::BorrowedPlace(Box::new(align_mir::BorrowedPlace { + slot, path: Vec::new(), ty, cleanup: None, + })), + }; + if shape == 1 { + block.stmts.insert(index, Stmt::Let(loaded, Rvalue::Load(slot))); + if !block.stmt_lines.is_empty() { block.stmt_lines.insert(index, (0, 0)); } + } + if validate_mir_producers(&bad).is_ok() { + accepted.push((ty, shape)); + continue; + } + assert!(validate_thin_partition_program(&bad, &[]).is_err(), "{ty:?}/{shape}"); + assert!(emit_llvm_ir(&bad, &BuildTarget::Native, Profile::Release, false, &[], None).is_err(), "{ty:?}/{shape}"); + } + } + assert!(accepted.is_empty(), "borrowed task inputs accepted as owners: {accepted:?}"); + Ok(()) + } + #[test] fn task_owned_metadata_rejects_reader_and_callback_forgery() -> Result<(), String> { for (owned, source) in [ @@ -39115,24 +39158,29 @@ fn main() -> i32 = 0 #[test] fn malformed_parallel_mir_cannot_publish_a_region_capability() { - let i32_ty = Ty::Int(IntTy { bits: 32, signed: true }); + let mut task = template_mir_function("region_task", Rvalue::Use(Operand::Const(Const::Unit)), vec![Ty::Unit]); + task.params = vec![0]; + task.param_modes = vec![align_ast::ParamMode::ByValue]; + task.borrow_mut_cleanup_slots = vec![None]; + task.slots = vec![Ty::ArenaHandle]; + task.slot_align = vec![None]; let spawn_error = codegen_program( - vec![Stmt::Let( - 0, + vec![Stmt::Let(0, Rvalue::TgBegin), Stmt::Let( + 1, Rvalue::SpawnTask { - tg: Operand::Const(Const::Unit), - lifted: program_call("invalid_task"), - captures: vec![Operand::Const(Const::Unit)], + tg: Operand::Value(0), + lifted: program_call("region_task"), + captures: vec![Operand::Value(0)], capture_tys: vec![Ty::ArenaHandle], - r: i32_ty, + r: Ty::Unit, fallible: false, }, )], - vec![Ty::Box(Scalar::Int(IntTy { bits: 32, signed: true }))], - vec![], + vec![Ty::ArenaHandle, Ty::Task(Scalar::Unit)], vec![], vec![], vec![], + vec![task], ) .expect_err("spawn metadata must reject a direct region capability before publication"); assert_lowering( diff --git a/crates/align_mir/src/producer.rs b/crates/align_mir/src/producer.rs index ecefbfbb8..9b8be300e 100644 --- a/crates/align_mir/src/producer.rs +++ b/crates/align_mir/src/producer.rs @@ -8701,7 +8701,7 @@ fn xml_local_call_components(program: &Program) -> Result>, Produ Some(target) } Rvalue::CallWithCleanup(call) => Some(&call.target), - Rvalue::Closure { lifted, .. } => Some(lifted), + Rvalue::Closure { lifted, .. } | Rvalue::SpawnTask { lifted, .. } => Some(lifted), _ => None, }; if let Some(target) = target.and_then(|target| indices.get(target).copied()) { @@ -9253,7 +9253,7 @@ fn builder_boolean_slots(function: &Function) -> Vec { crate::loop_facts::DropStateCall::None => {} } if let Rvalue::Use(operand) = value { address(&mut eligible, operand); } - if let Rvalue::Closure { captures, .. } = value { + if let Rvalue::Closure { captures, .. } | Rvalue::SpawnTask { captures, .. } = value { for capture in captures { address(&mut eligible, capture); } } } @@ -10206,6 +10206,16 @@ fn validate_resource_rvalues_component( }) }; let call_arguments_valid = match rvalue { + Rvalue::SpawnTask { lifted, captures, capture_tys, .. } => { + // Direct task inputs are ordinary owning parameters. A matching type + // alone cannot turn a shared borrowed origin into a transferable owner. + xml_direct_call_facts(program, lifted, local_contracts).is_some_and(|facts| { + facts.params == *capture_tys + && facts.modes.iter().all(|mode| *mode == align_ast::ParamMode::ByValue) + && operands_match_modes(captures, &facts.modes, &facts.params, program) + && xml_call_arguments_valid(captures, &facts.params, &facts.modes, *value) + }) + } Rvalue::Call(DirectCall::Program(target), args) => { xml_direct_call_facts(program, target, local_contracts).map_or_else( || { @@ -10325,6 +10335,9 @@ fn validate_resource_rvalues_component( }; if !call_arguments_valid { let detail = match rvalue { + Rvalue::SpawnTask { lifted, .. } => { + format!("owned-leaf argument provenance mismatch at task '{lifted}'") + } Rvalue::Call(DirectCall::Program(target), _) => { format!("owned-leaf argument provenance mismatch at call to '{target}'") } diff --git a/crates/align_sema/src/lib.rs b/crates/align_sema/src/lib.rs index c4828233f..6c777bb48 100644 --- a/crates/align_sema/src/lib.rs +++ b/crates/align_sema/src/lib.rs @@ -50178,7 +50178,8 @@ impl<'a> MoveCheck<'a> { } // A fn value is Copy (a pointer); an indirect call's callee + args are reads. ExprKind::FnValue(_) => {} - // A closure copies its captured (Copy) values into its env — reads, not moves. + // A literal first reads its captures. Only its enclosing Spawn completes owned + // transfer; ordinary closures admit Copy inputs alone. ExprKind::Closure { captures, .. } => { for c in captures { move_expr!(self, c, moved, false, false); @@ -62156,8 +62157,8 @@ impl<'a, 't> Checker<'a, 't> { self.finalize_block(&mut body_fin); task_wait::validate(&body_fin, self.tagged_types, self.diags); // Run the broad unnecessary-heap scan on the lifted lambda body too (parity with the narrow - // lint in `finalize_expr`); a box local here is function-local (Move values cannot be - // captured), so the scan is self-contained and never double-reports the enclosing function. + // lint in `finalize_expr`); a box local here is function-local (boxes cannot be captured), + // so the scan is self-contained and never double-reports the enclosing function. UnnecessaryHeapScan::run(&body_fin, self.diags); // A forbidden extern boundary inside this separately lifted function is a producer error, // not a recoverable callback type. Keep the exact diagnostic but do not publish the lifted diff --git a/docs/impl/259-task-owned-captures.md b/docs/impl/259-task-owned-captures.md index 8582ba87f..863ab59c7 100644 --- a/docs/impl/259-task-owned-captures.md +++ b/docs/impl/259-task-owned-captures.md @@ -41,7 +41,7 @@ remain outside this work. | Move-out / replacement / return | Existing function and task-result transfer owns returned captures. Direct and record file results, repeated generations, loop-carried replacement and returned buffers run with exact bytes. Repeated get, repeated spawn, use-after-move and borrowed returns reject. | | Drop / ownership provenance | Worker parameter cleanup and Plan228 untaken slots close normal, error, ignored handle, ignored/failed wait, implicit join, early return, `?`, replacement and nested group paths. Count live allocations and open descriptors; arena-backed captures must fail rather than be freed by a worker. | | Access / control | Distinguish moved self-contained capture authority from shared capture effects. Parameterized owners cross if/match/else/`?`/map_err, branch and loop joins, zero/one/multiple generations, ordinary mutable helpers and shared-view overlap in either capture order. Existing task access and wait-proof owners retain competing-spawn and stale/inner-wait controls. | -| Independent HIR / malformed MIR | Corrupt capture type, origin, consuming position and ordinary callable use. Checked-HIR closure validation admits owned graphs only under Spawn. MIR preflight authenticates the direct SpawnTask target/operands/signature and refuses Move-bearing ordinary closures without LLVM panic. Existing task result producer checks cover file result cleanup. | +| Independent HIR / malformed MIR | Corrupt capture type, origin, consuming position and ordinary callable use. Checked-HIR closure validation admits owned graphs only under Spawn. MIR producer validation authenticates ordinary by-value input provenance and certifies task-target dependencies; preflight authenticates the direct SpawnTask target/operands/signature and refuses Move-bearing ordinary closures without LLVM panic. Existing task result producer checks cover file result cleanup. | | Imports / monomorphization / cache | Direct/imported owning helpers and generic record carriers run through whole/per-unit checks and native execution; cold/hit interfaces preserve ownership and generated helper identity. Existing generic-lambda exclusions remain. | | File identity / I/O | Provider fixture opens one path, replaces/unlinks the name, transfers the admitted file, and reads the original bytes. Cross short read, EOF, read failure, subsequent caller failure and joined cleanup. Bounded repeated read windows return file plus buffer without reopening. | | Regression | `task_owned_captures`, `task_owned_results`, `task_group`, `task_progress`, `task_callback_access`, ordinary `lambda`, affected sema/HIR/MIR classifier owners, bounded PR gate and workspace Clippy. No GPU/service changes or consumer qualification are part of these checks. | @@ -108,3 +108,13 @@ The guide example passes both source-checking modes. Baseline rejects the R41 admitted-file witness at capture admission; the implemented native witness reads successfully. No benchmark is required because this capability makes no startup speed claim. Consumer full-answer and startup measurements remain pending. + +The full-diff review found one missing producer consumer: direct SpawnTask inputs +had type checks but bypassed ordinary owning-argument provenance. The corrected +boundary reuses `xml_call_arguments_valid`, mode validation and target dependency +ordering. `task_owned_capture_provenance_rejects_borrowed_inputs` crosses Buffer +and File with shared Arg, shared Load and BorrowedPlace inputs at producer, +partition and LLVM boundaries. All six mutations were accepted by the reviewed +candidate before the correction. The same capture inventory now participates +in the conservative exposed-boolean-slot scan. This closes the existing exact +input-authentication obligation without changing the public transfer strategy.