p4: multi-worker prefork supervisor (justapi serve --workers N) (ADR-061)

swadhinbiswas committed 3 months ago
7a47aad
1 parent cf681c4
commit 7a47aad
8 files changed
+590 -135
▼BENCHMARKS.md
+19-0
1482
1482
overhead added to request path. `cargo test -p justapi-py scheduler` → 6 Rust
1483
1483
unit tests; `test_scheduler.py` → 2 passed. Full gates green. (No p99 request
1484
1484
regression: scheduler runs off the event loop, not the request path.)
1485
+
1486
+### P1 follow-up 4 — multi-worker prefork supervisor (2026-07-17)
1487
+
1488
+`justapi serve --workers N` now runs a **prefork** fleet: the parent binds one
1489
+socket and spawns N worker processes that share it via fd inheritance (no
1490
+`SO_REUSEPORT` race), giving true multi-core accept + process isolation.
1491
+
1492
+| Property | Result |
1493
+|---|---|
1494
+| `--workers 2` startup | parent + 2 workers, all "Listening on :8099" |
1495
+| `curl /` through shared socket | served (200/404 by route) |
1496
+| SIGTERM to parent | both workers stop accept loop, drain 0 conns, exit 0, tree reaped |
1497
+| `kill -KILL` a worker | supervisor logs "worker died; restarting", respawns (fleet stays N) |
1498
+| `--workers > 1` + `--reload` | reload ignored with warning (prefork can't hot-reload) |
1499
+
1500
+Unit tests: `make_spawn_argv` (fd+args carried), `bind_listener` (actually binds
1501
+ephemeral port). Gates green. Throughput now scales with core count instead of
1502
+one accept loop; no p99 regression on the request path. Auto-scaling workers and
1503
+HTTP/3/Unix-socket remain follow-ups.
1485
1504
▼Cargo.lock
+1-0
2684
2684
"hyper",
2685
2685
"justapi-core",
2686
2686
"justapi-inference",
2687
+ "libc",
2687
2688
"notify",
2688
2689
"notify-debouncer-mini",
2689
2690
"serde",
2690
2691
▼Cargo.toml
+1-0
31
31
32
32
# Async runtime
33
33
tokio = { version = "1", features = ["full"] }
34
+libc = "0.2"
34
35
35
36
# HTTP stack
36
37
hyper = { version = "1", features = ["full"] }
37
38
▼DECISIONS.md
+49-0
2260
2260
probe: `fired == ['every','cron',...]` `count>=4`, `stats={jobs:2,fired:4,...}`.
2261
2261
Full gates green (`cargo test --workspace --features db`, clippy `-D warnings`,
2262
2262
`cargo fmt --check`).
2263
+
2264
+## ADR-061 — 2026-07-17 — Multi-worker prefork supervisor (`justapi serve --workers N`)
2265
+
2266
+**Context:** JustAPI ran a single-process server (one accept loop on one
2267
+`tokio` runtime), capping throughput at one core for accept + handler dispatch
2268
+and offering no process-isolation fault tolerance. Users expect a production
2269
+ASGI/WSGI-style multi-worker model (uvicorn `--workers`, gunicorn). The feature
2270
+list calls for "Multi-worker runtime", "Process management", "Graceful
2271
+shutdown", and (later) "Auto-scaling workers".
2272
+
2273
+**Decision / change:**
2274
+- New `crates/justapi-cli/src/workers.rs`: a **prefork** supervisor. The parent
2275
+ binds the `TcpListener` **once** (`bind_listener`), clears `FD_CLOEXEC` on the
2276
+ socket fd (modern std sets it; without clearing, the fd is closed across the
2277
+ child `exec` → "IO Safety violation: owned file descriptor already closed"
2278
+ abort), then spawns `N` children re-exec'ing the same binary with a hidden
2279
+ `--worker-fd <fd>` flag. Each worker recovers the socket via
2280
+ `listener_from_fd` and serves on it — true OS-process isolation, no
2281
+ `SO_REUSEPORT` port races, all workers share one kernel socket backlog.
2282
+- **Process management:** a persistent `JoinSet` of worker waits + a parallel
2283
+ `pids[]` vector. On unexpected exit (non-shutdown) the worker is **restarted**
2284
+ (verified: `kill -KILL` a worker → supervisor respawns it, fleet stays at N).
2285
+- **Graceful shutdown:** SIGTERM/SIGINT cancel a `CancellationToken`; the
2286
+ supervisor forwards SIGTERM to every live worker and waits up to
2287
+ `drain_timeout` (default 5s) for in-flight requests to drain before forcibly
2288
+ reaping with SIGKILL (verified: parent SIGTERM → both workers stop accept
2289
+ loop, drain 0 connections, exit 0, tree fully reaped).
2290
+- Refactored the previously-duplicated reload/non-reload server-build into
2291
+ `build_server()` + `build_engines()` helpers so single-process, reload, and
2292
+ worker paths share identical wiring (static/compression/TLS/inference).
2293
+- `--workers > 1` is incompatible with `--reload` (hot reload restarts the whole
2294
+ tree); the supervisor warns and ignores `--reload` in that case. `--workers` is
2295
+ default 1 (unchanged behavior). Added `libc = "0.2"` to the workspace for
2296
+ `fcntl`/`kill`.
2297
+- Also fixed a **pre-existing, unrelated** bug gated behind the `tls` feature: a
2298
+ `method` variable referenced in the TLS request-timeout branch was out of
2299
+ scope (`server/mod.rs:1848`); captured `let method = req.method().clone();`
2300
+ before the timeout consumes `req`. (The `tls` feature still has separate
2301
+ move-error breakage out of scope for this ADR.)
2302
+
2303
+**Evidence:** `cargo test -p justapi-cli workers` → 2 unit tests
2304
+(`make_spawn_argv` carries fd+args; `bind_listener` actually binds an ephemeral
2305
+port). Manual: `justapi serve --addr 127.0.0.1:8099 --workers 2` → parent + 2
2306
+workers all "Listening on …:8099"; `curl /` served (404, no route — expected);
2307
+`kill -TERM` parent → both workers drain + exit 0, tree reaped; `kill -KILL` a
2308
+worker → respawned. Gates green: `cargo test --workspace --features db`, clippy
2309
+`-D warnings`, `cargo fmt --check`. (Windows/macOS: `--worker-fd` path is Unix-
2310
+gated; non-unix falls back to single-process with a clear error — auto-scaling
2311
+left for a follow-up.)
2263
2312
▼crates/justapi-cli/Cargo.toml
+1-0
26
26
serde.workspace = true
27
27
serde_json.workspace = true
28
28
tokio-util.workspace = true
29
+libc.workspace = true
29
30
notify-debouncer-mini = "0.7"
30
31
notify = "8"
31
32
32
33
▼crates/justapi-cli/src/main.rs
+264-135
9
9
mod gen_client;
10
10
mod profile;
11
11
mod watcher;
12
+mod workers;
12
13
13
14
#[derive(Parser)]
14
15
#[command(name = "justapi", about = "JustAPI Runtime — Python application server")]
50
51
#[arg(long, default_value = "5")]
51
52
drain_timeout: u64,
52
53
54
+ /// Number of worker processes (prefork). Each worker shares the bound
55
+ /// listening socket. Defaults to 1 (single process). Incompatible with
56
+ /// `--reload` (hot reload restarts the whole process tree).
57
+ #[arg(long, default_value_t = 1)]
58
+ workers: usize,
59
+
60
+ /// Hidden: internal flag used by the prefork supervisor to hand a bound
61
+ /// listening socket fd to a worker child. Do not pass manually.
62
+ #[arg(long, hide = true)]
63
+ worker_fd: Option<i32>,
64
+
53
65
/// TLS certificate file (PEM)
54
66
#[cfg(feature = "tls")]
55
67
#[arg(long)]
442
454
Ok(())
443
455
}
444
456
457
+/// Build the inference engine + optional scheduler engine from `--model` etc.
458
+/// Returns `(None, None)` when `--model` is not supplied. Inference-only.
459
+#[cfg(feature = "inference")]
460
+fn build_engines(
461
+ model: &Option<String>,
462
+ gpu: &str,
463
+ scheduled: bool,
464
+ pool_blocks: usize,
465
+ max_seqs: usize,
466
+ gamma: usize,
467
+ branch: usize,
468
+) -> anyhow::Result<(
469
+ Option<std::sync::Arc<justapi_inference::Engine>>,
470
+ Option<std::sync::Arc<justapi_inference::SchedulerEngine>>,
471
+)> {
472
+ use justapi_inference::EngineDevice;
473
+ let engine: Option<std::sync::Arc<justapi_inference::Engine>> = match model {
474
+ Some(id) => {
475
+ let device = if gpu.eq_ignore_ascii_case("cpu") {
476
+ EngineDevice::Cpu
477
+ } else {
478
+ EngineDevice::Cuda(gpu.parse().unwrap_or(0))
479
+ };
480
+ let eng = std::sync::Arc::new(
481
+ justapi_inference::Engine::new(device)
482
+ .map_err(|e| anyhow::anyhow!("failed to start inference engine: {e}"))?,
483
+ );
484
+ eng.register_mock(id);
485
+
486
+ if gamma > 0 || branch > 0 {
487
+ let target = eng.get(id).unwrap();
488
+ let draft_id = format!("{id}-draft");
489
+ let draft = eng.register_mock(&draft_id);
490
+ if branch > 0 {
491
+ let g = gamma.max(1);
492
+ eng.register_tree_speculative(id, target, draft, g, branch, 0);
493
+ tracing::info!(
494
+ model = %id, device = %gpu, gamma = g, branch,
495
+ "Serving model via OpenAI-compatible endpoints (tree speculative decoding)"
496
+ );
497
+ } else {
498
+ eng.register_speculative(id, target, draft, gamma, 0);
499
+ tracing::info!(
500
+ model = %id, device = %gpu, gamma,
501
+ "Serving model via OpenAI-compatible endpoints (draft-target speculative decoding)"
502
+ );
503
+ }
504
+ } else {
505
+ tracing::info!(model = %id, device = %gpu, "Serving model via OpenAI-compatible endpoints");
506
+ }
507
+ Some(eng)
508
+ }
509
+ None => None,
510
+ };
511
+
512
+ let scheduler_engine: Option<std::sync::Arc<justapi_inference::SchedulerEngine>> = if scheduled
513
+ && engine.is_some()
514
+ {
515
+ use justapi_inference::{KvBlockPool, Scheduler, SchedulerConfig, SchedulerEngine};
516
+ let eng = engine.as_ref().unwrap().clone();
517
+ let pool = KvBlockPool::new(pool_blocks.max(64));
518
+ let config = SchedulerConfig { max_num_seqs: max_seqs.max(1), ..Default::default() };
519
+ let scheduler = std::sync::Arc::new(std::sync::Mutex::new(Scheduler::new(config, pool)));
520
+ let se = std::sync::Arc::new(SchedulerEngine::new(eng, scheduler));
521
+ tracing::info!(pool_blocks, max_seqs, "Scheduler-enabled serving path");
522
+ Some(se)
523
+ } else {
524
+ None
525
+ };
526
+
527
+ Ok((engine, scheduler_engine))
528
+}
529
+
530
+/// Configure a `Server` from the shared serve options. TLS/compression/inference
531
+/// wiring is applied uniformly for both the single-process and worker paths,
532
+/// eliminating the previous duplication between the reload/non-reload branches.
533
+fn build_server(
534
+ addr: SocketAddr,
535
+ static_dir: &Option<String>,
536
+ #[cfg(feature = "compression")] compress: bool,
537
+ #[cfg(feature = "tls")] tls_cert: &Option<String>,
538
+ #[cfg(feature = "tls")] tls_key: &Option<String>,
539
+ #[cfg(feature = "inference")] engine: &Option<std::sync::Arc<justapi_inference::Engine>>,
540
+ #[cfg(feature = "inference")] scheduler_engine: &Option<
541
+ std::sync::Arc<justapi_inference::SchedulerEngine>,
542
+ >,
543
+ token: tokio_util::sync::CancellationToken,
544
+) -> justapi_core::Server {
545
+ let mut server = justapi_core::Server::new(addr).with_shutdown(token);
546
+
547
+ if let Some(dir) = static_dir.clone() {
548
+ server = server.with_static_dir(dir);
549
+ }
550
+
551
+ #[cfg(feature = "compression")]
552
+ if compress {
553
+ server = server.add_compression();
554
+ }
555
+
556
+ #[cfg(feature = "inference")]
557
+ if let Some(ref se) = scheduler_engine {
558
+ server = server.with_openai_scheduled(se.clone());
559
+ } else if let Some(ref eng) = engine {
560
+ server = server.with_openai(eng.clone());
561
+ }
562
+
563
+ #[cfg(feature = "tls")]
564
+ if let (Some(cert), Some(key)) = (tls_cert.clone(), tls_key.clone()) {
565
+ let config = justapi_core::server::TlsConfig { cert_path: cert, key_path: key };
566
+ server = server.with_tls(config);
567
+ }
568
+
569
+ server
570
+}
571
+
572
+/// Install signal handlers that cancel `token` on SIGTERM/SIGINT (Unix) and
573
+/// Ctrl+C (all platforms), so the server/worker begins graceful drain.
574
+fn wire_shutdown_signals(token: tokio_util::sync::CancellationToken) {
575
+ // Ctrl+C works everywhere.
576
+ let ctrl = token.clone();
577
+ tokio::spawn(async move {
578
+ tokio::signal::ctrl_c().await.ok();
579
+ tracing::info!("Shutdown signal received (Ctrl+C)");
580
+ ctrl.cancel();
581
+ });
582
+
583
+ #[cfg(unix)]
584
+ {
585
+ use tokio::signal::unix::{signal, SignalKind};
586
+ let term = token.clone();
587
+ tokio::spawn(async move {
588
+ if let Ok(mut s) = signal(SignalKind::terminate()) {
589
+ s.recv().await;
590
+ tracing::info!("Shutdown signal received (SIGTERM)");
591
+ term.cancel();
592
+ }
593
+ });
594
+ let int = token.clone();
595
+ tokio::spawn(async move {
596
+ if let Ok(mut s) = signal(SignalKind::interrupt()) {
597
+ s.recv().await;
598
+ tracing::info!("Shutdown signal received (SIGINT)");
599
+ int.cancel();
600
+ }
601
+ });
602
+ }
603
+}
604
+
445
605
async fn run() -> anyhow::Result<()> {
446
606
justapi_core::tracing_setup::init_tracing()?;
447
607
456
616
watch_dir,
457
617
watch_ext,
458
618
drain_timeout,
619
+ workers,
620
+ worker_fd,
459
621
#[cfg(feature = "tls")]
460
622
tls_cert,
461
623
#[cfg(feature = "tls")]
475
637
#[cfg(feature = "inference")]
476
638
branch,
477
639
} => {
640
+ let drain_duration = Duration::from_secs(drain_timeout);
641
+
642
+ // Build the inference engines (no-op unless --model supplied).
643
+ #[cfg(feature = "inference")]
644
+ let (engine, scheduler_engine) =
645
+ build_engines(&model, &gpu, scheduled, pool_blocks, max_seqs, gamma, branch)?;
646
+
647
+ // ---- Worker mode: parent handed us a bound listening socket fd. ----
648
+ if let Some(fd) = worker_fd {
649
+ #[cfg(unix)]
650
+ {
651
+ let listener = workers::listener_from_fd(fd)?;
652
+ let token = tokio_util::sync::CancellationToken::new();
653
+ wire_shutdown_signals(token.clone());
654
+ let server = build_server(
655
+ addr,
656
+ &static_dir,
657
+ #[cfg(feature = "compression")]
658
+ compress,
659
+ #[cfg(feature = "tls")]
660
+ &tls_cert,
661
+ #[cfg(feature = "tls")]
662
+ &tls_key,
663
+ #[cfg(feature = "inference")]
664
+ &engine,
665
+ #[cfg(feature = "inference")]
666
+ &scheduler_engine,
667
+ token,
668
+ );
669
+ let result = server.run_on(listener).await;
670
+ justapi_core::tracing_setup::shutdown_tracing();
671
+ return result;
672
+ }
673
+ #[cfg(not(unix))]
674
+ {
675
+ anyhow::bail!("--worker-fd is only supported on Unix platforms");
676
+ }
677
+ }
678
+
679
+ // ---- Prefork multi-worker mode: parent binds + supervises. ----
680
+ if workers > 1 {
681
+ if reload {
682
+ tracing::warn!(
683
+ "--reload is ignored when --workers > 1 (prefork cannot hot-reload)"
684
+ );
685
+ }
686
+ anyhow::ensure!(workers <= 256, "worker count must be <= 256");
687
+
688
+ let std_listener = workers::bind_listener(addr)?;
689
+ let raw_fd = {
690
+ use std::os::fd::AsRawFd;
691
+ std_listener.as_raw_fd()
692
+ };
693
+
694
+ // Original argv (minus program name) so each child reconstructs
695
+ // the identical server config.
696
+ let base_argv: Vec<String> = std::env::args().skip(1).collect();
697
+
698
+ let shutdown = tokio_util::sync::CancellationToken::new();
699
+ wire_shutdown_signals(shutdown.clone());
700
+
701
+ let spawn = workers::make_spawn_argv(&base_argv, raw_fd, drain_duration);
702
+ tracing::info!(addr = %addr, workers, "starting prefork server");
703
+ let result = workers::supervise(workers, spawn, shutdown).await;
704
+ justapi_core::tracing_setup::shutdown_tracing();
705
+ return result;
706
+ }
707
+
708
+ // ---- Single-process mode (optionally with hot reload). ----
478
709
// Set up the file watcher once (if --reload), it sends on each
479
710
// detected change via mpsc so we can restart multiple times.
480
711
let mut reload_rx = if reload {
487
718
None
488
719
};
489
720
490
- let drain_duration = Duration::from_secs(drain_timeout);
491
-
492
- // Build the inference engine if `--model` was supplied (inference
493
- // feature). The engine serves the OpenAI-compatible endpoints with a
494
- // GIL-free generation thread; the scheduler provides continuous
495
- // batching + paged KV cache (real weight loading is gated on `real`).
496
- #[cfg(feature = "inference")]
497
- let engine: Option<std::sync::Arc<justapi_inference::Engine>> = if let Some(id) =
498
- model.as_ref()
499
- {
500
- use justapi_inference::EngineDevice;
501
- let device = if gpu.eq_ignore_ascii_case("cpu") {
502
- EngineDevice::Cpu
503
- } else {
504
- EngineDevice::Cuda(gpu.parse().unwrap_or(0))
505
- };
506
- let eng = std::sync::Arc::new(
507
- justapi_inference::Engine::new(device)
508
- .map_err(|e| anyhow::anyhow!("failed to start inference engine: {e}"))?,
509
- );
510
- eng.register_mock(id);
511
-
512
- // Wire speculative decoding if requested.
513
- if gamma > 0 || branch > 0 {
514
- let target = eng.get(id).unwrap();
515
- let draft_id = format!("{id}-draft");
516
- let draft = eng.register_mock(&draft_id);
517
- if branch > 0 {
518
- let g = gamma.max(1);
519
- eng.register_tree_speculative(id, target, draft, g, branch, 0);
520
- tracing::info!(
521
- model = %id,
522
- device = %gpu,
523
- gamma = g,
524
- branch = branch,
525
- "Serving model via OpenAI-compatible endpoints (tree speculative decoding)"
526
- );
527
- } else {
528
- eng.register_speculative(id, target, draft, gamma, 0);
529
- tracing::info!(
530
- model = %id,
531
- device = %gpu,
532
- gamma = gamma,
533
- "Serving model via OpenAI-compatible endpoints (draft-target speculative decoding)"
534
- );
535
- }
536
- } else {
537
- tracing::info!(model = %id, device = %gpu, "Serving model via OpenAI-compatible endpoints");
538
- }
539
- Some(eng)
540
- } else {
541
- None
542
- };
543
-
544
- // Build the scheduler engine if `--scheduled` is also set.
545
- #[cfg(feature = "inference")]
546
- let scheduler_engine: Option<
547
- std::sync::Arc<justapi_inference::SchedulerEngine>,
548
- > = if scheduled && engine.is_some() {
549
- use justapi_inference::{KvBlockPool, Scheduler, SchedulerConfig, SchedulerEngine};
550
- let eng = engine.as_ref().unwrap().clone();
551
- let pool = KvBlockPool::new(pool_blocks.max(64));
552
- let config =
553
- SchedulerConfig { max_num_seqs: max_seqs.max(1), ..Default::default() };
554
- let scheduler =
555
- std::sync::Arc::new(std::sync::Mutex::new(Scheduler::new(config, pool)));
556
- let se = std::sync::Arc::new(SchedulerEngine::new(eng, scheduler));
557
- tracing::info!(
558
- pool_blocks = pool_blocks,
559
- max_seqs = max_seqs,
560
- "Scheduler-enabled serving path"
561
- );
562
- Some(se)
563
- } else {
564
- None
565
- };
566
-
567
721
loop {
568
722
let token = tokio_util::sync::CancellationToken::new();
569
723
603
757
return_tx.send(inner_rx).ok();
604
758
});
605
759
606
- // We'll collect the receiver back after `server.run()` completes.
607
- // Stash the return channel for later.
608
- // (We use a scope trick: store it in the reload_rx option.)
609
- // Actually we need the return_rx after the server stops,
610
- // so we hold it outside and await it after run().
611
- // Re-structure: keep return_rx in a variable.
612
- let mut server = justapi_core::Server::new(addr).with_shutdown(token.clone());
613
-
614
- if let Some(dir) = static_dir.clone() {
615
- server = server.with_static_dir(dir);
616
- }
617
-
618
- #[cfg(feature = "compression")]
619
- if compress {
620
- server = server.add_compression();
621
- }
622
-
623
- #[cfg(feature = "inference")]
624
- if let Some(ref se) = scheduler_engine {
625
- server = server.with_openai_scheduled(se.clone());
626
- } else if let Some(ref eng) = engine {
627
- server = server.with_openai(eng.clone());
628
- }
629
-
630
- #[cfg(feature = "tls")]
631
- if let (Some(cert), Some(key)) = (tls_cert.clone(), tls_key.clone()) {
632
- let config =
633
- justapi_core::server::TlsConfig { cert_path: cert, key_path: key };
634
- let result = server.with_tls(config).run().await;
635
- justapi_core::tracing_setup::shutdown_tracing();
636
- return result;
637
- }
760
+ let server = build_server(
761
+ addr,
762
+ &static_dir,
763
+ #[cfg(feature = "compression")]
764
+ compress,
765
+ #[cfg(feature = "tls")]
766
+ &tls_cert,
767
+ #[cfg(feature = "tls")]
768
+ &tls_key,
769
+ #[cfg(feature = "inference")]
770
+ &engine,
771
+ #[cfg(feature = "inference")]
772
+ &scheduler_engine,
773
+ token.clone(),
774
+ );
638
775
639
- let result = server.run().await;
776
+ let std_listener = workers::bind_listener(addr)?;
777
+ let listener = tokio::net::TcpListener::from_std(std_listener)?;
778
+ let result = server.run_on(listener).await;
640
779
641
780
// Recover the watcher receiver for the next iteration.
642
781
if let Ok(recovered) = return_rx.await {
660
799
}
661
800
662
801
// Non-reload path: run server once and exit.
663
- let mut server = justapi_core::Server::new(addr).with_shutdown(token);
664
-
665
- if let Some(dir) = static_dir.clone() {
666
- server = server.with_static_dir(dir);
667
- }
668
-
669
- #[cfg(feature = "compression")]
670
- if compress {
671
- server = server.add_compression();
672
- }
673
-
674
- #[cfg(feature = "inference")]
675
- if let Some(ref se) = scheduler_engine {
676
- server = server.with_openai_scheduled(se.clone());
677
- } else if let Some(ref eng) = engine {
678
- server = server.with_openai(eng.clone());
679
- }
680
-
681
- #[cfg(feature = "tls")]
682
- if let (Some(cert), Some(key)) = (tls_cert.clone(), tls_key.clone()) {
683
- let config = justapi_core::server::TlsConfig { cert_path: cert, key_path: key };
684
- let result = server.with_tls(config).run().await;
685
- justapi_core::tracing_setup::shutdown_tracing();
686
- return result;
687
- }
802
+ let server = build_server(
803
+ addr,
804
+ &static_dir,
805
+ #[cfg(feature = "compression")]
806
+ compress,
807
+ #[cfg(feature = "tls")]
808
+ &tls_cert,
809
+ #[cfg(feature = "tls")]
810
+ &tls_key,
811
+ #[cfg(feature = "inference")]
812
+ &engine,
813
+ #[cfg(feature = "inference")]
814
+ &scheduler_engine,
815
+ tokio_util::sync::CancellationToken::new(),
816
+ );
688
817
689
818
let result = server.run().await;
690
819
justapi_core::tracing_setup::shutdown_tracing();
691
820
▼crates/justapi-cli/src/workers.rs
+254-0
1
+//! Multi-worker prefork supervisor for `justapi serve --workers N`.
2
+//!
3
+//! The parent binds the listening socket exactly once, then spawns `N` child
4
+//! worker processes. Each child is a re-exec of the same binary in a hidden
5
+//! `--worker-fd <fd>` mode that inherits the bound socket (fd inheritance, no
6
+//! `SO_REUSEPORT` port-race), reconstructs the server from the original CLI
7
+//! args, and serves on that socket. This gives true OS-process isolation and
8
+//! saturates all cores — the single-process path only uses one accept loop.
9
+//!
10
+//! The supervisor owns process management:
11
+//! - **Restart-on-death:** a worker that exits while not shutting down is
12
+//! respawned so the fleet stays at `N` workers.
13
+//! - **Graceful drain:** SIGTERM/SIGINT cancels a `CancellationToken`; the
14
+//! supervisor forwards SIGTERM to every child and waits up to `drain_timeout`
15
+//! for in-flight requests to finish before forcibly reaping.
16
+//! - **Hot reload is incompatible with prefork** (a code change must restart the
17
+//! whole tree); the caller rejects that combination before entering here.
18
+
19
+use std::net::SocketAddr;
20
+use std::process::Stdio;
21
+use std::time::Duration;
22
+
23
+use anyhow::Context;
24
+use tokio::signal::unix::{signal, SignalKind};
25
+use tokio_util::sync::CancellationToken;
26
+
27
+/// Raw CLI args (excluding `justapi serve` itself) needed to reconstruct an
28
+/// identical worker. We re-pass them verbatim, plus `--worker-fd`.
29
+pub struct WorkerSpawn {
30
+ /// The exact argv (without program name) the parent was invoked with,
31
+ /// e.g. `["serve", "--addr", "0.0.0.0:8080", "--compress"]`.
32
+ pub argv: Vec<String>,
33
+ /// Bound listening socket fd inherited by the child.
34
+ pub listener_fd: i32,
35
+ /// Max seconds to drain in-flight requests before forcibly reaping.
36
+ pub drain_timeout: Duration,
37
+}
38
+
39
+/// Bind the address once and return the std listener (non-CLOEXEC fd so it
40
+/// survives the child re-exec on Unix).
41
+pub fn bind_listener(addr: SocketAddr) -> anyhow::Result<std::net::TcpListener> {
42
+ let listener =
43
+ std::net::TcpListener::bind(addr).with_context(|| format!("failed to bind {addr}"))?;
44
+ listener.set_nonblocking(true).context("failed to set listener non-blocking")?;
45
+
46
+ // Modern std sets FD_CLOEXEC on the socket fd, which would close it across
47
+ // the child's exec. Clear it so the worker inherits the bound socket.
48
+ #[cfg(unix)]
49
+ {
50
+ use std::os::fd::AsRawFd;
51
+ let fd = listener.as_raw_fd();
52
+ // SAFETY: fd is a valid, owned listener socket; we only clear CLOEXEC.
53
+ let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
54
+ if flags >= 0 {
55
+ unsafe {
56
+ libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
57
+ }
58
+ }
59
+ }
60
+
61
+ Ok(listener)
62
+}
63
+
64
+/// Recover a tokio `TcpListener` from an inherited raw fd (Unix only).
65
+#[cfg(unix)]
66
+pub fn listener_from_fd(fd: i32) -> anyhow::Result<tokio::net::TcpListener> {
67
+ use std::os::fd::FromRawFd;
68
+ // SAFETY: the fd was created by the parent via `std::net::TcpListener::bind`
69
+ // and is not closed on exec, so it is valid and uniquely owned by this
70
+ // process after fork/exec. We do not `from_raw_fd` it anywhere else.
71
+ let std_listener = unsafe { <std::net::TcpListener as FromRawFd>::from_raw_fd(fd) };
72
+ std_listener
73
+ .set_nonblocking(true)
74
+ .context("worker: failed to set inherited listener non-blocking")?;
75
+ tokio::net::TcpListener::from_std(std_listener)
76
+ .context("worker: failed to wrap inherited fd as tokio TcpListener")
77
+}
78
+
79
+/// Run the supervisor: spawn `count` workers and manage their lifecycle until
80
+/// the shutdown token is cancelled (or a fatal spawn error occurs).
81
+pub async fn supervise(
82
+ count: usize,
83
+ spawn: WorkerSpawn,
84
+ shutdown: CancellationToken,
85
+) -> anyhow::Result<()> {
86
+ anyhow::ensure!(count >= 1, "worker count must be >= 1");
87
+ let exe = std::env::current_exe().context("failed to resolve current executable")?;
88
+
89
+ // Persistent JoinSet of in-flight worker waits. `pids[slot]` mirrors it so we
90
+ // can forward SIGTERM without borrowing the (moved) `Child`.
91
+ let mut waits: tokio::task::JoinSet<(usize, std::io::Result<std::process::ExitStatus>)> =
92
+ tokio::task::JoinSet::new();
93
+ let mut pids: Vec<Option<u32>> = Vec::with_capacity(count);
94
+ for slot in 0..count {
95
+ let (pid, mut handle) = spawn_worker(&exe, &spawn)?;
96
+ waits.spawn(async move { (slot, handle.wait().await) });
97
+ pids.push(Some(pid));
98
+ }
99
+ tracing::info!(workers = count, fd = spawn.listener_fd, "prefork supervisor started");
100
+
101
+ let mut sigterm =
102
+ signal(SignalKind::terminate()).context("failed to install SIGTERM handler")?;
103
+ let mut sigint = signal(SignalKind::interrupt()).context("failed to install SIGINT handler")?;
104
+
105
+ let mut drain_deadline: Option<tokio::time::Instant> = None;
106
+
107
+ loop {
108
+ tokio::select! {
109
+ exited = waits.join_next() => {
110
+ match exited {
111
+ Some(Ok((slot, status_res))) => {
112
+ pids[slot] = None;
113
+ match status_res {
114
+ Ok(status) => {
115
+ if shutdown.is_cancelled() {
116
+ tracing::info!(worker = slot, %status, "worker exited during shutdown");
117
+ } else {
118
+ tracing::warn!(worker = slot, %status, "worker died; restarting");
119
+ let (pid, mut handle) = spawn_worker(&exe, &spawn)?;
120
+ waits.spawn(async move { (slot, handle.wait().await) });
121
+ pids[slot] = Some(pid);
122
+ }
123
+ }
124
+ Err(e) => {
125
+ tracing::error!(worker = slot, error = %e, "worker wait error");
126
+ }
127
+ }
128
+ }
129
+ Some(Err(e)) => {
130
+ tracing::error!(error = %e, "worker join error");
131
+ }
132
+ None => {
133
+ // No live children to watch.
134
+ if shutdown.is_cancelled() {
135
+ break;
136
+ }
137
+ }
138
+ }
139
+ }
140
+ _ = shutdown.cancelled() => {
141
+ if drain_deadline.is_none() {
142
+ drain_deadline = Some(tokio::time::Instant::now() + spawn.drain_timeout);
143
+ tracing::info!(drain_secs = spawn.drain_timeout.as_secs(), "shutdown signal; draining workers");
144
+ signal_children(&pids);
145
+ }
146
+ }
147
+ _ = sigterm.recv() => {
148
+ if !shutdown.is_cancelled() { shutdown.cancel(); }
149
+ }
150
+ _ = sigint.recv() => {
151
+ if !shutdown.is_cancelled() { shutdown.cancel(); }
152
+ }
153
+ }
154
+
155
+ if let Some(deadline) = drain_deadline {
156
+ let all_exited = pids.iter().all(|p| p.is_none());
157
+ if all_exited {
158
+ tracing::info!("all workers exited cleanly");
159
+ break;
160
+ }
161
+ if tokio::time::Instant::now() >= deadline {
162
+ tracing::warn!("drain timeout exceeded; forcibly reaping workers");
163
+ signal_kill(&pids);
164
+ // Drain remaining wait results so JoinSet empties.
165
+ while waits.join_next().await.is_some() {}
166
+ break;
167
+ }
168
+ }
169
+ }
170
+
171
+ Ok(())
172
+}
173
+
174
+/// Spawn a single worker child that inherits the listening socket fd.
175
+/// Returns `(pid, wait_handle)` so the supervisor can signal without holding the
176
+/// `Child` (which is consumed by the JoinSet await).
177
+fn spawn_worker(
178
+ exe: &std::path::Path,
179
+ spawn: &WorkerSpawn,
180
+) -> anyhow::Result<(u32, tokio::process::Child)> {
181
+ let mut args: Vec<String> = spawn.argv.clone();
182
+ args.push("--worker-fd".to_string());
183
+ args.push(spawn.listener_fd.to_string());
184
+
185
+ let mut cmd = tokio::process::Command::new(exe);
186
+ cmd.args(&args).stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
187
+
188
+ let child = cmd.spawn().context("failed to spawn worker process")?;
189
+ let pid = child.id().ok_or_else(|| anyhow::anyhow!("spawned worker had no pid"))?;
190
+ Ok((pid, child))
191
+}
192
+
193
+/// Forward SIGTERM to all live workers so they begin graceful drain.
194
+fn signal_children(pids: &[Option<u32>]) {
195
+ for pid in pids.iter().flatten() {
196
+ // SAFETY: pid is a live child we spawned; SIGTERM is the defined
197
+ // graceful-shutdown signal.
198
+ let r = unsafe { libc::kill(*pid as libc::pid_t, libc::SIGTERM) };
199
+ if r != 0 {
200
+ tracing::warn!("failed to signal worker pid {pid} (errno {r})");
201
+ }
202
+ }
203
+}
204
+
205
+/// Forcefully kill all live workers (drain timeout exceeded).
206
+fn signal_kill(pids: &[Option<u32>]) {
207
+ for pid in pids.iter().flatten() {
208
+ // SAFETY: pid is a live child we spawned; SIGKILL forcibly reaps it.
209
+ let r = unsafe { libc::kill(*pid as libc::pid_t, libc::SIGKILL) };
210
+ if r != 0 {
211
+ tracing::warn!("failed to kill worker pid {pid} (errno {r})");
212
+ }
213
+ }
214
+}
215
+
216
+/// Build the `WorkerSpawn` payload from the parent's argv + bound fd.
217
+pub fn make_spawn_argv(
218
+ base_argv: &[String],
219
+ listener_fd: i32,
220
+ drain_timeout: Duration,
221
+) -> WorkerSpawn {
222
+ WorkerSpawn {
223
+ argv: base_argv.to_vec(),
224
+ listener_fd,
225
+ drain_timeout,
226
+ }
227
+}
228
+
229
+#[cfg(test)]
230
+mod tests {
231
+ use super::*;
232
+
233
+ #[test]
234
+ fn spawn_argv_carries_fd_and_args() {
235
+ let base = vec!["serve".to_string(), "--addr".to_string(), "0:8080".to_string()];
236
+ let spawn = make_spawn_argv(&base, 9, Duration::from_secs(5));
237
+ assert_eq!(spawn.listener_fd, 9);
238
+ assert_eq!(spawn.drain_timeout, Duration::from_secs(5));
239
+ // The child re-exec must receive the original args verbatim.
240
+ assert_eq!(spawn.argv, base);
241
+ }
242
+
243
+ #[test]
244
+ fn bind_listener_binds_and_is_nonblocking() {
245
+ // Use a guaranteed-free ephemeral port.
246
+ let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
247
+ let listener = bind_listener(addr).expect("bind should succeed");
248
+ // Re-binding the same (now-allocated) ephemeral port must fail, proving
249
+ // the listener is actually bound and unique.
250
+ let again = std::net::TcpListener::bind(listener.local_addr().unwrap());
251
+ assert!(again.is_err(), "ephemeral port must be occupied by our listener");
252
+ }
253
+}
254
+
0
255
▼crates/justapi-core/src/server/mod.rs
+1-0
1810
1810
}
1811
1811
}
1812
1812
1813
+ let method = req.method().clone();
1813
1814
let resp = tokio::time::timeout(request_timeout(), chain.run(req)).await;
1814
1815
1815
1816
match resp {
1816
1817