atagen

roundabout

public
Star0
Watch0
61c6e94355d9b164b40de203ec22111b4da17cbf
1
ssh://brewery@pls.lobotomise.me:33/atagen/roundabout.git
src/daemon.rsRust653 lines20.1 KB
1
use std::{
2
    collections::BTreeMap,
3
    fs, io,
4
    os::{
5
        fd::FromRawFd,
6
        unix::net::{UnixListener, UnixStream},
7
    },
8
    path::PathBuf,
9
    process,
10
    sync::{
11
        Arc, Mutex, MutexGuard,
12
        atomic::{AtomicBool, Ordering},
13
    },
14
    thread,
15
    time::{Duration, Instant},
16
};
17
18
use crate::{
19
    client,
20
    config::Config,
21
    paths,
22
    proto::{self, Acquire, Event, LeaseId, LeaseInfo, Reader, Request, Status},
23
    sched::{self, Capacity, Disk, FsId, Need, Queued, Reason, Running, Verdict},
24
    sys,
25
    units::Bytes,
26
};
27
28
struct Run {
29
    started: Instant,
30
    rss: Bytes,
31
    reminded: Option<Instant>,
32
}
33
34
struct Lease {
35
    req: Acquire,
36
    heavy: bool,
37
    fs: Vec<FsId>,
38
    queued_at: Instant,
39
    run: Option<Run>,
40
    out: UnixStream,
41
    waiting: Option<Reason>,
42
    last_pos: usize,
43
    watchers: Vec<UnixStream>,
44
}
45
46
impl Lease {
47
    fn elapsed(&self) -> Duration {
48
        self.run
49
            .as_ref()
50
            .map_or(self.queued_at, |r| r.started)
51
            .elapsed()
52
    }
53
54
    fn overdue(&self, grace: f64) -> bool {
55
        self.run.is_some() && self.elapsed() > self.req.need.time.mul_f64(grace)
56
    }
57
}
58
59
struct State {
60
    cfg: Config,
61
    next: LeaseId,
62
    leases: BTreeMap<LeaseId, Lease>,
63
    cap: Capacity,
64
    baselines: BTreeMap<FsId, Bytes>,
65
    conns: usize,
66
    idle_since: Instant,
67
}
68
69
type Shared = Arc<Mutex<State>>;
70
71
fn lock(shared: &Shared) -> MutexGuard<'_, State> {
72
    shared
73
        .lock()
74
        .unwrap_or_else(std::sync::PoisonError::into_inner)
