Skip to content

Commit 5f7bead

Browse files
avrabeclaude
andauthored
fix(p3-async): mixed-mode stackful, type classification, stream-write over-count (LS-A-12/13/14) (#153)
Three independent defects in meld-core/src/p3_async.rs surfaced by the post-v0.8.0 Mythos delta-pass on the file. 1. LS-A-12 — P3AsyncFeatures::uses_stackful_lift mis-classifies mixed-mode uses_stackful_lift() was derived as `uses_async_lift && !uses_callback_lift`, a conjunction of two component-wide existential flags. A component that declares one callback-mode async lift AND one stackful-mode async lift sets both flags to true, so the derived predicate returned false even though a stackful lift was present. Fix: new independent field `uses_stackful_lift_internal: bool` set per-lift in detect_features. uses_callback_lift remains "any lift has callback"; uses_stackful_lift_internal is "any lift is stackful". The dispatcher in adapter/fact.rs already routes per-site based on the merged module's [callback]<export> companion, so this never reached the emitter — but downstream consumers following the public API would misroute. Bug shipped in #146 / v0.8.0. 2. LS-A-13 — StreamWriteResult::decode folds over-count into Complete decode(ret, requested) classified any non-negative ret with `written >= requested` as Complete, including the out-of-contract `written > requested` case. A buggy or hostile runtime returning n > data_len would silently drive callers to advance their producer cursor past the source buffer. Fix: split into three explicit arms — `==` => Complete, `<` => Partial, `>` => Unknown(ret). 3. LS-A-14 — detect_features substring matching misclassifies future<stream<...>> detect_features classified P3Async types via desc.contains("stream") / desc.contains("future"). A nested type future<stream<u8>> matched "stream" first and landed in stream_types instead of future_types. Fix: classify on the root constructor of the description (the prefix up to the first '<'), not on substring containment. Tests (4 new): - stackful_lift_is_existential_over_lifts (replaces single-lift test) - ls_a_12_mixed_mode_component_reports_stackful - ls_a_13_stream_write_over_count_is_unknown_not_complete - ls_a_14_future_of_stream_classifies_as_future LS-A-12 / LS-A-13 / LS-A-14 added to safety/stpa/loss-scenarios.yaml. Discovered by the post-v0.8.0 Mythos delta-pass sweep (gate from #151). Refs: LS-A-12, LS-A-13, LS-A-14 Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b313dda commit 5f7bead

3 files changed

Lines changed: 338 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,26 @@ All notable changes to this project will be documented in this file.
6666
pinned by `ls_a_20_flags_canonical_abi_matches_spec` (covers
6767
N=1/8/9/17/32/33) and `ls_a_20_flags_parser_produces_flags_variant`.
6868

69+
- **P3 async detection: mixed-mode stackful, substring type classification,
70+
stream-write over-count** (LS-A-12, LS-A-13, LS-A-14). Three independent
71+
defects in `meld-core/src/p3_async.rs` surfaced by the post-v0.8.0 Mythos
72+
delta-pass.
73+
- `P3AsyncFeatures::uses_stackful_lift()` was derived as `uses_async_lift
74+
&& !uses_callback_lift`, mis-classifying mixed-mode components (one
75+
callback-mode lift + one stackful-mode lift) as callback-only.
76+
Now tracks the stackful presence via a new independent
77+
`uses_stackful_lift_internal: bool` set per-lift in `detect_features`.
78+
- `StreamWriteResult::decode(ret, requested)` folded any
79+
`written >= requested` into `Complete { written }`, including the
80+
out-of-contract `written > requested` case. A buggy / hostile runtime
81+
returning `n > data_len` could drive callers to advance their cursor
82+
past the source buffer. Now classifies as `Unknown(ret)` so callers
83+
see a clear contract violation instead of silent corruption.
84+
- `detect_features` classified P3Async types via `desc.contains("stream")`
85+
/ `desc.contains("future")`, so `future<stream<u8>>` matched "stream"
86+
first and landed in `stream_types`. Now classifies on the **root
87+
constructor** of the type description.
88+
6989
### Added
7090

7191
- **Mythos delta-pass CI gate** (`.github/workflows/mythos-gate.yml`,

meld-core/src/p3_async.rs

Lines changed: 221 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,20 @@ pub enum StreamWriteResult {
810810
impl StreamWriteResult {
811811
/// Decode the raw `i32` return value of `stream_write` against the
812812
/// `requested` byte count the producer passed in.
813+
///
814+
/// Per the ABI contract on [`HostIntrinsic::StreamWrite`]:
815+
/// * `n == data_len` ⇒ [`Self::Complete`]
816+
/// * `0 <= n < data_len` ⇒ [`Self::Partial`]
817+
/// * `n < 0` ⇒ [`Self::Error`] (or [`Self::Unknown`] if the code is
818+
/// not a recognised [`AbiError`])
819+
///
820+
/// `n > data_len` is **out-of-contract**. A previous version of this
821+
/// decoder folded `n > requested` into `Complete { written: n }`,
822+
/// which let a buggy / hostile runtime drive callers to advance
823+
/// their producer cursor past the source buffer with no trap. The
824+
/// current implementation classifies an over-count as [`Self::Unknown`]
825+
/// so callers see a clear "contract violation, do not advance" rather
826+
/// than silent corruption (LS-A-13).
813827
pub const fn decode(ret: i32, requested: u32) -> Self {
814828
if ret < 0 {
815829
match AbiError::from_i32(ret) {
@@ -818,10 +832,15 @@ impl StreamWriteResult {
818832
}
819833
} else {
820834
let written = ret as u32;
821-
if written >= requested {
835+
if written == requested {
822836
Self::Complete { written }
823-
} else {
837+
} else if written < requested {
824838
Self::Partial { written, requested }
839+
} else {
840+
// n > requested is out-of-contract. Surface it as
841+
// Unknown(ret) rather than Complete, so callers never
842+
// advance the producer cursor past the source buffer.
843+
Self::Unknown(ret)
825844
}
826845
}
827846
}
@@ -911,6 +930,15 @@ pub struct P3AsyncFeatures {
911930
/// Whether any canonical lift carries a `(callback ...)` option (P3
912931
/// callback mode for async exports).
913932
pub uses_callback_lift: bool,
933+
/// Whether any canonical lift is *stackful* — i.e. `async` without
934+
/// a `(callback ...)` option. This is tracked **independently** of
935+
/// [`uses_callback_lift`] because the Component Model permits a
936+
/// single component to declare both kinds of lifts at once (one
937+
/// callback-mode export and one stackful-mode export). A derived
938+
/// predicate `uses_async_lift && !uses_callback_lift` would
939+
/// misclassify mixed-mode components as callback-only — see
940+
/// LS-A-12.
941+
pub uses_stackful_lift_internal: bool,
914942
/// Whether any canonical lower uses `(canon lower ... async ...)`.
915943
pub uses_async_lower: bool,
916944
}
@@ -923,6 +951,7 @@ impl P3AsyncFeatures {
923951
&& self.required_intrinsics.is_empty()
924952
&& !self.uses_async_lift
925953
&& !self.uses_callback_lift
954+
&& !self.uses_stackful_lift_internal
926955
&& !self.uses_async_lower
927956
}
928957

@@ -936,21 +965,27 @@ impl P3AsyncFeatures {
936965
/// `true` if the component uses any *control-plane* (async lift/lower /
937966
/// callback) construct.
938967
pub fn uses_control_plane(&self) -> bool {
939-
self.uses_async_lift || self.uses_callback_lift || self.uses_async_lower
968+
self.uses_async_lift
969+
|| self.uses_callback_lift
970+
|| self.uses_stackful_lift_internal
971+
|| self.uses_async_lower
940972
}
941973

942-
/// `true` if any async lift is **stackful** — i.e. uses `async`
943-
/// without a `(callback ...)` option.
974+
/// `true` if any async lift in the component is **stackful** — i.e.
975+
/// uses `(canon lift ... async ...)` without a `(callback ...)` option.
944976
///
945977
/// Stackful mode requires the trampoline shape declared in [`thread`]
946978
/// (`thread_new` / `thread_switch_to` / `thread_yield` / `thread_exit`).
947-
/// The emitter for that trampoline ships in a follow-up PR within
948-
/// the v0.8.0 milestone (SR-32, #140). Until then, callers SHOULD
949-
/// surface a clear "stackful lifting not yet emitted by meld" error
950-
/// rather than silently fall through to the callback path, which
951-
/// would produce a wrong trampoline.
979+
///
980+
/// The predicate is an **existential** over the component's lifts:
981+
/// it returns `true` if *any* lift is stackful, regardless of whether
982+
/// other lifts use callback mode. A component that declares both a
983+
/// callback-mode lift and a stackful-mode lift correctly returns
984+
/// `true` here so the dispatcher can route the stackful lift to the
985+
/// stackful emitter and the callback lift to the callback emitter
986+
/// (SR-32, LS-A-12).
952987
pub fn uses_stackful_lift(&self) -> bool {
953-
self.uses_async_lift && !self.uses_callback_lift
988+
self.uses_stackful_lift_internal
954989
}
955990
}
956991

@@ -962,31 +997,41 @@ pub fn detect_features(comp: &ParsedComponent) -> P3AsyncFeatures {
962997
let mut required: std::collections::BTreeSet<HostIntrinsic> = std::collections::BTreeSet::new();
963998

964999
// Walk component-level types looking for stream/future declarations.
1000+
// Classify on the **root constructor** of the type description
1001+
// (the prefix up to the first '<'), not on substring containment —
1002+
// `future<stream<u8>>` is a future type even though its description
1003+
// contains "stream" as a parameter (LS-A-14).
9651004
for (idx, ty) in comp.types.iter().enumerate() {
9661005
if let ComponentTypeKind::P3Async(desc) = &ty.kind {
967-
if desc.contains("stream") {
968-
feats.stream_types.push((idx as u32, desc.clone()));
969-
} else if desc.contains("future") {
970-
feats.future_types.push((idx as u32, desc.clone()));
1006+
let root = desc.split('<').next().unwrap_or(desc).trim();
1007+
match root {
1008+
"stream" => feats.stream_types.push((idx as u32, desc.clone())),
1009+
"future" => feats.future_types.push((idx as u32, desc.clone())),
1010+
_ => {
1011+
// `map<K,V>` and any other P3Async kind not handled
1012+
// by this ABI; tracked in comp.p3_async_features for
1013+
// the warning path.
1014+
}
9711015
}
972-
// `map<K,V>` is also P3Async but not handled by this ABI;
973-
// it's tracked in `comp.p3_async_features` for the warning path.
9741016
}
9751017
}
9761018

9771019
// Walk canonicals to find any data-plane intrinsic the component
978-
// already declares, and detect async lift/lower options.
1020+
// already declares, and detect async lift/lower options. The
1021+
// callback-vs-stackful classification is **per lift**: a single
1022+
// component may declare one callback-mode lift and one stackful-
1023+
// mode lift, so we set the two booleans independently (LS-A-12).
9791024
for entry in &comp.canonical_functions {
9801025
if let Some(intr) = HostIntrinsic::from_canonical_entry(entry) {
9811026
required.insert(intr);
9821027
}
9831028
match entry {
984-
CanonicalEntry::Lift { options, .. } => {
985-
if options.async_ {
986-
feats.uses_async_lift = true;
987-
}
1029+
CanonicalEntry::Lift { options, .. } if options.async_ => {
1030+
feats.uses_async_lift = true;
9881031
if options.callback.is_some() {
9891032
feats.uses_callback_lift = true;
1033+
} else {
1034+
feats.uses_stackful_lift_internal = true;
9901035
}
9911036
}
9921037
CanonicalEntry::Lower { options, .. } if options.async_ => {
@@ -1611,27 +1656,174 @@ mod tests {
16111656

16121657
/// SR-32 / #140 — `P3AsyncFeatures::uses_stackful_lift()` derived flag.
16131658
///
1614-
/// A component is in **stackful** mode iff it has an async lift
1615-
/// without a `(callback ...)` option. This pins the contract that
1616-
/// callers can use to decide whether to invoke the stackful emitter
1617-
/// (when it ships) or surface a "not yet emitted" error.
1659+
/// `uses_stackful_lift()` reports whether **any** lift in the
1660+
/// component is stackful — i.e. async without callback. It is
1661+
/// independent of `uses_callback_lift`: a component that declares
1662+
/// both a callback-mode lift and a stackful-mode lift returns true
1663+
/// here regardless. This pins the LS-A-12 fix.
16181664
#[test]
1619-
fn stackful_lift_is_async_without_callback() {
1665+
fn stackful_lift_is_existential_over_lifts() {
16201666
let mut feats = P3AsyncFeatures::default();
16211667
assert!(!feats.uses_stackful_lift(), "empty: no stackful");
16221668

1669+
// Only a stackful lift exists.
16231670
feats.uses_async_lift = true;
1671+
feats.uses_stackful_lift_internal = true;
16241672
feats.uses_callback_lift = false;
1625-
assert!(feats.uses_stackful_lift(), "async + !callback => stackful");
1673+
assert!(
1674+
feats.uses_stackful_lift(),
1675+
"async + stackful_internal => stackful"
1676+
);
16261677

1678+
// Mixed: both a callback-mode and a stackful-mode lift.
16271679
feats.uses_callback_lift = true;
1680+
assert!(
1681+
feats.uses_stackful_lift(),
1682+
"mixed-mode component (LS-A-12): stackful_lift remains true \
1683+
when ANY lift is stackful, regardless of sibling callback lifts"
1684+
);
1685+
1686+
// Only callback lifts.
1687+
feats.uses_stackful_lift_internal = false;
16281688
assert!(
16291689
!feats.uses_stackful_lift(),
1630-
"async + callback => callback mode, NOT stackful"
1690+
"callback-only component: not stackful"
16311691
);
16321692

1693+
// No async at all.
16331694
feats.uses_async_lift = false;
16341695
feats.uses_callback_lift = false;
1696+
feats.uses_stackful_lift_internal = false;
16351697
assert!(!feats.uses_stackful_lift(), "no async => no stackful");
16361698
}
1699+
1700+
/// LS-A-12 regression: a component with both a callback-mode lift
1701+
/// and a stackful-mode lift must report `uses_stackful_lift() == true`.
1702+
/// Prior to the fix, `uses_stackful_lift` was derived as
1703+
/// `uses_async_lift && !uses_callback_lift`, which mis-classified
1704+
/// mixed-mode components as callback-only.
1705+
#[test]
1706+
fn ls_a_12_mixed_mode_component_reports_stackful() {
1707+
use crate::parser::{
1708+
CanonStringEncoding, CanonicalEntry, CanonicalOptions, ParsedComponent,
1709+
};
1710+
1711+
fn lift(callback: Option<u32>) -> CanonicalEntry {
1712+
CanonicalEntry::Lift {
1713+
core_func_index: 0,
1714+
type_index: 0,
1715+
options: CanonicalOptions {
1716+
string_encoding: CanonStringEncoding::Utf8,
1717+
memory: Some(0),
1718+
realloc: None,
1719+
post_return: None,
1720+
async_: true,
1721+
callback,
1722+
},
1723+
}
1724+
}
1725+
1726+
let mut comp = ParsedComponent {
1727+
name: None,
1728+
core_modules: vec![],
1729+
imports: vec![],
1730+
exports: vec![],
1731+
types: vec![],
1732+
instances: vec![],
1733+
canonical_functions: vec![lift(Some(7)), lift(None)],
1734+
sub_components: vec![],
1735+
component_aliases: vec![],
1736+
component_instances: vec![],
1737+
core_entity_order: vec![],
1738+
component_func_defs: vec![],
1739+
component_instance_defs: vec![],
1740+
component_type_defs: vec![],
1741+
original_size: 0,
1742+
original_hash: String::new(),
1743+
depth_0_sections: vec![],
1744+
p3_async_features: vec![],
1745+
};
1746+
// Suppress unused-let warning if the visibility test grows.
1747+
let _ = &mut comp;
1748+
1749+
let feats = detect_features(&comp);
1750+
assert!(feats.uses_async_lift);
1751+
assert!(feats.uses_callback_lift, "lift 1 has callback");
1752+
assert!(
1753+
feats.uses_stackful_lift(),
1754+
"mixed-mode: lift 2 is stackful (no callback), so the \
1755+
existential predicate must be true even though lift 1 is \
1756+
callback-mode"
1757+
);
1758+
}
1759+
1760+
/// LS-A-13 regression: `StreamWriteResult::decode` previously folded
1761+
/// any `written >= requested` into `Complete { written }`, including
1762+
/// the out-of-contract `written > requested` case. A buggy or hostile
1763+
/// runtime returning `n > data_len` would silently drive callers to
1764+
/// advance their producer cursor past the source buffer.
1765+
#[test]
1766+
fn ls_a_13_stream_write_over_count_is_unknown_not_complete() {
1767+
let r = StreamWriteResult::decode(100, 10);
1768+
assert!(
1769+
matches!(r, StreamWriteResult::Unknown(100)),
1770+
"decode(100, 10) must classify as Unknown(100) not Complete; \
1771+
got {r:?}"
1772+
);
1773+
1774+
// Exact-match still Complete.
1775+
assert!(matches!(
1776+
StreamWriteResult::decode(10, 10),
1777+
StreamWriteResult::Complete { written: 10 }
1778+
));
1779+
// Partial unchanged.
1780+
assert!(matches!(
1781+
StreamWriteResult::decode(5, 10),
1782+
StreamWriteResult::Partial {
1783+
written: 5,
1784+
requested: 10
1785+
}
1786+
));
1787+
}
1788+
1789+
/// LS-A-14 regression: `detect_features` classified types by
1790+
/// `desc.contains("stream")` / `desc.contains("future")`, so
1791+
/// `future<stream<u8>>` matched "stream" first and landed in
1792+
/// `stream_types` instead of `future_types`. Classify on the root
1793+
/// constructor.
1794+
#[test]
1795+
fn ls_a_14_future_of_stream_classifies_as_future() {
1796+
use crate::parser::{ComponentType, ComponentTypeKind, ParsedComponent};
1797+
1798+
let comp = ParsedComponent {
1799+
name: None,
1800+
core_modules: vec![],
1801+
imports: vec![],
1802+
exports: vec![],
1803+
types: vec![ComponentType {
1804+
kind: ComponentTypeKind::P3Async("future<stream<u8>>".to_string()),
1805+
}],
1806+
instances: vec![],
1807+
canonical_functions: vec![],
1808+
sub_components: vec![],
1809+
component_aliases: vec![],
1810+
component_instances: vec![],
1811+
core_entity_order: vec![],
1812+
component_func_defs: vec![],
1813+
component_instance_defs: vec![],
1814+
component_type_defs: vec![],
1815+
original_size: 0,
1816+
original_hash: String::new(),
1817+
depth_0_sections: vec![],
1818+
p3_async_features: vec![],
1819+
};
1820+
let feats = detect_features(&comp);
1821+
assert_eq!(
1822+
feats.future_types.len(),
1823+
1,
1824+
"`future<stream<u8>>` must classify as future (root \
1825+
constructor), not stream"
1826+
);
1827+
assert!(feats.stream_types.is_empty());
1828+
}
16371829
}

0 commit comments

Comments
 (0)