/build/source/nativelink-worker/src/persistent_worker/live_worker.rs
Line | Count | Source |
1 | | // Copyright 2024 Trace Machina, Inc. All rights reserved. |
2 | | // |
3 | | // Licensed under the Business Source License, Version 1.1 (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may requested a copy of the License by emailing contact@nativelink.com. |
6 | | // |
7 | | // Use of this module requires an enterprise license agreement, which can be |
8 | | // attained by emailing contact@nativelink.com or signing up for Nativelink |
9 | | // Cloud at app.nativelink.com. |
10 | | // |
11 | | // Unless required by applicable law or agreed to in writing, software |
12 | | // distributed under the License is distributed on an "AS IS" BASIS, |
13 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
14 | | // See the License for the specific language governing permissions and |
15 | | // limitations under the License. |
16 | | |
17 | | //! `LiveWorker`: one long-lived persistent-worker child process. |
18 | | //! |
19 | | //! A `LiveWorker` wraps a child process started with the action's tool + |
20 | | //! startup-flag prefix + `--persistent_worker`. It owns the child's stdin/stdout |
21 | | //! and exposes `dispatch(request) -> response` for the pool to invoke. |
22 | | //! |
23 | | //! Lifecycle: |
24 | | //! - `spawn` → ready, never-used |
25 | | //! - `dispatch` flips an in-flight bool, writes the request, reads the response, |
26 | | //! updates `last_used` and `request_count` |
27 | | //! - `drop` sends SIGTERM, waits a grace period, then SIGKILL |
28 | | //! |
29 | | //! v1 is single-request-per-worker (no multiplex). The pool serializes |
30 | | //! concurrent acquires of the same worker via its data structure. |
31 | | |
32 | | use core::time::Duration; |
33 | | use std::path::{Path, PathBuf}; |
34 | | use std::process::Stdio; |
35 | | use std::time::Instant; |
36 | | |
37 | | use bytes::BytesMut; |
38 | | use nativelink_error::{Code, Error, ResultExt, make_err}; |
39 | | use prost::Message as ProstMessage; |
40 | | use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader}; |
41 | | use tokio::process::{Child, ChildStdin, ChildStdout, Command}; |
42 | | use tracing::{debug, warn}; |
43 | | |
44 | | use super::protocol::{WireFormat, WorkRequest, WorkResponse}; |
45 | | |
46 | | /// Default time we wait for a single `WorkResponse` after writing a request. |
47 | | /// Beyond this we kill the worker and surface a `DeadlineExceeded` error. |
48 | | /// Callers may override via `LiveWorker::dispatch_with_timeout`. |
49 | | const DEFAULT_DISPATCH_TIMEOUT: Duration = Duration::from_mins(10); |
50 | | |
51 | | /// One persistent-worker child process. |
52 | | #[derive(Debug)] |
53 | | pub struct LiveWorker { |
54 | | /// Spawned child process. Kept on the struct so its Drop kills the process |
55 | | /// when the worker is dropped without `shutdown` being called. |
56 | | child: Child, |
57 | | /// Registered with the reaper for as long as the child is ours to wait on. |
58 | | _owned: crate::reaper::OwnedChild, |
59 | | stdin: ChildStdin, |
60 | | stdout: BufReader<ChildStdout>, |
61 | | /// Wire format we negotiated at spawn (immutable for this worker's lifetime). |
62 | | wire_format: WireFormat, |
63 | | /// Number of successful `dispatch` calls completed. |
64 | | request_count: u64, |
65 | | /// When the last `dispatch` finished. Used by the pool's idle eviction. |
66 | | last_used: Instant, |
67 | | /// Working directory the child runs in. Stored so the pool can reuse it |
68 | | /// across requests for the same `WorkerKey` until per-request sandboxing |
69 | | /// arrives in v2. |
70 | | working_dir: PathBuf, |
71 | | } |
72 | | |
73 | | impl LiveWorker { |
74 | | /// Spawn a new persistent worker. |
75 | | /// |
76 | | /// `executable` is the resolved tool path (absolute, or relative to |
77 | | /// `working_dir`). `startup_args` are the flags that the worker is launched |
78 | | /// with once — these become part of the `WorkerKey`. The Bazel-conventional |
79 | | /// `--persistent_worker` flag is appended automatically. |
80 | 10 | pub fn spawn( |
81 | 10 | executable: &Path, |
82 | 10 | startup_args: &[String], |
83 | 10 | wire_format: WireFormat, |
84 | 10 | working_dir: &Path, |
85 | 10 | env: &[(String, String)], |
86 | 10 | namespaced: bool, |
87 | 10 | ) -> Result<Self, Error> { |
88 | 10 | let mut cmd = Command::new(executable); |
89 | 10 | cmd.args(startup_args) |
90 | 10 | .arg("--persistent_worker") |
91 | 10 | .current_dir(working_dir) |
92 | 10 | // The action's environment and nothing else, as for a one-shot |
93 | 10 | // action; the environment is part of the worker's key. |
94 | 10 | .env_clear() |
95 | 10 | .envs(env.iter().map(|(name, value)| (name, value))) |
96 | 10 | .stdin(Stdio::piped()) |
97 | 10 | .stdout(Stdio::piped()) |
98 | | // WorkResponse.output carries per-action diagnostics. The child |
99 | | // stderr stream is process-lifetime data and cannot be attributed |
100 | | // safely to a single request. |
101 | 10 | .stderr(Stdio::null()) |
102 | 10 | .kill_on_drop(true); |
103 | | // Its own process group, so the resource sampler can attribute the |
104 | | // process and its children to the request being served. |
105 | | #[cfg(unix)] |
106 | 10 | cmd.process_group(0); |
107 | | #[cfg(target_os = "linux")] |
108 | 10 | if namespaced { |
109 | | use std::os::unix::ffi::OsStrExt; |
110 | 0 | let working_dir_c = std::ffi::CString::new(working_dir.as_os_str().as_bytes()) |
111 | 0 | .err_tip(|| "Persistent worker working directory is not a valid C string")?; |
112 | 0 | let action_dir_c = working_dir_c.clone(); |
113 | | // SAFETY: configure_namespace is async-signal-safe and meant for pre_exec. |
114 | | unsafe { |
115 | 0 | cmd.pre_exec(move || { |
116 | 0 | crate::namespace_utils::configure_namespace( |
117 | | false, |
118 | 0 | None, |
119 | 0 | &working_dir_c, |
120 | 0 | &action_dir_c, |
121 | | ) |
122 | 0 | }); |
123 | | } |
124 | 10 | } |
125 | | #[cfg(not(target_os = "linux"))] |
126 | | let _ = namespaced; |
127 | | |
128 | 10 | debug!( |
129 | | ?executable, |
130 | | ?startup_args, |
131 | | ?wire_format, |
132 | | "Spawning persistent worker" |
133 | | ); |
134 | | |
135 | 10 | let mut child = cmd.spawn().err_tip(|| {0 |
136 | 0 | format!( |
137 | | "Spawning persistent worker {} with args {startup_args:?}", |
138 | 0 | executable.display() |
139 | | ) |
140 | 0 | })?; |
141 | | // Ours until shutdown waits on it, however long it idles; the |
142 | | // reaper leaves it alone. |
143 | 10 | let owned = crate::reaper::OwnedChild::new(child.id()); |
144 | 10 | let stdin = child |
145 | 10 | .stdin |
146 | 10 | .take() |
147 | 10 | .ok_or_else(|| make_err!0 (Code::Internal0 , "Persistent worker child has no stdin"))?0 ; |
148 | 10 | let stdout = child |
149 | 10 | .stdout |
150 | 10 | .take() |
151 | 10 | .ok_or_else(|| make_err!0 (Code::Internal0 , "Persistent worker child has no stdout"))?0 ; |
152 | | |
153 | 10 | Ok(Self { |
154 | 10 | child, |
155 | 10 | _owned: owned, |
156 | 10 | stdin, |
157 | 10 | stdout: BufReader::new(stdout), |
158 | 10 | wire_format, |
159 | 10 | request_count: 0, |
160 | 10 | last_used: Instant::now(), |
161 | 10 | working_dir: working_dir.to_path_buf(), |
162 | 10 | }) |
163 | 10 | } |
164 | | |
165 | 0 | pub const fn wire_format(&self) -> WireFormat { |
166 | 0 | self.wire_format |
167 | 0 | } |
168 | | |
169 | | /// The worker process's id, which is also its process group. |
170 | 2 | pub fn pid(&self) -> Option<u32> { |
171 | 2 | self.child.id() |
172 | 2 | } |
173 | | |
174 | 11 | pub const fn request_count(&self) -> u64 { |
175 | 11 | self.request_count |
176 | 11 | } |
177 | | |
178 | 1 | pub const fn last_used(&self) -> Instant { |
179 | 1 | self.last_used |
180 | 1 | } |
181 | | |
182 | 0 | pub fn working_dir(&self) -> &Path { |
183 | 0 | &self.working_dir |
184 | 0 | } |
185 | | |
186 | | /// Returns true if the child process has already exited. |
187 | 3 | pub fn is_dead(&mut self) -> bool { |
188 | 3 | matches!2 (self.child.try_wait(), Ok(Some(_)) | Err(_)) |
189 | 3 | } |
190 | | |
191 | | /// Send a single `WorkRequest` and read the matching `WorkResponse`. On |
192 | | /// any I/O error or response framing error, the worker is considered |
193 | | /// dead — the caller (pool) must not return it to the idle set. |
194 | 2 | pub async fn dispatch(&mut self, request: &WorkRequest) -> Result<WorkResponse, Error> { |
195 | 2 | self.dispatch_with_timeout(request, DEFAULT_DISPATCH_TIMEOUT) |
196 | 2 | .await |
197 | 2 | } |
198 | | |
199 | 5 | pub async fn dispatch_with_timeout( |
200 | 5 | &mut self, |
201 | 5 | request: &WorkRequest, |
202 | 5 | timeout: Duration, |
203 | 5 | ) -> Result<WorkResponse, Error> { |
204 | 5 | if request.request_id != 0 { |
205 | 1 | return Err(make_err!( |
206 | 1 | Code::InvalidArgument, |
207 | 1 | "v1 persistent workers do not support multiplex; request_id must be 0, got {}", |
208 | 1 | request.request_id |
209 | 1 | )); |
210 | 4 | } |
211 | | |
212 | 4 | let bytes = request.encode_framed(self.wire_format)?0 ; |
213 | 4 | self.stdin |
214 | 4 | .write_all(&bytes) |
215 | 4 | .await |
216 | 4 | .err_tip(|| "Writing WorkRequest to persistent worker stdin")?0 ; |
217 | 4 | self.stdin |
218 | 4 | .flush() |
219 | 4 | .await |
220 | 4 | .err_tip(|| "Flushing persistent worker stdin")?0 ; |
221 | | |
222 | 4 | let read_fut = read_response(&mut self.stdout, self.wire_format); |
223 | 4 | let response3 = if let Ok(result3 ) = tokio::time::timeout(timeout, read_fut).await { |
224 | 3 | result?0 |
225 | | } else { |
226 | 1 | warn!( |
227 | | ?timeout, |
228 | | "Persistent worker did not respond before deadline; killing child" |
229 | | ); |
230 | 1 | drop(self.child.kill().await); |
231 | 1 | return Err(make_err!( |
232 | 1 | Code::DeadlineExceeded, |
233 | 1 | "Persistent worker did not respond within {timeout:?}" |
234 | 1 | )); |
235 | | }; |
236 | | |
237 | 3 | if response.request_id != 0 { |
238 | | // v1 contract: workers MUST echo request_id 0. If a tool ever |
239 | | // produces a multiplex-style id we don't know what to do with the |
240 | | // response — fail conservatively rather than misroute output. |
241 | 0 | return Err(make_err!( |
242 | 0 | Code::Internal, |
243 | 0 | "Persistent worker returned non-zero request_id={}; v1 does not support multiplex", |
244 | 0 | response.request_id |
245 | 0 | )); |
246 | 3 | } |
247 | | |
248 | 3 | self.request_count += 1; |
249 | 3 | self.last_used = Instant::now(); |
250 | 3 | Ok(response) |
251 | 5 | } |
252 | | |
253 | | /// Gracefully drain: close stdin so the worker observes EOF and exits on |
254 | | /// its own, then wait up to `grace`, then SIGKILL if still alive. Always |
255 | | /// consumes the worker. |
256 | 8 | pub async fn shutdown(mut self, grace: Duration) { |
257 | | // Dropping stdin closes the pipe, which most well-behaved workers |
258 | | // interpret as "no more work, please exit". |
259 | 8 | drop(self.stdin); |
260 | | |
261 | 8 | match tokio::time::timeout(grace, self.child.wait()).await { |
262 | 2 | Ok(Ok(status)) => debug!(?status, "Persistent worker exited cleanly"), |
263 | 0 | Ok(Err(err)) => warn!(?err, "Error waiting for persistent worker exit"), |
264 | | Err(_) => { |
265 | 4 | warn!( |
266 | | ?grace, |
267 | | "Persistent worker did not exit within grace; SIGKILLing" |
268 | | ); |
269 | 4 | drop(self.child.kill().await); |
270 | | } |
271 | | } |
272 | 6 | } |
273 | | } |
274 | | |
275 | | /// Read one framed `WorkResponse` from the worker's stdout. |
276 | 4 | async fn read_response( |
277 | 4 | stdout: &mut BufReader<ChildStdout>, |
278 | 4 | format: WireFormat, |
279 | 4 | ) -> Result<WorkResponse, Error> { |
280 | 4 | match format { |
281 | | WireFormat::Proto => { |
282 | | // Length-delimited varint frame. Decode the varint manually because |
283 | | // we want to read exactly the right number of bytes without buffering |
284 | | // arbitrary data from the worker's pipe. |
285 | 0 | let len = read_varint(stdout).await?; |
286 | 0 | if len > 64 * 1024 * 1024 { |
287 | 0 | return Err(make_err!( |
288 | 0 | Code::OutOfRange, |
289 | 0 | "Persistent worker WorkResponse length {len} exceeds 64 MiB cap" |
290 | 0 | )); |
291 | 0 | } |
292 | 0 | let mut buf = BytesMut::with_capacity(len); |
293 | 0 | buf.resize(len, 0); |
294 | 0 | stdout |
295 | 0 | .read_exact(&mut buf) |
296 | 0 | .await |
297 | 0 | .err_tip(|| "Reading WorkResponse proto body from persistent worker")?; |
298 | 0 | <WorkResponse as ProstMessage>::decode(&buf[..]) |
299 | 0 | .map_err(|e| make_err!(Code::Internal, "Decoding WorkResponse proto body: {e}")) |
300 | | } |
301 | | WireFormat::Json => { |
302 | | // Newline-delimited JSON. |
303 | 4 | let mut line = Vec::with_capacity(256); |
304 | | loop { |
305 | 98 | let mut byte = [0u8; 1]; |
306 | 98 | let n97 = stdout |
307 | 98 | .read(&mut byte) |
308 | 98 | .await |
309 | 97 | .err_tip(|| "Reading WorkResponse JSON byte from persistent worker")?0 ; |
310 | 97 | if n == 0 { |
311 | 0 | return Err(make_err!( |
312 | 0 | Code::Aborted, |
313 | 0 | "Persistent worker closed stdout before sending a full JSON response" |
314 | 0 | )); |
315 | 97 | } |
316 | 97 | if byte[0] == b'\n' { |
317 | 3 | if line.is_empty() { |
318 | 0 | continue; // tolerate blank lines between responses |
319 | 3 | } |
320 | 3 | break; |
321 | 94 | } |
322 | 94 | line.push(byte[0]); |
323 | 94 | if line.len() > 64 * 1024 * 1024 { |
324 | 0 | return Err(make_err!( |
325 | 0 | Code::OutOfRange, |
326 | 0 | "Persistent worker WorkResponse JSON line exceeds 64 MiB cap" |
327 | 0 | )); |
328 | 94 | } |
329 | | } |
330 | 3 | WorkResponse::decode_framed(&line, WireFormat::Json) |
331 | | } |
332 | | } |
333 | 3 | } |
334 | | |
335 | | /// Read a protobuf varint from the reader. Reused for the length prefix of a |
336 | | /// length-delimited frame. |
337 | 0 | async fn read_varint(r: &mut BufReader<ChildStdout>) -> Result<usize, Error> { |
338 | 0 | let mut result: u64 = 0; |
339 | 0 | for shift in (0..64).step_by(7) { |
340 | 0 | let mut byte = [0u8; 1]; |
341 | 0 | let n = r |
342 | 0 | .read(&mut byte) |
343 | 0 | .await |
344 | 0 | .err_tip(|| "Reading varint byte from persistent worker stdout")?; |
345 | 0 | if n == 0 { |
346 | 0 | return Err(make_err!( |
347 | 0 | Code::Aborted, |
348 | 0 | "Persistent worker closed stdout while reading varint" |
349 | 0 | )); |
350 | 0 | } |
351 | 0 | result |= u64::from(byte[0] & 0x7f) << shift; |
352 | 0 | if byte[0] & 0x80 == 0 { |
353 | 0 | return usize::try_from(result).map_err(|_| { |
354 | 0 | make_err!( |
355 | 0 | Code::OutOfRange, |
356 | | "Varint length {result} does not fit in usize" |
357 | | ) |
358 | 0 | }); |
359 | 0 | } |
360 | | } |
361 | 0 | Err(make_err!( |
362 | 0 | Code::OutOfRange, |
363 | 0 | "Varint did not terminate within 10 bytes" |
364 | 0 | )) |
365 | 0 | } |
366 | | |
367 | | #[cfg(test)] |
368 | | mod tests { |
369 | | use std::io::Write as _; |
370 | | |
371 | | use nativelink_macro::nativelink_test; |
372 | | |
373 | | use super::*; |
374 | | |
375 | | struct TestWorkerProgram { |
376 | | executable: PathBuf, |
377 | | startup_args: Vec<String>, |
378 | | } |
379 | | |
380 | | impl TestWorkerProgram { |
381 | 4 | fn startup_args(&self) -> &[String] { |
382 | 4 | &self.startup_args |
383 | 4 | } |
384 | | } |
385 | | |
386 | | /// The worker process gets only the environment it is given. The shell |
387 | | /// scripts need PATH to find `sleep` wherever the tests run; PowerShell |
388 | | /// needs the rest of what Windows sets, so there the whole environment |
389 | | /// goes through, as `action_environment` does with its defaults. |
390 | 4 | fn path_env() -> Vec<(String, String)> { |
391 | 4 | if cfg!(windows) { |
392 | 0 | std::env::vars().collect() |
393 | | } else { |
394 | 4 | vec![( |
395 | 4 | "PATH".to_string(), |
396 | 4 | std::env::var("PATH").unwrap_or_default(), |
397 | 4 | )] |
398 | | } |
399 | 4 | } |
400 | | |
401 | | #[cfg(unix)] |
402 | 4 | fn echo_script(working_dir: &Path, unix_body: &str, _windows_body: &str) -> TestWorkerProgram { |
403 | 4 | let path = working_dir.join("worker.sh"); |
404 | 4 | let mut file = std::fs::File::create(&path).unwrap(); |
405 | 4 | file.write_all(unix_body.as_bytes()).unwrap(); |
406 | 4 | file.sync_all().unwrap(); |
407 | 4 | drop(file); |
408 | | |
409 | 4 | TestWorkerProgram { |
410 | 4 | executable: PathBuf::from("/bin/sh"), |
411 | 4 | startup_args: vec![path.display().to_string()], |
412 | 4 | } |
413 | 4 | } |
414 | | |
415 | | #[cfg(windows)] |
416 | | fn echo_script(working_dir: &Path, _unix_body: &str, windows_body: &str) -> TestWorkerProgram { |
417 | | let path = working_dir.join("worker.ps1"); |
418 | | let mut file = std::fs::File::create(&path).unwrap(); |
419 | | file.write_all(windows_body.as_bytes()).unwrap(); |
420 | | file.sync_all().unwrap(); |
421 | | drop(file); |
422 | | |
423 | | TestWorkerProgram { |
424 | | executable: PathBuf::from("powershell.exe"), |
425 | | startup_args: vec![ |
426 | | "-NoProfile".to_owned(), |
427 | | "-ExecutionPolicy".to_owned(), |
428 | | "Bypass".to_owned(), |
429 | | "-File".to_owned(), |
430 | | path.display().to_string(), |
431 | | ], |
432 | | } |
433 | | } |
434 | | |
435 | | #[nativelink_test] |
436 | | async fn shutdown_kills_unresponsive_worker() { |
437 | | // A worker that never reads/writes — shutdown grace expires, we SIGKILL. |
438 | | let dir = tempfile::tempdir().unwrap(); |
439 | | let script = echo_script(dir.path(), "exec sleep 30\n", "Start-Sleep -Seconds 30\n"); |
440 | | let worker = LiveWorker::spawn( |
441 | | &script.executable, |
442 | | script.startup_args(), |
443 | | WireFormat::Json, |
444 | | dir.path(), |
445 | | &path_env(), |
446 | | false, |
447 | | ) |
448 | | .unwrap(); |
449 | | let start = Instant::now(); |
450 | | worker.shutdown(Duration::from_millis(100)).await; |
451 | | // SIGKILL should arrive well within a second. |
452 | | assert!(start.elapsed() < Duration::from_secs(2)); |
453 | | } |
454 | | |
455 | | #[nativelink_test] |
456 | | async fn dispatch_json_round_trip() { |
457 | | // A worker scripted to read one JSON line and echo a canned response. |
458 | | let dir = tempfile::tempdir().unwrap(); |
459 | | let script = echo_script( |
460 | | dir.path(), |
461 | | // Read one line, ignore it, emit a fixed response. Newline-terminated. |
462 | | "read line\necho '{\"exitCode\":0,\"output\":\"ok\"}'\n", |
463 | | "$line = [Console]::In.ReadLine()\n[Console]::Out.WriteLine('{\"exitCode\":0,\"output\":\"ok\"}')\n", |
464 | | ); |
465 | | let mut worker = LiveWorker::spawn( |
466 | | &script.executable, |
467 | | script.startup_args(), |
468 | | WireFormat::Json, |
469 | | dir.path(), |
470 | | &path_env(), |
471 | | false, |
472 | | ) |
473 | | .unwrap(); |
474 | | |
475 | | let req = WorkRequest { |
476 | | arguments: vec!["compile".into()], |
477 | | ..WorkRequest::default() |
478 | | }; |
479 | | let resp = worker.dispatch(&req).await.unwrap(); |
480 | | assert_eq!(resp.exit_code, 0); |
481 | | assert_eq!(resp.output, "ok"); |
482 | | assert_eq!(worker.request_count(), 1); |
483 | | |
484 | | worker.shutdown(Duration::from_secs(1)).await; |
485 | | } |
486 | | |
487 | | #[nativelink_test] |
488 | | async fn dispatch_timeout_kills_worker() { |
489 | | let dir = tempfile::tempdir().unwrap(); |
490 | | // Worker reads, then sleeps forever instead of responding. |
491 | | let script = echo_script( |
492 | | dir.path(), |
493 | | "read line\nexec sleep 60\n", |
494 | | "$line = [Console]::In.ReadLine()\nStart-Sleep -Seconds 60\n", |
495 | | ); |
496 | | let mut worker = LiveWorker::spawn( |
497 | | &script.executable, |
498 | | script.startup_args(), |
499 | | WireFormat::Json, |
500 | | dir.path(), |
501 | | &path_env(), |
502 | | false, |
503 | | ) |
504 | | .unwrap(); |
505 | | |
506 | | let req = WorkRequest { |
507 | | arguments: vec!["compile".into()], |
508 | | ..WorkRequest::default() |
509 | | }; |
510 | | let result = worker |
511 | | .dispatch_with_timeout(&req, Duration::from_millis(100)) |
512 | | .await; |
513 | | assert!(result.is_err()); |
514 | | assert_eq!(result.unwrap_err().code, Code::DeadlineExceeded); |
515 | | assert!(worker.is_dead()); |
516 | | } |
517 | | |
518 | | #[nativelink_test] |
519 | | async fn rejects_multiplex_request_id() { |
520 | | let dir = tempfile::tempdir().unwrap(); |
521 | | let script = echo_script( |
522 | | dir.path(), |
523 | | "read line\necho '{\"exitCode\":0}'\n", |
524 | | "$line = [Console]::In.ReadLine()\n[Console]::Out.WriteLine('{\"exitCode\":0}')\n", |
525 | | ); |
526 | | let mut worker = LiveWorker::spawn( |
527 | | &script.executable, |
528 | | script.startup_args(), |
529 | | WireFormat::Json, |
530 | | dir.path(), |
531 | | &path_env(), |
532 | | false, |
533 | | ) |
534 | | .unwrap(); |
535 | | let req = WorkRequest { |
536 | | request_id: 7, |
537 | | ..WorkRequest::default() |
538 | | }; |
539 | | let err = worker.dispatch(&req).await.unwrap_err(); |
540 | | assert_eq!(err.code, Code::InvalidArgument); |
541 | | worker.shutdown(Duration::from_secs(1)).await; |
542 | | } |
543 | | } |