Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-worker/src/persistent_worker/pool.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 request 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
//! `PersistentWorkerPool`: per-`LocalWorker` pool of `LiveWorker`s keyed by
18
//! `WorkerKey`. Mirrors Bazel's notion of `WorkerKey` — two actions whose
19
//! executable + startup-flag prefix + wire format are identical share a worker
20
//! process.
21
22
use core::time::Duration;
23
use core::{fmt, mem};
24
use std::collections::HashMap;
25
use std::path::{Path, PathBuf};
26
use std::sync::Arc;
27
28
use nativelink_error::{Code, Error, ResultExt, make_err};
29
use nativelink_util::background_spawn;
30
use nativelink_util::task::JoinHandleDropGuard;
31
use parking_lot::Mutex;
32
use tokio::sync::Notify;
33
use tracing::{debug, info, warn};
34
35
use super::live_worker::LiveWorker;
36
use super::protocol::WireFormat;
37
38
/// Default per-key bound on live workers. Matches Bazel's `worker_max_instances`
39
/// default and keeps memory pressure bounded without operator intervention.
40
pub const DEFAULT_MAX_WORKERS_PER_KEY: usize = 4;
41
42
/// Default idle timeout. A worker that has not handled a request for this
43
/// duration is shut down by the background sweeper.
44
pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_mins(5);
45
46
/// Default per-worker request cap. After this many `dispatch` calls a worker is
47
/// dropped on `release` (helps recycle accumulated JVM state).
48
pub const DEFAULT_MAX_REQUESTS_PER_WORKER: u64 = 200;
49
50
/// Default wait for a worker to come back when a key is at its cap.
51
pub const DEFAULT_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(30);
52
53
/// Identity by which two persistent-worker actions are considered compatible.
54
/// Same `WorkerKey` => same worker can serve both.
55
#[derive(Clone, PartialEq, Eq, Hash)]
56
pub struct WorkerKey {
57
    /// Resolved tool executable. Mirrors `Action.arguments[0]`.
58
    pub executable: PathBuf,
59
    /// Leading arguments that become tool startup flags. Computed as the prefix
60
    /// of `Action.arguments[1..]` up to (excluding) the first arg starting
61
    /// with `@` (Bazel's response-file convention).
62
    pub startup_args: Vec<String>,
63
    /// Wire format from the action's `requires-worker-protocol`.
64
    pub wire_format: WireFormat,
65
    /// The action's environment, sorted by name. A worker process is
66
    /// started with exactly this environment, the way a one-shot action is,
67
    /// so two actions that differ in it do not share a process.
68
    pub env: Vec<(String, String)>,
69
}
70
71
/// The key is logged on every spawn and completion and quoted in errors the
72
/// client sees, so the environment shows as its variable names only: an
73
/// action's environment can carry a token.
74
impl fmt::Debug for WorkerKey {
75
13
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76
13
        f.debug_struct("WorkerKey")
77
13
            .field("executable", &self.executable)
78
13
            .field("startup_args", &self.startup_args)
79
13
            .field("wire_format", &self.wire_format)
80
13
            .field(
81
13
                "env",
82
13
                &self.env.iter().map(|(name, _)| name).collect::<Vec<_>>(),
83
            )
84
13
            .finish()
85
13
    }
