@@ -12,9 +12,15 @@ use crate::proto::executor as pb;
1212use crate :: proto:: executor:: executor_client:: ExecutorClient ;
1313use crate :: transport;
1414use anyhow:: { Context , Result , bail} ;
15+ use prost:: Message ;
1516use std:: path:: Path ;
1617use tonic:: transport:: Channel ;
1718
19+ /// Control<->executor protocol limit in bytes. Action inputs and outputs can
20+ /// approach 15 MiB, so 20 MiB leaves protobuf headroom while still bounding
21+ /// memory use. Keep this in sync with `maxMessageSize` in the Go executor.
22+ const MAX_MESSAGE_SIZE : usize = 20 * 1024 * 1024 ;
23+
1824/// Executor health snapshot used to gate dispatch.
1925#[ derive( Debug , Clone ) ]
2026pub struct Health {
@@ -75,7 +81,9 @@ impl ExecutorDispatcher {
7581 None => transport:: connect_lazy ( socket) ,
7682 } ;
7783 ExecutorDispatcher {
78- client : ExecutorClient :: new ( channel) ,
84+ client : ExecutorClient :: new ( channel)
85+ . max_encoding_message_size ( MAX_MESSAGE_SIZE )
86+ . max_decoding_message_size ( MAX_MESSAGE_SIZE ) ,
7987 }
8088 }
8189}
@@ -122,9 +130,16 @@ impl Dispatcher for ExecutorDispatcher {
122130 }
123131
124132 async fn run_action ( & self , raw : Vec < u8 > ) -> Result < Outcome > {
133+ let request = pb:: RunActionRequest { task : raw } ;
134+ if request. encoded_len ( ) > MAX_MESSAGE_SIZE {
135+ return Err ( tonic:: Status :: resource_exhausted ( format ! (
136+ "RunAction request exceeds the {MAX_MESSAGE_SIZE}-byte protocol limit"
137+ ) )
138+ . into ( ) ) ;
139+ }
125140 let mut client = self . client . clone ( ) ;
126141 let mut stream = client
127- . run_action ( pb :: RunActionRequest { task : raw } )
142+ . run_action ( request )
128143 . await
129144 . context ( "executor RunAction failed" ) ?
130145 . into_inner ( ) ;
@@ -152,3 +167,132 @@ impl Dispatcher for ExecutorDispatcher {
152167 }
153168 }
154169}
170+
171+ #[ cfg( all( test, unix) ) ]
172+ mod tests {
173+ use super :: * ;
174+ use crate :: proto:: executor:: executor_server:: { Executor , ExecutorServer } ;
175+ use std:: pin:: Pin ;
176+ use tokio_stream:: Stream ;
177+ use tokio_stream:: wrappers:: UnixListenerStream ;
178+ use tonic:: { Request , Response , Status } ;
179+
180+ #[ derive( Clone ) ]
181+ struct FakeExecutor {
182+ output : Vec < u8 > ,
183+ }
184+
185+ #[ tonic:: async_trait]
186+ impl Executor for FakeExecutor {
187+ type RunActionStream =
188+ Pin < Box < dyn Stream < Item = std:: result:: Result < pb:: RunActionResponse , Status > > + Send > > ;
189+
190+ async fn run_action (
191+ & self ,
192+ _request : Request < pb:: RunActionRequest > ,
193+ ) -> std:: result:: Result < Response < Self :: RunActionStream > , Status > {
194+ let response = pb:: RunActionResponse {
195+ event : Some ( pb:: run_action_response:: Event :: Result ( pb:: ActionResult {
196+ outcome : Some ( pb:: action_result:: Outcome :: Output ( self . output . clone ( ) ) ) ,
197+ } ) ) ,
198+ } ;
199+ Ok ( Response :: new ( Box :: pin ( tokio_stream:: once ( Ok ( response) ) ) ) )
200+ }
201+
202+ async fn health (
203+ & self ,
204+ _request : Request < pb:: HealthRequest > ,
205+ ) -> std:: result:: Result < Response < pb:: HealthResponse > , Status > {
206+ Ok ( Response :: new ( pb:: HealthResponse :: default ( ) ) )
207+ }
208+
209+ async fn sync_keys (
210+ & self ,
211+ _request : Request < pb:: SyncKeysRequest > ,
212+ ) -> std:: result:: Result < Response < pb:: SyncKeysResponse > , Status > {
213+ Ok ( Response :: new ( pb:: SyncKeysResponse :: default ( ) ) )
214+ }
215+ }
216+
217+ async fn test_dispatcher ( output : Vec < u8 > ) -> ( ExecutorDispatcher , tempfile:: TempDir ) {
218+ let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
219+ let socket = dir. path ( ) . join ( "executor.sock" ) ;
220+ let listener = tokio:: net:: UnixListener :: bind ( & socket) . expect ( "bind executor socket" ) ;
221+ tokio:: spawn ( async move {
222+ let service = ExecutorServer :: new ( FakeExecutor { output } )
223+ . max_decoding_message_size ( MAX_MESSAGE_SIZE * 2 )
224+ . max_encoding_message_size ( MAX_MESSAGE_SIZE * 2 ) ;
225+ let _ = tonic:: transport:: Server :: builder ( )
226+ . add_service ( service)
227+ . serve_with_incoming ( UnixListenerStream :: new ( listener) )
228+ . await ;
229+ } ) ;
230+ ( ExecutorDispatcher :: new ( & socket, None ) , dir)
231+ }
232+
233+ fn request_payload_with_encoded_size ( target : usize ) -> Vec < u8 > {
234+ let mut payload = vec ! [ b'a' ; target] ;
235+ loop {
236+ let encoded = pb:: RunActionRequest {
237+ task : payload. clone ( ) ,
238+ }
239+ . encoded_len ( ) ;
240+ match encoded. cmp ( & target) {
241+ std:: cmp:: Ordering :: Equal => return payload,
242+ std:: cmp:: Ordering :: Less => payload. resize ( payload. len ( ) + target - encoded, b'a' ) ,
243+ std:: cmp:: Ordering :: Greater => payload. truncate ( payload. len ( ) - ( encoded - target) ) ,
244+ }
245+ }
246+ }
247+
248+ fn response_output_with_encoded_size ( target : usize ) -> Vec < u8 > {
249+ let mut output = vec ! [ b'a' ; target] ;
250+ loop {
251+ let encoded = pb:: RunActionResponse {
252+ event : Some ( pb:: run_action_response:: Event :: Result ( pb:: ActionResult {
253+ outcome : Some ( pb:: action_result:: Outcome :: Output ( output. clone ( ) ) ) ,
254+ } ) ) ,
255+ }
256+ . encoded_len ( ) ;
257+ match encoded. cmp ( & target) {
258+ std:: cmp:: Ordering :: Equal => return output,
259+ std:: cmp:: Ordering :: Less => output. resize ( output. len ( ) + target - encoded, b'a' ) ,
260+ std:: cmp:: Ordering :: Greater => output. truncate ( output. len ( ) - ( encoded - target) ) ,
261+ }
262+ }
263+ }
264+
265+ fn assert_status ( error : & anyhow:: Error , expected : tonic:: Code ) {
266+ let status = error
267+ . downcast_ref :: < Status > ( )
268+ . unwrap_or_else ( || panic ! ( "expected tonic status, got {error:#}" ) ) ;
269+ assert_eq ! ( status. code( ) , expected, "unexpected status: {status}" ) ;
270+ }
271+
272+ #[ tokio:: test]
273+ async fn enforces_request_encoding_limit ( ) {
274+ let ( dispatcher, _dir) = test_dispatcher ( Vec :: new ( ) ) . await ;
275+ let below = request_payload_with_encoded_size ( MAX_MESSAGE_SIZE - 1 ) ;
276+ assert ! ( dispatcher. run_action( below) . await . is_ok( ) ) ;
277+
278+ let above = request_payload_with_encoded_size ( MAX_MESSAGE_SIZE + 1 ) ;
279+ let error = dispatcher. run_action ( above) . await . unwrap_err ( ) ;
280+ assert_status ( & error, tonic:: Code :: ResourceExhausted ) ;
281+ }
282+
283+ #[ tokio:: test]
284+ async fn enforces_response_decoding_limit ( ) {
285+ let below = response_output_with_encoded_size ( MAX_MESSAGE_SIZE - 1 ) ;
286+ let ( dispatcher, _dir) = test_dispatcher ( below. clone ( ) ) . await ;
287+ match dispatcher. run_action ( Vec :: new ( ) ) . await . unwrap ( ) {
288+ Outcome :: Success { output_json } => assert_eq ! ( output_json, below) ,
289+ Outcome :: Failure { .. } => panic ! ( "expected success" ) ,
290+ }
291+
292+ let above = response_output_with_encoded_size ( MAX_MESSAGE_SIZE + 1 ) ;
293+ let ( dispatcher, _dir) = test_dispatcher ( above) . await ;
294+ let error = dispatcher. run_action ( Vec :: new ( ) ) . await . unwrap_err ( ) ;
295+ // Tonic reports an oversized decoded response as OutOfRange.
296+ assert_status ( & error, tonic:: Code :: OutOfRange ) ;
297+ }
298+ }
0 commit comments