dwara_core/wasm/
adapter.rs

1//! The WASM-to-unified-chain adapter (DW-119).
2//!
3//! [`WasmChainAdapter`] bridges the proxy-wasm host's per-request
4//! [`PluginInstances`] into the unified [`crate::plugins::PluginChain`]
5//! by implementing [`crate::plugins::WasmDispatch`]. The dataplane
6//! constructs one adapter per request (holding the `PluginInstances`
7//! for the route's WASM plugins) and passes it to `PluginChain::new`;
8//! the chain calls back into the adapter once per WASM entry per phase,
9//! passing the entry's NAME, and the adapter dispatches to exactly that
10//! plugin's instance (so a chain interleaving native filters and WASM
11//! plugins in one phase runs each entry exactly once, in config order).
12//!
13//! This module (and the whole proxy-wasm host) compiles unconditionally
14//! in the OSS build — there are no cargo features for it. The unified
15//! chain's no-wasm adapter (`crate::plugins::NoWasm`) exists for
16//! native-only chains and tests.
17//!
18//! Dependency direction: `wasm` depends on `plugins` (downward —
19//! `plugins` sits below `wasm` in the dependency table). The adapter
20//! converts `wasm::host::LocalResponse` to
21//! `plugins::LocalResponse` at the dispatch boundary, and attributes
22//! traps to the named plugin so the dataplane can record
23//! `dwara_plugin_failures_total{name,reason}` (DW-157).
24//!
25//! ## HTTP callouts: the pause/resume seam (DW-167)
26//!
27//! When a phase dispatch ends with the plugin having registered
28//! `proxy_http_call` callouts ([`PhaseResult::Pause`]), the adapter
29//! records WHICH plugin paused and reports
30//! [`ChainOutcome::CalloutPending`] up the chain; the chain stops and
31//! the async driver in `dataplane::plugin_dispatch` takes over:
32//!
33//! 1. [`WasmChainAdapter::pending_callouts`] drains the paused
34//!    instance's queue (FIFO) as transport-ready [`CalloutRequest`]s;
35//! 2. the driver performs each exchange off-thread
36//!    (`spawn_blocking` around the synchronous client) and records the
37//!    `dwara_plugin_callouts_total{name,outcome}` metric;
38//! 3. [`WasmChainAdapter::deliver_callout`] re-enters the instance
39//!    (synchronous wasmtime) with `proxy_on_http_call_response` and
40//!    returns the plugin's resumed outcome — Continue, a local
41//!    response, a trap, or another `CalloutPending` (the plugin
42//!    dispatched again; the driver loops, bounded);
43//! 4. on Continue the driver reads the resumed plugin's phase payload
44//!    back through this adapter and continues the chain from the entry
45//!    AFTER the paused one (`PluginChain::resume_*`).
46//!
47//! The wasm runner stays fully synchronous; only the driver awaits.
48
49use crate::config::PluginPhase;
50use crate::plugins::{ChainOutcome, LocalResponse, WasmDispatch};
51use crate::wasm::callout::CalloutRequest;
52use crate::wasm::callout::CalloutResponse;
53use crate::wasm::host::PendingCallout;
54use crate::wasm::host::PhaseResult;
55use crate::wasm::runner::PluginInstances;
56
57/// Convert a `wasm::host::LocalResponse` to the shared
58/// `plugins::LocalResponse` (structurally identical; the canonical
59/// type lives in the lower `plugins` domain).
60fn convert_local(resp: crate::wasm::host::LocalResponse) -> LocalResponse {
61    LocalResponse {
62        status: resp.status,
63        headers: resp.headers,
64        body: resp.body,
65    }
66}
67
68/// The per-request WASM dispatch adapter.
69///
70/// Holds the proxy-wasm host's [`PluginInstances`] for the route's WASM
71/// plugins and implements [`WasmDispatch`] so the unified
72/// [`crate::plugins::PluginChain`] can drive them alongside native
73/// filters with no dataplane-visible difference in attachment semantics.
74pub struct WasmChainAdapter {
75    instances: PluginInstances,
76    /// The plugin that paused for callouts (DW-167): the driver
77    /// performs its pending exchanges, delivers the responses to it,
78    /// and reads its resumed phase payload back. `None` between
79    /// phases.
80    paused: Option<(String, PluginPhase)>,
81}
82
83impl WasmChainAdapter {
84    /// Wrap a per-request [`PluginInstances`] (created by
85    /// [`crate::wasm::runner::PluginRunner::instantiate`]).
86    pub fn new(instances: PluginInstances) -> Self {
87        Self {
88            instances,
89            paused: None,
90        }
91    }
92
93    /// Whether a plugin is paused for callouts (the driver's loop
94    /// condition, DW-167).
95    pub fn has_pause(&self) -> bool {
96        self.paused.is_some()
97    }
98
99    /// The paused plugin's name (failure attribution for the loop
100    /// guard and callout errors; falls back to the caller's choice).
101    pub fn paused_plugin(&self) -> Option<&str> {
102        self.paused.as_ref().map(|(name, _)| name.as_str())
103    }
104
105    /// Drain the paused plugin's pending callouts as transport-ready
106    /// requests (FIFO — the order the plugin dispatched them). `None`
107    /// when nothing is paused (the driver's defensive path).
108    pub fn pending_callouts(&mut self) -> Option<(String, Vec<CalloutRequest>)> {
109        let (name, _) = self.paused.as_ref()?;
110        let name = name.clone();
111        let pending: Vec<PendingCallout> = match self.instances.instance_mut(&name) {
112            Some(inst) => inst.take_pending_callouts(),
113            None => Vec::new(),
114        };
115        let requests = pending
116            .into_iter()
117            .map(|p| CalloutRequest {
118                plugin: name.clone(),
119                token: p.token,
120                uri: p.uri,
121                method: p.method,
122                headers: p.headers,
123                body: p.body,
124                timeout_ms: p.timeout_ms,
125            })
126            .collect();
127        Some((name, requests))
128    }
129
130    /// Re-enqueue callouts the driver drained but did not perform: a
131    /// mid-delivery re-dispatch (`ChainOutcome::CalloutPending` while
132    /// queued requests remain) must not abandon them — the remainder
133    /// goes back AHEAD of the new queue, preserving dispatch order,
134    /// so every dispatched callout is eventually performed (or the
135    /// loop guard trips). No-op when nothing is paused.
136    pub fn requeue_callouts(&mut self, requests: Vec<CalloutRequest>) {
137        let Some((name, _)) = self.paused.as_ref() else {
138            return;
139        };
140        let name = name.clone();
141        let pending = requests
142            .into_iter()
143            .map(|r| PendingCallout {
144                token: r.token,
145                uri: r.uri,
146                method: r.method,
147                headers: r.headers,
148                body: r.body,
149                timeout_ms: r.timeout_ms,
150            })
151            .collect();
152        if let Some(inst) = self.instances.instance_mut(&name) {
153            inst.requeue_callouts(pending);
154        }
155    }
156
157    /// Deliver a completed callout response to the paused plugin
158    /// (synchronous wasmtime re-entry) and collect its resumed
159    /// outcome. [`ChainOutcome::CalloutPending`] means the plugin
160    /// dispatched another callout inside the callback — keep driving.
161    /// On every terminal outcome the pause record is cleared.
162    pub fn deliver_callout(&mut self, token: u32, resp: &CalloutResponse) -> ChainOutcome {
163        let Some((name, _)) = self.paused.clone() else {
164            return ChainOutcome::Continue;
165        };
166        let outcome = match self.instances.instance_mut(&name) {
167            Some(inst) => inst.deliver_callout_response(token, resp),
168            None => PhaseResult::Continue,
169        };
170        self.map_resumed(&name, outcome)
171    }
172
173    /// The paused plugin's resumed `request_headers` payload (its
174    /// post-callout header map) — what the chain resumes from.
175    pub fn paused_request_headers(&self) -> Vec<(String, String)> {
176        self.paused
177            .as_ref()
178            .and_then(|(name, _)| self.instances.instance_request_headers(name))
179            .unwrap_or_default()
180    }
181
182    /// The paused plugin's resumed `response_headers` payload.
183    pub fn paused_response_headers(&self) -> Vec<(String, String)> {
184        self.paused
185            .as_ref()
186            .and_then(|(name, _)| self.instances.instance_response_headers(name))
187            .unwrap_or_default()
188    }
189
190    /// The paused plugin's resumed `request_body` payload.
191    pub fn paused_request_body(&self) -> Vec<u8> {
192        self.paused
193            .as_ref()
194            .and_then(|(name, _)| self.instances.instance_request_body(name))
195            .unwrap_or_default()
196    }
197
198    /// The paused plugin's resumed `response_body` payload.
199    pub fn paused_response_body(&self) -> Vec<u8> {
200        self.paused
201            .as_ref()
202            .and_then(|(name, _)| self.instances.instance_response_body(name))
203            .unwrap_or_default()
204    }
205
206    /// Clear the pause record after the driver collected the resumed
207    /// plugin's phase payload (the record survives the Continue
208    /// delivery so `paused_*` can read the payload afterwards).
209    pub fn clear_pause(&mut self) {
210        self.paused = None;
211    }
212
213    /// Map a resumed [`PhaseResult`] from a callout callback to the
214    /// chain vocabulary. On Continue the pause record STAYS (the
215    /// driver reads the resumed payload through `paused_*` next and
216    /// clears it with [`WasmChainAdapter::clear_pause`]); terminal
217    /// outcomes clear it; another pending round keeps it.
218    fn map_resumed(&mut self, name: &str, result: PhaseResult) -> ChainOutcome {
219        match result {
220            PhaseResult::Continue => ChainOutcome::Continue,
221            PhaseResult::LocalResponse(r) => {
222                self.paused = None;
223                ChainOutcome::LocalResponse(convert_local(r))
224            }
225            PhaseResult::Trap(e) => {
226                self.paused = None;
227                ChainOutcome::Error {
228                    plugin: name.to_string(),
229                    message: e,
230                }
231            }
232            PhaseResult::Pause => ChainOutcome::CalloutPending,
233        }
234    }
235}
236
237impl WasmDispatch for WasmChainAdapter {
238    fn on_request_headers(
239        &mut self,
240        name: &str,
241        headers: Vec<(String, String)>,
242    ) -> (ChainOutcome, Vec<(String, String)>) {
243        // Per-NAME dispatch: the chain interleaves native filters and
244        // WASM entries within a phase and calls this once per WASM
245        // entry, so the adapter must run exactly the named plugin's
246        // instance (running the whole set per call would execute each
247        // plugin once per WASM entry on the route). A name with no
248        // instance (a native-only name filtered out at instantiation)
249        // passes through unchanged.
250        let Some(inst) = self.instances.instance_mut(name) else {
251            return (ChainOutcome::Continue, headers);
252        };
253        match inst.on_request_headers(headers) {
254            PhaseResult::Continue => (ChainOutcome::Continue, inst.request_headers().to_vec()),
255            PhaseResult::LocalResponse(r) => {
256                (ChainOutcome::LocalResponse(convert_local(r)), Vec::new())
257            }
258            PhaseResult::Trap(e) => (
259                ChainOutcome::Error {
260                    plugin: name.to_string(),
261                    message: e,
262                },
263                Vec::new(),
264            ),
265            PhaseResult::Pause => {
266                self.paused = Some((name.to_string(), PluginPhase::RequestHeaders));
267                (
268                    ChainOutcome::CalloutPending,
269                    inst.request_headers().to_vec(),
270                )
271            }
272        }
273    }
274
275    fn on_request_body(&mut self, name: &str, body: Vec<u8>) -> (ChainOutcome, Vec<u8>) {
276        let Some(inst) = self.instances.instance_mut(name) else {
277            return (ChainOutcome::Continue, body);
278        };
279        match inst.on_request_body(body) {
280            PhaseResult::Continue => (ChainOutcome::Continue, inst.request_body().to_vec()),
281            PhaseResult::LocalResponse(r) => {
282                (ChainOutcome::LocalResponse(convert_local(r)), Vec::new())
283            }
284            PhaseResult::Trap(e) => (
285                ChainOutcome::Error {
286                    plugin: name.to_string(),
287                    message: e,
288                },
289                Vec::new(),
290            ),
291            PhaseResult::Pause => {
292                self.paused = Some((name.to_string(), PluginPhase::RequestBody));
293                (ChainOutcome::CalloutPending, inst.request_body().to_vec())
294            }
295        }
296    }
297
298    fn on_response_headers(
299        &mut self,
300        name: &str,
301        headers: Vec<(String, String)>,
302    ) -> (ChainOutcome, Vec<(String, String)>) {
303        let Some(inst) = self.instances.instance_mut(name) else {
304            return (ChainOutcome::Continue, headers);
305        };
306        match inst.on_response_headers(headers) {
307            PhaseResult::Continue => (ChainOutcome::Continue, inst.response_headers().to_vec()),
308            PhaseResult::LocalResponse(r) => {
309                (ChainOutcome::LocalResponse(convert_local(r)), Vec::new())
310            }
311            PhaseResult::Trap(e) => (
312                ChainOutcome::Error {
313                    plugin: name.to_string(),
314                    message: e,
315                },
316                Vec::new(),
317            ),
318            PhaseResult::Pause => {
319                self.paused = Some((name.to_string(), PluginPhase::ResponseHeaders));
320                (
321                    ChainOutcome::CalloutPending,
322                    inst.response_headers().to_vec(),
323                )
324            }
325        }
326    }
327
328    fn on_response_body(&mut self, name: &str, body: Vec<u8>) -> (ChainOutcome, Vec<u8>) {
329        let Some(inst) = self.instances.instance_mut(name) else {
330            return (ChainOutcome::Continue, body);
331        };
332        match inst.on_response_body(body) {
333            PhaseResult::Continue => (ChainOutcome::Continue, inst.response_body().to_vec()),
334            PhaseResult::LocalResponse(r) => {
335                (ChainOutcome::LocalResponse(convert_local(r)), Vec::new())
336            }
337            PhaseResult::Trap(e) => (
338                ChainOutcome::Error {
339                    plugin: name.to_string(),
340                    message: e,
341                },
342                Vec::new(),
343            ),
344            PhaseResult::Pause => {
345                self.paused = Some((name.to_string(), PluginPhase::ResponseBody));
346                (ChainOutcome::CalloutPending, inst.response_body().to_vec())
347            }
348        }
349    }
350
351    fn on_done(&mut self) {
352        self.paused = None;
353        self.instances.on_done();
354    }
355}