Coverage Report

Created: 2026-10-02 11:24

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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
}