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}