Skip to content

Commit 06133d3

Browse files
avrabeclaude
andauthored
feat(p3): static resource-lifetime validation for async streams (#142 iv, v0.21.0) (#210)
* feat(p3): static resource-lifetime validation for async streams (#142 iv) meld fuse now rejects a fusion where a component declares a stream<T>/future<T> whose element type is a resource handle. A borrow<R> cannot outlive its lending call across the async boundary (use-after-scope); an own<R> is the drop-while-referenced hazard #142 (iv) names. New StreamValidationIssue::ResourceLifetime → Error::StreamValidation. Detection: resource handles surface in the parsed stream descriptor as `stream<Type(N)>` (handle hidden behind a Type(idx) ref), so the check parses the index out and reuses ParsedComponent::resolve_to_resource — the same Type(idx)→Own/Borrow chase meld applies to function params — rather than trusting Debug text for the handle kind. The wasmparser ComponentValType::Type(N) Debug form is pinned by a regression test so a wasmparser upgrade that changes it fails loudly. New LS-R-14 (approved) + 3 tests (borrow flagged, own flagged-as-owned, primitive not flagged) + the format pin. resolve_to_resource made pub(crate). #142 (ii) bounded-channel capacity documented NOT APPLICABLE: the canonical ABI has no capacity concept (stream.new takes none; streams are unbounded). This closes the #142 (i)-(iv) checklist (i/iii shipped v0.13/v0.15, iv here, ii N/A). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-trigger CI (Mythos gate label refresh) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 45c6f42 commit 06133d3

7 files changed

Lines changed: 318 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
## [0.21.0] - 2026-05-30
8+
9+
### Added
10+
11+
- **Static resource-lifetime validation for async streams** (#142 (iv),
12+
SR-34). `meld fuse` now rejects a fusion where a component declares a
13+
`stream<T>` / `future<T>` whose element type is a resource handle.
14+
`borrow<R>` is a definite violation — the borrow cannot outlive its
15+
lending call across the async boundary (use-after-scope); `own<R>` is
16+
flagged as the drop-while-referenced hazard #142 names. Surfaced as
17+
`StreamValidationIssue::ResourceLifetime`
18+
`Error::StreamValidation`. Detection reuses
19+
`ParsedComponent::resolve_to_resource` (the same `Type(idx)` → handle
20+
resolution meld applies to function params), with the wasmparser
21+
`ComponentValType::Type(N)` descriptor form pinned by a regression
22+
test. New loss scenario **LS-R-14** (approved). Limitation: a handle
23+
nested inside a composite element (`stream<list<own<R>>>`) is not
24+
flagged — the same boundary as `stream_elements_in_valtype`.
25+
26+
### Notes
27+
28+
- **#142 (ii) bounded-channel capacity is not applicable.** The
29+
Component-Model canonical ABI has no bounded-channel / capacity
30+
concept — `stream.new` takes no capacity and streams are unbounded by
31+
construction — so there is nothing in the component binary to
32+
validate. Documented in `p3_stream.rs` and LS-R-14; this closes the
33+
#142 (i)–(iv) checklist (i/iii shipped in v0.13.0/v0.15.0, iv here,
34+
ii not-applicable).
35+
736
## [0.20.0] - 2026-05-29
837

938
### Changed

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ exclude = [
1010
]
1111

1212
[workspace.package]
13-
version = "0.20.0"
13+
version = "0.21.0"
1414
authors = ["PulseEngine <https://github.com/pulseengine>"]
1515
edition = "2024"
1616
license = "Apache-2.0"

meld-core/src/p3_stream.rs

Lines changed: 182 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,22 @@ pub fn build_stream_pair_graph(
303303
// legal bidirectional-pipe pattern (two independent streams
304304
// in opposite directions, each individually acyclic).
305305
//
306-
// Checks **(ii) bounded-channel capacity** and **(iv) resource
307-
// lifetime across async boundaries** remain TODO — they need
308-
// information beyond `CanonicalEntry` / `ParsedComponent` (capacity
309-
// flags + per-handle lifetime tracking).
306+
// (iv) Resource lifetime across async boundaries — a resource
307+
// handle (`own<R>` / `borrow<R>`) carried as a `stream<T>` /
308+
// `future<T>` element type. A `borrow<R>` is only valid within
309+
// its lending call, but a stream/future is read *after* that
310+
// call returns across the async boundary → use-after-scope; an
311+
// `own<R>` transferred into a stream and then dropped by the
312+
// producer while the consumer still holds it is the
313+
// drop-while-referenced hazard #142 names. See
314+
// [`resource_lifetime_issues`].
315+
//
316+
// Check **(ii) bounded-channel capacity** is **not applicable**: the
317+
// Component-Model canonical ABI has no bounded-channel / capacity
318+
// concept — `stream.new` (`CanonicalEntry::StreamNew { ty }`) takes no
319+
// capacity, and streams are unbounded by construction. There is nothing
320+
// in the component binary to validate; "declare a capacity" presupposes
321+
// an annotation mechanism the ABI does not provide (#142).
310322

311323
/// A validation issue raised by [`validate_stream_pair_graph`].
312324
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -329,6 +341,20 @@ pub enum StreamValidationIssue {
329341
/// ≥ 3. Could deadlock at runtime if every component is waiting on
330342
/// inbound stream data before producing outbound data.
331343
Cycle { component_cycle: Vec<usize> },
344+
/// Check (iv): a `stream<T>` / `future<T>` carries a resource handle
345+
/// as its element type. `owned == false` is a `borrow<R>` —
346+
/// definitely invalid, the borrow cannot outlive its lending call
347+
/// across the async boundary. `owned == true` is an `own<R>` —
348+
/// permitted only if the producer never drops the handle while the
349+
/// consumer still references it (the drop-while-referenced hazard
350+
/// meld cannot rule out statically). `descriptor` is the offending
351+
/// `stream<…>` / `future<…>` type descriptor.
352+
ResourceLifetime {
353+
component: usize,
354+
descriptor: String,
355+
resource_type_id: u32,
356+
owned: bool,
357+
},
332358
}
333359

334360
/// Return every `stream<T>` element type reachable from the given
@@ -685,6 +711,64 @@ pub fn validate_stream_pair_graph(
685711
issues
686712
}
687713

714+
/// Check (iv): flag any `stream<T>` / `future<T>` whose element type is
715+
/// a resource handle (`own<R>` / `borrow<R>`).
716+
///
717+
/// The P3Async descriptor records the element type as the Debug form of
718+
/// the wasmparser `ComponentValType`, so a handle element appears as
719+
/// `stream<Type(N)>` where component type index `N` resolves to a
720+
/// `Defined(Own(R))` / `Defined(Borrow(R))`. We parse the `Type(N)`
721+
/// index out and reuse [`ParsedComponent::resolve_to_resource`] — the
722+
/// same `Type(idx)` → handle chase meld already applies to function
723+
/// params — rather than trusting the Debug text for the handle kind.
724+
///
725+
/// Limitations (acknowledged, not targets): only a *direct* handle
726+
/// element is detected. A handle nested inside a composite element
727+
/// (`stream<list<own<R>>>` → `stream<List(..)>`) is not flagged, the
728+
/// same boundary as [`stream_elements_in_valtype`].
729+
pub fn resource_lifetime_issues(components: &[ParsedComponent]) -> Vec<StreamValidationIssue> {
730+
let mut issues = Vec::new();
731+
for (ci, comp) in components.iter().enumerate() {
732+
for ty in &comp.types {
733+
let ComponentTypeKind::P3Async(desc) = &ty.kind else {
734+
continue;
735+
};
736+
let Some(inner) = desc
737+
.strip_prefix("stream<")
738+
.or_else(|| desc.strip_prefix("future<"))
739+
.and_then(|s| s.strip_suffix('>'))
740+
else {
741+
continue;
742+
};
743+
let Some(idx) = parse_type_index(inner.trim()) else {
744+
continue;
745+
};
746+
if let Some((resource_type_id, owned)) =
747+
comp.resolve_to_resource(&ComponentValType::Type(idx))
748+
{
749+
issues.push(StreamValidationIssue::ResourceLifetime {
750+
component: ci,
751+
descriptor: desc.clone(),
752+
resource_type_id,
753+
owned,
754+
});
755+
}
756+
}
757+
}
758+
issues
759+
}
760+
761+
/// Parse the wasmparser `ComponentValType::Type(N)` Debug form
762+
/// (`"Type(N)"`) back to its index. Returns `None` for any other shape
763+
/// (e.g. `"Primitive(U8)"`, `"List(..)"`).
764+
fn parse_type_index(s: &str) -> Option<u32> {
765+
s.strip_prefix("Type(")?
766+
.strip_suffix(')')?
767+
.trim()
768+
.parse()
769+
.ok()
770+
}
771+
688772
/// Cycle-only sub-pass — exposed so tests that only care about (iii)
689773
/// don't have to construct `ParsedComponent` fixtures.
690774
pub fn cycle_issues_from_pairs(graph: &StreamPairGraph) -> Vec<StreamValidationIssue> {
@@ -1544,4 +1628,98 @@ mod tests {
15441628
"matching stream types on resolved edge must not raise; got {issues:?}"
15451629
);
15461630
}
1631+
1632+
// ─── (iv) resource lifetime across async boundaries ──────────────
1633+
1634+
/// Pin the wasmparser `ComponentValType::Type(N)` Debug form that
1635+
/// `parse_type_index` depends on. If a wasmparser upgrade changes
1636+
/// this, the resource-lifetime check would silently stop detecting
1637+
/// handle elements — this test fails first.
1638+
#[test]
1639+
fn wasmparser_type_debug_form_is_stable() {
1640+
assert_eq!(
1641+
format!("{:?}", wasmparser::ComponentValType::Type(7)),
1642+
"Type(7)"
1643+
);
1644+
assert_eq!(parse_type_index("Type(7)"), Some(7));
1645+
assert_eq!(parse_type_index("Primitive(U8)"), None);
1646+
assert_eq!(parse_type_index("List(..)"), None);
1647+
}
1648+
1649+
/// Build a component whose type table is all defined types, with a
1650+
/// matching all-`Defined` `component_type_defs` so
1651+
/// `get_type_definition(idx)` (and thus `resolve_to_resource`)
1652+
/// resolves `Type(idx)` to `types[idx]`.
1653+
fn comp_with_defined_types(types: Vec<ComponentType>) -> ParsedComponent {
1654+
let defs = vec![crate::parser::ComponentTypeDef::Defined; types.len()];
1655+
let mut c = make_component(vec![], vec![], types, vec![]);
1656+
c.component_type_defs = defs;
1657+
c
1658+
}
1659+
1660+
fn defined(vt: ComponentValType) -> ComponentType {
1661+
ComponentType {
1662+
kind: ComponentTypeKind::Defined(vt),
1663+
}
1664+
}
1665+
1666+
#[test]
1667+
fn ls_r_14_borrow_handle_in_stream_flagged() {
1668+
// types[0] = stream<Type(1)>; types[1] = borrow<resource 42>.
1669+
let comp = comp_with_defined_types(vec![
1670+
stream_type("Type(1)"),
1671+
defined(ComponentValType::Borrow(42)),
1672+
]);
1673+
let issues = resource_lifetime_issues(&[comp]);
1674+
assert_eq!(issues.len(), 1, "borrow-in-stream must flag: {issues:?}");
1675+
match &issues[0] {
1676+
StreamValidationIssue::ResourceLifetime {
1677+
component,
1678+
resource_type_id,
1679+
owned,
1680+
..
1681+
} => {
1682+
assert_eq!(*component, 0);
1683+
assert_eq!(*resource_type_id, 42);
1684+
assert!(!owned, "borrow ⇒ owned = false");
1685+
}
1686+
other => panic!("expected ResourceLifetime, got {other:?}"),
1687+
}
1688+
}
1689+
1690+
#[test]
1691+
fn ls_r_14_own_handle_in_future_flagged_as_owned() {
1692+
// types[0] = future<Type(1)>; types[1] = own<resource 5>.
1693+
let future_ty = ComponentType {
1694+
kind: ComponentTypeKind::P3Async("future<Type(1)>".to_string()),
1695+
};
1696+
let comp = comp_with_defined_types(vec![future_ty, defined(ComponentValType::Own(5))]);
1697+
let issues = resource_lifetime_issues(&[comp]);
1698+
assert_eq!(issues.len(), 1, "own-in-future must flag: {issues:?}");
1699+
assert!(
1700+
matches!(
1701+
&issues[0],
1702+
StreamValidationIssue::ResourceLifetime {
1703+
owned: true,
1704+
resource_type_id: 5,
1705+
..
1706+
}
1707+
),
1708+
"own ⇒ owned = true; got {:?}",
1709+
issues[0]
1710+
);
1711+
}
1712+
1713+
#[test]
1714+
fn ls_r_14_primitive_element_stream_not_flagged() {
1715+
// A plain data stream carries no handle — must not flag.
1716+
let comp = comp_with_defined_types(vec![
1717+
stream_type("Primitive(U8)"),
1718+
defined(ComponentValType::Primitive(PrimitiveValType::U8)),
1719+
]);
1720+
assert!(
1721+
resource_lifetime_issues(&[comp]).is_empty(),
1722+
"primitive-element stream must not flag (iv)"
1723+
);
1724+
}
15471725
}

meld-core/src/parser.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1948,7 +1948,7 @@ impl ParsedComponent {
19481948
///
19491949
/// Returns `Some((resource_type_id, is_owned))` for `Own(T)`, `Borrow(T)`,
19501950
/// and `Type(idx)` that resolves to a `Defined(Own(T))` or `Defined(Borrow(T))`.
1951-
fn resolve_to_resource(&self, ty: &ComponentValType) -> Option<(u32, bool)> {
1951+
pub(crate) fn resolve_to_resource(&self, ty: &ComponentValType) -> Option<(u32, bool)> {
19521952
match ty {
19531953
ComponentValType::Own(id) => Some((*id, true)),
19541954
ComponentValType::Borrow(id) => Some((*id, false)),

meld-core/src/resolver.rs

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1563,15 +1563,26 @@ impl Resolver {
15631563
));
15641564

15651565
// Issue #142: static stream validation. Catches dataflow cycles
1566-
// (SCC ≥ 3 in the producer→consumer graph) and type-mismatches
1567-
// on stream-typed import edges. See the module comment in
1568-
// p3_stream.rs for the precision boundary on (i).
1569-
if let Some(spg) = graph.stream_pair_graph.as_ref() {
1570-
let issues = crate::p3_stream::validate_stream_pair_graph(
1571-
components,
1572-
&graph.resolved_imports,
1573-
spg,
1574-
);
1566+
// (SCC ≥ 3 in the producer→consumer graph), type-mismatches on
1567+
// stream-typed import edges (i/iii), and resource handles
1568+
// carried as stream/future element types (iv). See the module
1569+
// comment in p3_stream.rs for the precision boundary on (i) and
1570+
// why (ii) bounded-channel capacity is not applicable.
1571+
{
1572+
let mut issues = graph
1573+
.stream_pair_graph
1574+
.as_ref()
1575+
.map(|spg| {
1576+
crate::p3_stream::validate_stream_pair_graph(
1577+
components,
1578+
&graph.resolved_imports,
1579+
spg,
1580+
)
1581+
})
1582+
.unwrap_or_default();
1583+
// (iv) resource lifetime — scans component types directly, so
1584+
// it runs even when no cross-component stream pairs were found.
1585+
issues.extend(crate::p3_stream::resource_lifetime_issues(components));
15751586
if !issues.is_empty() {
15761587
let mut lines = Vec::with_capacity(issues.len());
15771588
for issue in &issues {
@@ -1591,6 +1602,17 @@ impl Resolver {
15911602
" · cycle: components {component_cycle:?} form a closed stream-pair loop (SCC size ≥ 3)"
15921603
));
15931604
}
1605+
crate::p3_stream::StreamValidationIssue::ResourceLifetime {
1606+
component,
1607+
descriptor,
1608+
resource_type_id,
1609+
owned,
1610+
} => {
1611+
let kind = if *owned { "own" } else { "borrow" };
1612+
lines.push(format!(
1613+
" · resource lifetime: component {component} carries a {kind}<resource {resource_type_id}> handle as a `{descriptor}` element — a handle's lifetime cannot be guaranteed across the async stream/future boundary (#142 iv)"
1614+
));
1615+
}
15941616
}
15951617
}
15961618
return Err(crate::error::Error::StreamValidation(lines.join("\n")));

0 commit comments

Comments
 (0)