86
}
87
88
impl WorkerKey {
89
    /// Derive a `WorkerKey` from a full action `argv` and the negotiated wire
90
    /// format.
91
    ///
92
    /// `argv[0]` is the executable. Subsequent arguments are scanned
93
    /// left-to-right and treated as startup flags until the first
94
    /// `@`-prefixed argument (Bazel's response file), at which point the rest
95
    /// are considered per-request inputs and excluded from the key.
96
15
    pub fn from_argv(argv: &[String], wire_format: WireFormat) -> Result<Self, Error> {
97
15
        let (
exe14
,
rest14
) = argv.split_first().ok_or_else(||
{1
98
1
            make_err!(
99
1
                Code::InvalidArgument,
100
                "Cannot derive WorkerKey from empty argument list"
101
            )
102
1
        })?;
103
14
        let startup_args: Vec<String> = rest
104
14
            .iter()
105
25
            .
take_while14
(|a| !a.starts_with('@'))
106
14
            .cloned()
107
14
            .collect();
108
14
        Ok(Self {
109
14
            executable: PathBuf::from(exe),
110
14
            startup_args,
111
14
            wire_format,
112
14
            env: Vec::new(),
113
14
        })
114
15
    }
115
116
    /// The environment the worker process is started with, part of the key.
117
    #[must_use]
118
5
    pub fn with_env(mut self, mut env: Vec<(String, String)>) -> Self {
119
5
        env.sort();
120
5
        self.env = env;
121
5
        self
122
5
    }
123
}
124
125
/// Configuration knobs for a `PersistentWorkerPool`. Defaults are sensible for
126
/// a JVM-heavy workload; operators can override in `nativelink-config`.
127
#[derive(Clone, Copy, Debug)]
128
pub struct PoolConfig {
129
    pub max_workers_per_key: usize,
130
    pub idle_timeout: Duration,
131
    pub max_requests_per_worker: u64,
132
    /// SIGKILL grace when shutting a worker down.
133
    pub shutdown_grace: Duration,
134
    /// How long `acquire` waits for a worker to come back when the key is
135
    /// at `max_workers_per_key` with none idle, before the action runs
136
    /// one-shot instead. Bazel itself queues on `worker_max_instances`; a
137
    /// one-shot run of a worker tool pays the tool's startup again.
138
    pub acquire_timeout: Duration,
139
    /// Start each worker process in its own user, PID, UTS, IPC and mount
140
    /// namespaces, with a fresh `/proc`, as one-shot actions are when the
141
    /// worker runs with `use_namespaces`. Never a masked root or a private
142
    /// `/tmp`: the process outlives any one action, so neither can apply
143
    /// to it.
144
    pub namespaced: bool,
145
}
146
147
impl Default for PoolConfig {
148
96
    fn default() -> Self {
149
96
        Self {
150
96
            max_workers_per_key: DEFAULT_MAX_WORKERS_PER_KEY,
151
96
            idle_timeout: DEFAULT_IDLE_TIMEOUT,
152
96
            max_requests_per_worker: DEFAULT_MAX_REQUESTS_PER_WORKER,
153
96
            shutdown_grace: Duration::from_secs(5),
154
96
            acquire_timeout: DEFAULT_ACQUIRE_TIMEOUT,
155
96
            namespaced: false,
156
96
        }
157
96
    }
158
}
159
160
/// Reservation handle returned by `acquire`. Holding it removes the worker
161
/// from the pool's idle set; `release` returns it (unless the request marked
162
/// it as dead). Drop-without-release is treated as "worker is dead": it is
163
/// shut down rather than re-pooled.
164
#[derive(Debug)]
165
pub struct Lease {
166
    inner: Option<LeaseInner>,
167
}
168
169
#[derive(Debug)]
170
struct LeaseInner {
171
    worker: LiveWorker,
172
    pool: Arc<PoolInner>,
173
    key: WorkerKey,
174
}
175
176
#[derive(Debug)]
177
struct CountSlotGuard {
178
    pool: Arc<PoolInner>,
179
    key: WorkerKey,
180
    active: bool,
181
}
182
183
impl CountSlotGuard {
184
10
    const fn new(pool: Arc<PoolInner>, key: WorkerKey) -> Self {
185
10
        Self {
186
10
            pool,
187
10
            key,
188
10
            active: true,
189
10
        }
190
10
    }
191
192
6
    fn disarm(mut self) {
193
6
        self.active = false;
194
6
    }
195
196
3
    fn decrement_now(mut self) {
197
3
        if self.active {
198
3
            self.pool.decrement_count(&self.key);
199
3
            self.active = false;
200
3
        
}0
201
3
    }
202
}
203
204
impl Drop for CountSlotGuard {
205
10
    fn drop(&mut self) {
206
10
        if self.active {
207
1
            self.pool.decrement_count(&self.key);
208
9
        }
209
10
    }
210
}
211
212
impl Lease {
213
4
    pub const fn worker(&mut self) -> &mut LiveWorker {
214
4
        &mut self
215
4
            .inner
216
4
            .as_mut()
217
4
            .expect("lease used after release")
218
4
            .worker
219
4
    }
220
221
    /// Return the worker to the pool. Caller signals whether the worker is
222
    /// still healthy. Unhealthy workers are shut down asynchronously and not
223
    /// returned to the idle set.
224
8
    pub async fn release(mut self, healthy: bool) {
225
8
        let Some(inner) = self.inner.take() else {
226
0
            return;
227
        };
228
8
        inner
229
8
            .pool
230
8
            .return_worker(inner.key, inner.worker, healthy)
231
8
            .await;
232
7
    }
233
}
234
235
impl Drop for Lease {
236
8
    fn drop(&mut self) {
237
        // If the caller forgot to call `release`, treat the worker as
238
        // unhealthy and shut it down on a detached task.
239
8
        if let Some(
inner0
) = self.inner.take() {
240
0
            let pool = inner.pool.clone();
241
0
            let key = inner.key.clone();
242
0
            let worker = inner.worker;
243
0
            let grace = pool.config.shutdown_grace;
244
0
            background_spawn!("persistent_worker_lease_drop", async move {
245
0
                let count_slot = CountSlotGuard::new(pool.clone(), key.clone());
246
0
                warn!(
247
                    ?key,
248
                    "Lease dropped without release; treating worker as unhealthy"
249
                );
250
0
                worker.shutdown(grace).await;
251
0
                count_slot.decrement_now();
252
0
            });
253
8
        }
254
8
    }
255
}
256
257
/// Per-key pool state. Wrapped in a `Mutex` because `acquire` and `release`
258
/// run on different tokio tasks.
259
#[derive(Debug)]
260
struct KeyState {
261
    /// Idle workers ready to serve. Always size <= `total_count`.
262
    idle: Vec<LiveWorker>,
263
    /// Total live workers for this key (idle + leased). Used to enforce
264
    /// `max_workers_per_key`.
265
    total_count: usize,
266
}
267
268
impl KeyState {
269
5
    const fn new() -> Self {
270
5
        Self {
271
5
            idle: Vec::new(),
272
5
            total_count: 0,
273
5
        }
274
5
    }
275
}
276
277
#[derive(Debug)]
278
struct PoolInner {
279
    config: PoolConfig,
280
    state: Mutex<HashMap<WorkerKey, KeyState>>,
281
    /// Woken whenever a worker is returned or a slot is freed, for an
282
    /// `acquire` waiting on a key at its cap.
283
    returned: Notify,
284
}
285
286
impl PoolInner {
287
    /// Helper used by Drop on a forgotten Lease.
288
4
    fn decrement_count(&self, key: &WorkerKey) {
289
        {
290
4
            let mut state = self.state.lock();
291
4
            if let Some(s) = state.get_mut(key) {
292
4
                s.total_count = s.total_count.saturating_sub(1);
293
4
            
}0
294
        }
295
4
        self.returned.notify_waiters();
296
4
    }
297
298
8
    
async fn return_worker(self: Arc<Self>, key: WorkerKey, worker: LiveWorker, healthy: bool)6
{
299
8
        let exceeded_cap = worker.request_count() >= self.config.max_requests_per_worker;
300
8
        if !healthy || 
exceeded_cap4
{
301
4
            let count_slot = CountSlotGuard::new(self.clone(), key.clone());
302
4
            debug!(
303
                ?key,
304
4
                request_count = worker.request_count(),
305
                healthy,
306
                "Retiring persistent worker"
307
            );
308
4
            worker.shutdown(self.config.shutdown_grace).await;
309
3
            count_slot.decrement_now();
310
3
            return;
311
4
        }
312
4
        {
313
4
            let mut state = self.state.lock();
314
4
            let entry = state.entry(key).or_insert_with(KeyState::new);
315
4
            entry.idle.push(worker);
316
4
        }
317
4
        self.returned.notify_waiters();
318
7
    }
319
}
320
321
/// The pool itself.
322
#[derive(Clone, Debug)]
323
pub struct PersistentWorkerPool {
324
    inner: Arc<PoolInner>,
325
}
326
327
impl Default for PersistentWorkerPool {
328
0
    fn default() -> Self {
329
0
        Self::new(PoolConfig::default())
330
0
    }
331
}
332
333
impl PersistentWorkerPool {
334
    /// How long `acquire` waits at the cap before giving up.
335
2
    pub fn acquire_timeout(&self) -> Duration {
336
2
        self.inner.config.acquire_timeout
337
2
    }
338
339
86
    pub fn new(config: PoolConfig) -> Self {
340
86
        Self {
341
86
            inner: Arc::new(PoolInner {
342
86
                config,
343
86
                state: Mutex::new(HashMap::new()),
344
86
                returned: Notify::new(),
345
86
            }),
346
86
        }
347
86
    }
348
349
    /// Acquire a worker for `key`. If an idle worker is available it is
350
    /// returned. Otherwise a new one is spawned, capped at
351
    /// `max_workers_per_key`. If the cap is reached and no idle worker exists,
352
    /// returns `Err(Code::ResourceExhausted)` — the caller should fall back to
353
    /// the one-shot subprocess path or wait and retry.
354
    ///
355
    /// `working_dir` is a stable worker process directory. Per-request action
356
    /// directories are passed through `WorkRequest::sandbox_dir`, because the
357
    /// action directory is deleted after each action completes.
358
7
    pub async fn acquire(
359
7
        &self,
360
7
        key: WorkerKey,
361
7
        executable_path: &Path,
362
7
        working_dir: &Path,
363
9
    ) -> Result<Lease, Error> {
364
9
        let deadline = tokio::time::Instant::now() + self.inner.config.acquire_timeout;
365
        loop {
366
            // Take an idle worker, or reserve a slot to spawn one, without
367
            // blocking on the spawn itself.
368
2
            let notified = {
369
10
                let mut state = self.inner.state.lock();
370
10
                let entry = state.entry(key.clone()).or_insert_with(KeyState::new);
371
10
                while let Some(
mut worker2
) = entry.idle.pop() {
372
2
                    if worker.is_dead() {
373
                        // Process died while idle (e.g. JVM OOM). Drop it.
374
0
                        entry.total_count = entry.total_count.saturating_sub(1);
375
0
                        continue;
376
2
                    }
377
2
                    return Ok(Lease {
378
2
                        inner: Some(LeaseInner {
379
2
                            worker,
380
2
                            pool: self.inner.clone(),
381
2
                            key,
382
2
                        }),
383
2
                    });
384
                }
385
8
                if entry.total_count < self.inner.config.max_workers_per_key {
386
                    // Reserve a slot before releasing the lock so a concurrent
387
                    // acquire can't over-commit.
388
6
                    entry.total_count += 1;
389
6
                    break;
390
2
                }
391
                // At the cap with nothing idle: wait for a worker to come
392
                // back. The waiter is registered under the lock so a return
393
                // between the check and the wait is not missed.
394
2
                let notified = self.inner.returned.notified();
395
2
                let mut notified = Box::pin(notified);
396
2
                notified.as_mut().enable();
397
2
                notified
398
            };
399
2
            if tokio::time::timeout_at(deadline, notified).await.is_err() {
400
1
                return Err(make_err!(
401
1
                    Code::ResourceExhausted,
402
1
                    "Persistent worker pool for {key:?} is at capacity ({} workers) and none came back within {:?}",
403
1
                    self.inner.config.max_workers_per_key,
404
1
                    self.inner.config.acquire_timeout
405
1
                ));
406
1
            }
407
        }
408
409
        // Lock released; spawn outside the critical section.
410
6
        let count_slot = CountSlotGuard::new(self.inner.clone(), key.clone());
411
6
        let spawn_result = LiveWorker::spawn(
412
6
            executable_path,
413
6
            &key.startup_args,
414
6
            key.wire_format,
415
6
            working_dir,
416
6
            &key.env,
417
6
            self.inner.config.namespaced,
418
        );
419
6
        let worker = match spawn_result {
420
6
            Ok(w) => w,
421
0
            Err(err) => {
422
0
                return Err(err).err_tip(|| format!("Spawning persistent worker for {key:?}"));
423
            }
424
        };
425
6
        count_slot.disarm();
426
6
        info!(?key, "Spawned new persistent worker");
427
6
        Ok(Lease {
428
6
            inner: Some(LeaseInner {
429
6
                worker,
430
6
                pool: self.inner.clone(),
431
6
                key,
432
6
            }),
433
6
        })
434
9
    }
435
436
    /// Runs `sweep_idle` on a timer until the pool is dropped. Without it an
437
    /// idle worker process, a JVM as a rule, lived as long as the worker did.
438
83
    pub fn spawn_sweeper(&self) -> JoinHandleDropGuard<()> {
439
83
        let inner = Arc::downgrade(&self.inner);
440
83
        let interval = (self.inner.config.idle_timeout / 4).max(Duration::from_secs(1));
441
83
        JoinHandleDropGuard::new(background_spawn!("persistent_worker_sweeper", async move 
{78
442
            loop {
443
78
                tokio::time::sleep(interval).await;
444
1
                let Some(inner) = inner.upgrade() else {
445
0
                    return;
446
                };
447
1
                Self { inner }.sweep_idle().await;
448
            }
449
0
        }))
450
83
    }
451
452
    /// How many worker processes the pool holds, idle or leased.
453
13
    pub fn live_workers(&self) -> usize {
454
13
        self.inner
455
13
            .state
456
13
            .lock()
457
13
            .values()
458
13
            .map(|entry| entry.total_count)
459
13
            .sum()
460
13
    }
461
462
    /// Drop all idle workers whose `last_used` is older than `idle_timeout`.
463
    /// Intended to be called periodically from a background task.
464
1
    pub async fn sweep_idle(&self) {
465
1
        let threshold = std::time::Instant::now()
466
1
            .checked_sub(self.inner.config.idle_timeout)
467
1
            .unwrap_or_else(std::time::Instant::now);
468
1
        let evicted: Vec<LiveWorker> = {
469
1
            let mut state = self.inner.state.lock();
470
1
            let mut evicted = Vec::new();
471
1
            for entry in state.values_mut() {
472
1
                let (keep, drop_): (Vec<_>, Vec<_>) = mem::take(&mut entry.idle)
473
1
                    .into_iter()
474
1
                    .partition(|w| w.last_used() >= threshold);
475
1
                entry.total_count = entry.total_count.saturating_sub(drop_.len());
476
1
                entry.idle = keep;
477
1
                evicted.extend(drop_);
478
            }
479
1
            evicted
480
        };
481
1
        for w in evicted {
482
1
            w.shutdown(self.inner.config.shutdown_grace).await;
483
        }
484
0
    }
485
}
486
487
#[cfg(test)]
488
mod tests {
489
    #[cfg(unix)]
490
    use std::io::Write as _;
491
492
    #[cfg(unix)]
493
    use nativelink_macro::nativelink_test;
494
495
    use super::*;
496
497
    #[cfg(unix)]
498
4
    fn shell_script(working_dir: &Path, body: &str) -> PathBuf {
499
4
        let path = working_dir.join("worker.sh");
500
4
        let mut file = std::fs::File::create(&path).unwrap();
501
4
        file.write_all(body.as_bytes()).unwrap();
502
4
        file.sync_all().unwrap();
503
4
        path
504
4
    }
505
506
    /// The worker process gets only the environment in its key, so the
507
    /// scripts need PATH to find `sleep` wherever the tests run.
508
    #[cfg(unix)]
509
4
    fn path_env() -> Vec<(String, String)> {
510
4
        vec![(
511
4
            "PATH".to_string(),
512
4
            std::env::var("PATH").unwrap_or_default(),
513
4
        )]
514
4
    }
515
516
    #[cfg(unix)]
517
4
    fn shell_worker_key(script: &Path) -> WorkerKey {
518
4
        WorkerKey {
519
4
            executable: PathBuf::from("/bin/sh"),
520
4
            startup_args: vec![script.display().to_string()],
521
4
            wire_format: WireFormat::Json,
522
4
            env: path_env(),
523
4
        }
524
4
    }
525
526
    #[test]
527
1
    fn worker_key_excludes_argfile_and_later_args() {
528
1
        let argv: Vec<String> = ["javac", "-source", "21", "@args.txt", "Foo.java"]
529
1
            .iter()
530
5
            .
map1
(|s| (*s).to_string())
531
1
            .collect();
532
1
        let key = WorkerKey::from_argv(&argv, WireFormat::Proto).unwrap();
533
1
        assert_eq!(key.executable, PathBuf::from("javac"));
534
1
        assert_eq!(key.startup_args, vec!["-source".to_string(), "21".into()]);
535
1
    }
536
537
    #[test]
538
1
    fn worker_key_handles_empty_startup_args() {
539
1
        let argv: Vec<String> = ["javac", "@args.txt"]
540
1
            .iter()
541
2
            .
map1
(|s| (*s).to_string())
542
1
            .collect();
543
1
        let key = WorkerKey::from_argv(&argv, WireFormat::Json).unwrap();
544
1
        assert_eq!(key.startup_args, Vec::<String>::new());
545
1
        assert_eq!(key.wire_format, WireFormat::Json);
546
1
    }
547
548
    #[test]
549
1
    fn worker_key_handles_no_argfile() {
550
        // No @argfile means *all* trailing args are startup flags. Unusual but
551
        // legal — the action just doesn't use a response file.
552
1
        let argv: Vec<String> = ["scalac", "-deprecation", "-Xfatal-warnings"]
553
1
            .iter()
554
3
            .
map1
(|s| (*s).to_string())
555
1
            .collect();
556
1
        let key = WorkerKey::from_argv(&argv, WireFormat::Proto).unwrap();
557
1
        assert_eq!(key.startup_args.len(), 2);
558
1
    }
559
560
    #[test]
561
1
    fn worker_key_rejects_empty_argv() {
562
1
        let argv: Vec<String> = Vec::new();
563
1
        assert!(WorkerKey::from_argv(&argv, WireFormat::Proto).is_err());
564
1
    }
565
566
    #[test]
567
1
    fn worker_key_equality_collapses_compatible_actions() {
568
1
        let a = WorkerKey::from_argv(
569
1
            &["javac", "-source", "21", "@a"].map(String::from),
570
1
            WireFormat::Proto,
571
        )
572
1
        .unwrap();
573
1
        let b = WorkerKey::from_argv(
574
1
            &["javac", "-source", "21", "@b"].map(String::from),
575
1
            WireFormat::Proto,
576
        )
577
1
        .unwrap();
578
1
        assert_eq!(a, b);
579
1
    }
580
581
    #[test]
582
1
    fn worker_key_distinguishes_different_startup_args() {
583
1
        let a = WorkerKey::from_argv(
584
1
            &["javac", "-source", "21", "@a"].map(String::from),
585
1
            WireFormat::Proto,
586
        )
587
1
        .unwrap();
588
1
        let b = WorkerKey::from_argv(
589
1
            &["javac", "-source", "17", "@a"].map(String::from),
590
1
            WireFormat::Proto,
591
        )
592
1
        .unwrap();
593
1
        assert_ne!(a, b);
594
1
    }
595
596
    #[test]
597
1
    fn worker_key_distinguishes_wire_formats() {
598
1
        let a =
599
1
            WorkerKey::from_argv(&["javac", "@a"].map(String::from), WireFormat::Proto).unwrap();
600
1
        let b = WorkerKey::from_argv(&["javac", "@a"].map(String::from), WireFormat::Json).unwrap();
601
1
        assert_ne!(a, b);
602
1
    }
603
604
    #[test]
605
1
    fn worker_key_distinguishes_environments_and_ignores_their_order() {
606
1
        let base =
607
3
            || WorkerKey::from_argv(&["javac", "@a"].map(String::from), WireFormat::Proto).unwrap();
608
1
        let a = base().with_env(vec![
609
1
            ("PATH".into(), "/bin".into()),
610
1
            ("LANG".into(), "C".into()),
611
        ]);
612
1
        let b = base().with_env(vec![
613
1
            ("LANG".into(), "C".into()),
614
1
            ("PATH".into(), "/bin".into()),
615
        ]);
616
1
        let c = base().with_env(vec![("PATH".into(), "/usr/bin".into())]);
617
1
        assert_eq!(a, b);
618
1
        assert_ne!(a, c);
619
1
    }
620
621
    #[nativelink_test]
622
    #[cfg(unix)]
623
    async fn sweeper_retires_a_worker_idle_past_the_timeout() {
624
        let dir = tempfile::tempdir().unwrap();
625
        let script = shell_script(dir.path(), "exec sleep 60\n");
626
        let key = shell_worker_key(&script);
627
        let pool = PersistentWorkerPool::new(PoolConfig {
628
            idle_timeout: Duration::from_millis(200),
629
            shutdown_grace: Duration::from_millis(100),
630
            ..PoolConfig::default()
631
        });
632
        let _sweeper = pool.spawn_sweeper();
633
634
        let lease = pool
635
            .acquire(key, Path::new("/bin/sh"), dir.path())
636
            .await
637
            .unwrap();
638
        lease.release(true).await;
639
        assert_eq!(pool.live_workers(), 1);
640
641
        // The sweeper runs every second at least; by then the worker has
642
        // been idle far longer than its timeout.
643
        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
644
        while pool.live_workers() > 0 && tokio::time::Instant::now() < deadline {
645
            tokio::time::sleep(Duration::from_millis(100)).await;
646
        }
647
        assert_eq!(pool.live_workers(), 0, "the idle worker was not retired");
648
    }
649
650
    #[nativelink_test]
651
    #[cfg(unix)]
652
    async fn dropping_unhealthy_release_frees_pool_slot() {
653
        let dir = tempfile::tempdir().unwrap();
654
        let script = shell_script(dir.path(), "exec sleep 60\n");
655
        let key = shell_worker_key(&script);
656
        let pool = PersistentWorkerPool::new(PoolConfig {
657
            max_workers_per_key: 1,
658
            shutdown_grace: Duration::from_secs(1),
659
            ..PoolConfig::default()
660
        });
661
662
        let lease = pool
663
            .acquire(key.clone(), Path::new("/bin/sh"), dir.path())
664
            .await
665
            .unwrap();
666
        let release_handle =
667
            background_spawn!("persistent_worker_release_test", lease.release(false));
668
        tokio::time::sleep(Duration::from_millis(50)).await;
669
        release_handle.abort();
670
        assert!(release_handle.await.unwrap_err().is_cancelled());
671
672
        let next_lease = tokio::time::timeout(
673
            Duration::from_secs(2),
674
            pool.acquire(key, Path::new("/bin/sh"), dir.path()),
675
        )
676
        .await
677
        .unwrap()
678
        .unwrap();
679
        next_lease.release(false).await;
680
    }
681
682
    #[nativelink_test]
683
    #[cfg(unix)]
684
    async fn acquire_at_the_cap_waits_for_a_returned_worker() {
685
        let dir = tempfile::tempdir().unwrap();
686
        let script = shell_script(dir.path(), "exec sleep 60\n");
687
        let key = shell_worker_key(&script);
688
        let pool = PersistentWorkerPool::new(PoolConfig {
689
            max_workers_per_key: 1,
690
            shutdown_grace: Duration::from_millis(100),
691
            acquire_timeout: Duration::from_secs(5),
692
            ..PoolConfig::default()
693
        });
694
695
        let lease = pool
696
            .acquire(key.clone(), Path::new("/bin/sh"), dir.path())
697
            .await
698
            .unwrap();
699
        let waiter = {
700
            let pool = pool.clone();
701
            let key = key.clone();
702
            let dir = dir.path().to_path_buf();
703
1
            background_spawn!("persistent_worker_acquire_waiter", async move {
704
1
                pool.acquire(key, Path::new("/bin/sh"), &dir).await
705
1
            })
706
        };
707
        tokio::time::sleep(Duration::from_millis(100)).await;
708
        assert!(
709
            !waiter.is_finished(),
710
            "the second acquire should be waiting at the cap"
711
        );
712
        lease.release(true).await;
713
        let second = tokio::time::timeout(Duration::from_secs(2), waiter)
714
            .await
715
            .expect("the waiter got the returned worker")
716
            .unwrap()
717
            .unwrap();
718
        second.release(false).await;
719
    }
720
721
    #[nativelink_test]
722
    #[cfg(unix)]
723
    async fn concurrent_acquire_respects_per_key_cap() {
724
        let dir = tempfile::tempdir().unwrap();
725
        let script = shell_script(dir.path(), "exec sleep 60\n");
726
        let key = shell_worker_key(&script);
727
        let pool = PersistentWorkerPool::new(PoolConfig {
728
            max_workers_per_key: 1,
729
            shutdown_grace: Duration::from_millis(100),
730
            acquire_timeout: Duration::from_millis(200),
731
            ..PoolConfig::default()
732
        });
733
734
        let lease = pool
735
            .acquire(key.clone(), Path::new("/bin/sh"), dir.path())
736
            .await
737
            .unwrap();
738
        let err = pool
739
            .acquire(key, Path::new("/bin/sh"), dir.path())
740
            .await
741
            .unwrap_err();
742
        assert_eq!(err.code, Code::ResourceExhausted);
743
        lease.release(false).await;
744
    }
745
}