75
}
76
77
impl State {
78
    fn new(cfg: Config) -> Self {
79
        let mut st = Self {
80
            cfg,
81
            next: 1,
82
            leases: BTreeMap::new(),
83
            cap: Capacity {
84
                ram_budget: Bytes::ZERO,
85
                ram_live: Bytes::ZERO,
86
                ram_reserve: Bytes::ZERO,
87
                cores: 1,
88
                disks: BTreeMap::new(),
89
                pressure: false,
90
            },
91
            baselines: BTreeMap::new(),
92
            conns: 0,
93
            idle_since: Instant::now(),
94
        };
95
        st.apply_budget();
96
        st
97
    }
98
99
    fn apply_budget(&mut self) {
100
        let (total, live) = sys::memory();
101
        self.cap.ram_budget = self.cfg.budget.ram.resolve(total);
102
        self.cap.ram_live = live;
103
        self.cap.ram_reserve = self.cfg.budget.ram_reserve;
104
        self.cap.cores = self.cfg.budget.cores.resolve(sys::cores());
105
    }
106
107
    /// running leases keep the heaviness they started with, so a reload can't
108
    /// retroactively put two heavies side by side
109
    fn reload(&mut self, cfg: Config) {
110
        self.cfg = cfg;
111
        self.apply_budget();
112
        let queued: Vec<(LeaseId, Need)> = self
113
            .leases
114
            .iter()
115
            .filter(|(_, l)| l.run.is_none())
116
            .map(|(id, l)| (*id, l.req.need))
117
            .collect();
118
        for (id, need) in queued {
119
            let heavy = self.heavy(&need);
120
            if let Some(l) = self.leases.get_mut(&id) {
121
                l.heavy = heavy;
122
            }
123
        }
124
        self.refresh();
125
        self.reschedule();
126
    }
127
128
    fn heavy(&self, need: &Need) -> bool {
129
        let h = &self.cfg.heavy;
130
        need.ram >= h.ram || need.disk >= h.disk || need.cores >= h.cores.resolve(sys::cores())
131
    }
132
133
    /// capped at a tenth of the fs, or a 16G tmpfs /tmp with a 20G reserve
134
    /// admits nothing ever
135
    fn reserve(&self, size: Bytes) -> Bytes {
136
        self.cfg.budget.disk_reserve.min(Bytes(size.0 / 10))
137
    }
138
139
    fn fs_for(&mut self, cwd: &PathBuf) -> Vec<FsId> {
140
        let mut ids = Vec::new();
141
        for path in std::iter::once(cwd).chain(&self.cfg.budget.disk_paths) {
142
            if let Some(fs) = sys::fs(path) {
143
                if !ids.contains(&fs.id) {
144
                    ids.push(fs.id);
145
                }
146
                let reserve = self.reserve(fs.size);
147
                self.cap.disks.entry(fs.id).or_insert(Disk {
148
                    free: fs.free,
149
                    reserve,
150
                    consumed: Bytes::ZERO,
151
                });
152
            }
153
        }
154
        ids
155
    }
156
157
    fn refresh(&mut self) {
158
        let (_, live) = sys::memory();
159
        self.cap.ram_live = live;
160
        self.cap.pressure = self
161
            .cfg
162
            .budget
163
            .psi_max
164
            .is_some_and(|max| sys::pressure().is_some_and(|p| p > max));
165
166
        let mut paths: Vec<PathBuf> = self.leases.values().map(|l| l.req.cwd.clone()).collect();
167
        paths.extend(self.cfg.budget.disk_paths.iter().cloned());
168
        let mut disks = BTreeMap::new();
169
        for path in paths {
170
            if let Some(fs) = sys::fs(&path) {
171
                disks.insert(fs.id, (fs.free, self.reserve(fs.size)));
172
            }
173
        }
174
175
        let busy: Vec<FsId> = self
176
            .leases
177
            .values()
178
            .filter(|l| l.run.is_some())
179
            .flat_map(|l| l.fs.iter().copied())
180
            .collect();
181
        self.baselines.retain(|id, _| busy.contains(id));
182
        self.cap.disks = disks
183
            .into_iter()
184
            .map(|(id, (free, reserve))| {
185
                let consumed = if busy.contains(&id) {
186
                    self.baselines
187
                        .entry(id)
188
                        .or_insert(free)
189
                        .saturating_sub(free)
190
                } else {
191
                    Bytes::ZERO
192
                };
193
                (
194
                    id,
195
                    Disk {
196
                        free,
197
                        reserve,
198
                        consumed,
199
                    },
200
                )
201
            })
202
            .collect();
203
    }
204
205
    fn running(&self) -> Vec<Running> {
206
        self.leases
207
            .values()
208
            .filter_map(|l| {
209
                let run = l.run.as_ref()?;
210
                Some(Running {
211
                    need: l.req.need,
212
                    heavy: l.heavy,
213
                    fs: l.fs.clone(),
214
                    rss: run.rss,
215
                    remaining: l.req.need.time.saturating_sub(run.started.elapsed()),
216
                })
217
            })
218
            .collect()
219
    }
220
221
    fn reschedule(&mut self) {
222
        let queue: Vec<Queued> = self
223
            .leases
224
            .iter()
225
            .filter(|(_, l)| l.run.is_none())
226
            .map(|(id, l)| Queued {
227
                id: *id,
228
                need: l.req.need,
229
                heavy: l.heavy,
230
                fs: l.fs.clone(),
231
            })
232
            .collect();
233
        if queue.is_empty() {
234
            return;
235
        }
236
        let verdicts = sched::plan(
237
            &queue,
238
            &self.running(),
239
            &self.cap,
240
            self.cfg.queue.backfill_max_time.0,
241
        );
242
        let cores = self.cap.cores;
243
        let mut position = 0;
244
        for (id, verdict) in verdicts {
245
            let Some(lease) = self.leases.get_mut(&id) else {
246
                continue;
247
            };
248
            match verdict {
249
                Verdict::Admit => {
250
                    lease.run = Some(Run {
251
                        started: Instant::now(),
252
                        rss: Bytes::ZERO,
253
                        reminded: None,
254
                    });
255
                    let _ = proto::send(
256
                        &mut lease.out,
257
                        &Event::Granted {
258
                            lease: id,
259
                            cores: lease.req.need.cores.clamp(1, cores.max(1)),
260
                            heavy: lease.heavy,
261
                        },
262
                    );
263
                }
264
                Verdict::Wait(reason) => {
265
                    position += 1;
266
                    lease.waiting = Some(reason);
267
                    if lease.last_pos != position {
268
                        lease.last_pos = position;
269
                        let _ = proto::send(
270
                            &mut lease.out,
271
                            &Event::Queued {
272
                                lease: id,
273
                                position,
274
                                reason: Some(reason),
275
                            },
276
                        );
277
                    }
278
                }
279
            }
280
        }
281
    }
282
283
    fn remove(&mut self, id: LeaseId) {
284
        if let Some(lease) = self.leases.remove(&id) {
285
            for mut w in lease.watchers {
286
                let _ = proto::send(&mut w, &Event::Fired { overdue: false });
287
            }
288
        }
289
        if self.leases.is_empty() {
290
            self.idle_since = Instant::now();
291
        }
292
        self.refresh();
293
        self.reschedule();
294
    }
295
296
    fn fire_overdue(&mut self) {
297
        let grace = self.cfg.overrun.time_grace;
298
        for lease in self.leases.values_mut() {
299
            if !lease.watchers.is_empty() && lease.overdue(grace) {
300
                for mut w in lease.watchers.drain(..) {
301
                    let _ = proto::send(&mut w, &Event::Fired { overdue: true });
302
                }
303
            }
304
        }
305
    }
306
307
    fn info(&self) -> Vec<LeaseInfo> {
308
        let grace = self.cfg.overrun.time_grace;
309
        let mut position = 0;
310
        self.leases
311
            .iter()
312
            .map(|(id, l)| {
313
                if l.run.is_none() {
314
                    position += 1;
315
                }
316
                LeaseInfo {
317
                    id: *id,
318
                    cmd: l.req.cmd.clone(),
319
                    cwd: l.req.cwd.clone(),
320
                    session: l.req.session.clone(),
321
                    need: l.req.need,
322
                    heavy: l.heavy,
323
                    running: l.run.is_some(),
324
                    elapsed: l.elapsed().as_secs(),
325
                    rss: l.run.as_ref().map_or(Bytes::ZERO, |r| r.rss),
326
                    overdue: l.overdue(grace),
327
                    waiting: l.waiting.filter(|_| l.run.is_none()),
328
                    position: if l.run.is_none() { position } else { 0 },
329
                }
330
            })
331
            .collect()
332
    }
333
334
    fn status(&self) -> Status {
335
        let (ram_free, cores_free) = sched::headroom(&self.running(), &self.cap);
336
        Status {
337
            leases: self.info(),
338
            ram_free,
339
            ram_budget: self.cap.ram_budget,
340
            cores_free,
341
            cores: self.cap.cores,
342
        }
343
    }
344
}
345
346
fn serve(shared: &Shared, mut stream: UnixStream) -> io::Result<()> {
347
    let mut reader = Reader::new(&stream)?;
348
    let Some(req) = reader.recv::<Request>()? else {
349
        return Ok(());
350
    };
351
    match req {
352
        Request::Acquire(req) => {
353
            let id = {
354
                let mut st = lock(shared);
355
                let id = st.next;
356
                st.next += 1;
357
                let fs = st.fs_for(&req.cwd);
358
                let heavy = st.heavy(&req.need);
359
                st.leases.insert(
360
                    id,
361
                    Lease {
362
                        req,
363
                        heavy,
364
                        fs,
365
                        queued_at: Instant::now(),
366
                        run: None,
367
                        out: stream.try_clone()?,
368
                        waiting: None,
369
                        last_pos: 0,
370
                        watchers: Vec::new(),
371
                    },
372
                );
373
                st.reschedule();
374
                id
375
            };
376
            hold(shared, id, &mut reader);
377
            lock(shared).remove(id);
378
            Ok(())
379
        }
380
        Request::Status => {
381
            let status = lock(shared).status();
382
            proto::send(&mut stream, &Event::Status(status))
383
        }
384
        Request::Kill { lease } => {
385
            let mut st = lock(shared);
386
            let reply = match st.leases.get_mut(&lease) {
387
                Some(l) => {
388
                    let _ = proto::send(&mut l.out, &Event::Kill);
389
                    Event::Ok
390
                }
391
                None => Event::Err {
392
                    msg: format!("no lease {lease}"),
393
                },
394
            };
395
            drop(st);
396
            proto::send(&mut stream, &reply)
397
        }
398
        Request::Extend { lease, by } => {
399
            let mut st = lock(shared);
400
            let reply = match st.leases.get_mut(&lease) {
401
                Some(l) => {
402
                    l.req.need.time += by.0;
403
                    if let Some(run) = &mut l.run {
404
                        run.reminded = None;
405
                    }
406
                    Event::Ok
407
                }
408
                None => Event::Err {
409
                    msg: format!("no lease {lease}"),
410
                },
411
            };
412
            drop(st);
413
            proto::send(&mut stream, &reply)
414
        }
415
        Request::Watch { lease } => {
416
            let mut st = lock(shared);
417
            let grace = st.cfg.overrun.time_grace;
418
            match st.leases.get_mut(&lease) {
419
                Some(l) if l.overdue(grace) => {
420
                    proto::send(&mut stream, &Event::Fired { overdue: true })
421
                }
422
                Some(l) => {
423
                    l.watchers.push(stream);
424
                    Ok(())
425
                }
426
                None => proto::send(&mut stream, &Event::Fired { overdue: false }),
427
            }
428
        }
429
        Request::Overdue { session } => {
430
            let mut st = lock(shared);
431
            let grace = st.cfg.overrun.time_grace;
432
            let every = st.cfg.overrun.remind_every.0;
433
            let mut due = Vec::new();
434
            for (id, l) in &mut st.leases {
435
                if l.req.session.as_deref() != Some(&session) || !l.overdue(grace) {
436
                    continue;
437
                }
438
                let Some(run) = &mut l.run else { continue };
439
                if run.reminded.is_some_and(|t| t.elapsed() < every) {
440
                    continue;
441
                }
442
                run.reminded = Some(Instant::now());
443
                due.push(*id);
444
            }
445
            let leases = st
446
                .info()
447
                .into_iter()
448
                .filter(|i| due.contains(&i.id))
449
                .collect();
450
            drop(st);
451
            proto::send(&mut stream, &Event::Overdue { leases })
452
        }
453
        Request::Started { .. } | Request::Usage { .. } | Request::Done => proto::send(
454
            &mut stream,
455
            &Event::Err {
456
                msg: "no lease on this connection".into(),
457
            },
458
        ),
459
    }
460
}
461
462
/// a client that vanishes without saying done got sigkilled or similar, and
463
/// pdeathsig only reaches its direct child. take the rest of the group down
464
/// so a headless build doesn't keep eating ram off the books
465
fn hold(shared: &Shared, id: LeaseId, reader: &mut Reader) {
466
    let mut pgid = None;
467
    let clean = loop {
468
        match reader.recv::<Request>() {
469
            Ok(Some(Request::Usage { rss })) => {
470
                if let Some(run) = lock(shared)
471
                    .leases
472
                    .get_mut(&id)
473
                    .and_then(|l| l.run.as_mut())
474
                {
475
                    run.rss = rss;
476
                }
477
            }
478
            Ok(Some(Request::Started { pid })) => pgid = Some(pid),
479
            Ok(Some(Request::Done)) => break true,
480
            Ok(Some(_)) => {}
481
            Ok(None) | Err(_) => break false,
482
        }
483
    };
484
    if !clean && let Some(pgid) = pgid.filter(|p| *p > 1) {
485
        unsafe { libc::kill(-pgid, libc::SIGTERM) };
486
    }
487
}
488
489
fn tick(shared: &Shared) {
490
    loop {
491
        thread::sleep(Duration::from_secs(1));
492
        let mut st = lock(shared);
493
        st.refresh();
494
        st.reschedule();
495
        st.fire_overdue();
496
        if st.conns == 0 && st.leases.is_empty() && st.idle_since.elapsed() >= st.cfg.queue.linger.0
497
        {
498
            // systemd owns the socket and queues whoever knocks while we're
499
            // leaving, so there's nothing to tidy
500
            if ACTIVATED.load(Ordering::Relaxed) {
501
                process::exit(0);
502
            }
503
            // a spawner holds this while it clears a stale socket, so taking it
504
            // here stops us unlinking our successor's socket on the way out
505
            if let Some(_guard) = try_spawn_lock() {
506
                let _ = fs::remove_file(paths::socket());
507
                process::exit(0);
508
            }
509
        }
510
    }
511
}
512
513
/// watches the directory, not the file. editors save by renaming over it,
514
/// which would orphan a watch on the old inode after the first edit
515
#[cfg(target_os = "linux")]
516
fn watch_config(shared: &Shared) {
517
    use std::os::unix::ffi::OsStrExt;
518
519
    let path = paths::config_file();
520
    let (Some(dir), Some(name)) = (path.parent(), path.file_name()) else {
521
        return;
522
    };
523
    let _ = fs::create_dir_all(dir);
524
    let Ok(cdir) = std::ffi::CString::new(dir.as_os_str().as_bytes()) else {
525
        return;
526
    };
527
    let fd = unsafe { libc::inotify_init1(libc::IN_CLOEXEC) };
528
    if fd < 0 {
529
        return;
530
    }
531
    let mask = libc::IN_CLOSE_WRITE
532
        | libc::IN_MOVED_TO
533
        | libc::IN_MOVED_FROM
534
        | libc::IN_CREATE
535
        | libc::IN_DELETE;
536
    if unsafe { libc::inotify_add_watch(fd, cdir.as_ptr(), mask) } < 0 {
537
        return;
538
    }
539
540
    let header = std::mem::size_of::<libc::inotify_event>();
541
    let mut buf = [0u8; 4096];
542
    loop {
543
        let n = unsafe { libc::read(fd, buf.as_mut_ptr().cast(), buf.len()) };
544
        let Ok(n) = usize::try_from(n) else {
545
            return;
546
        };
547
        let mut hit = false;
548
        let mut off = 0;
549
        while off + header <= n {
550
            let ev: libc::inotify_event =
551
                unsafe { std::ptr::read_unaligned(buf.as_ptr().add(off).cast()) };
552
            let len = ev.len as usize;
553
            let raw = &buf[off + header..(off + header + len).min(n)];
554
            let ev_name = raw.split(|b| *b == 0).next().unwrap_or_default();
555
            hit |= ev_name == name.as_bytes();
556
            off += header + len;
557
        }
558
        if !hit {
559
            continue;
560
        }
561
        thread::sleep(Duration::from_millis(100));
562
        match Config::load() {
563
            Ok(cfg) => {
564
                lock(shared).reload(cfg);
565
                eprintln!("roundabout: config reloaded");
566
            }
567
            Err(e) => eprintln!("roundabout: kept the old config, {e}"),
568
        }
569
    }
570
}
571
572
/// creates the file if it's gone, else a wiped runtime dir leaves us idling
573
/// forever unable to take the lock we need to exit
574
fn try_spawn_lock() -> Option<client::Flock> {
575
    let file = fs::OpenOptions::new()
576
        .create(true)
577
        .truncate(false)
578
        .write(true)
579
        .open(paths::spawn_lock())
580
        .ok()?;
581
    client::Flock::try_exclusive(file)
582
}
583
584
static ACTIVATED: AtomicBool = AtomicBool::new(false);
585
586
fn inherited() -> Option<UnixListener> {
587
    let ours = std::env::var("LISTEN_PID").ok()?.parse::<u32>().ok()? == process::id();
588
    if !ours || std::env::var("LISTEN_FDS").ok()? != "1" {
589
        return None;
590
    }
591
    // SAFETY: sd_listen_fds protocol, fd 3 is the listening socket
592
    Some(unsafe { UnixListener::from_raw_fd(3) })
593
}
594
595
fn bind() -> UnixListener {
596
    // fork once more so the client that spawned us can reap its child now
597
    // instead of carrying a zombie for the length of its build
598
    match unsafe { libc::fork() } {
599
        -1 => {
600
            eprintln!("roundabout: fork failed: {}", io::Error::last_os_error());
601
            process::exit(1);
602
        }
603
        0 => {}
604
        _ => process::exit(0),
605
    }
606
    match UnixListener::bind(paths::socket()) {
607
        Ok(l) => l,
608
        Err(e) if e.kind() == io::ErrorKind::AddrInUse && client::connect().is_some() => {
609
            process::exit(0)
610
        }
611
        Err(e) => {
612
            eprintln!("roundabout: bind: {e}");
613
            process::exit(1);
614
        }
615
    }
616
}
617
618
pub fn main(cfg: Config) -> ! {
619
    let listener = if let Some(l) = inherited() {
620
        ACTIVATED.store(true, Ordering::Relaxed);
621
        l
622
    } else {
623
        bind()
624
    };
625
626
    let shared: Shared = Arc::new(Mutex::new(State::new(cfg)));
627
    {
628
        let shared = shared.clone();
629
        thread::spawn(move || tick(&shared));
630
    }
631
    #[cfg(target_os = "linux")]
632
    {
633
        let shared = shared.clone();
634
        thread::spawn(move || watch_config(&shared));
635
    }
636
637
    for stream in listener.incoming() {
638
        let Ok(stream) = stream else { continue };
639
        lock(&shared).conns += 1;
640
        let shared = shared.clone();
641
        thread::spawn(move || {
642
            if let Err(e) = serve(&shared, stream) {
643
                eprintln!("roundabout: {e}");
644
            }
645
            let mut st = lock(&shared);
646
            st.conns -= 1;
647
            if st.leases.is_empty() {
648
                st.idle_since = Instant::now();
649
            }
650
        });
651
    }
652
    process::exit(0)
653
}