@@ -810,6 +810,20 @@ pub enum StreamWriteResult {
810810impl 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