1use std::net::IpAddr;
8use std::time::{Duration, Instant};
9
10use super::exposure::{port_in_use, reachable_beyond_loopback};
11use super::ollama::{self, LoadState};
12use super::probe::{Health, ServerClient, ServerReport};
13use super::runtime::{InferRuntime, Instance, Managed, ManagedPlan};
14use super::spec::InferFlavor;
15use crate::workload_runtime::evidence::RunEvidence;
16use crate::workload_runtime::supervise::{LaunchSpec, ProcLimits, Supervised};
17use crate::workload_runtime::types::{
18 InstanceHandle, InstanceState, InstanceStatus, RuntimeError, WorkloadRuntime,
19};
20
21const OUTPUT_LIMIT: usize = 1024 * 1024;
23const UNLOAD_GRACE: Duration = Duration::from_secs(5);
25const OLLAMA_LOAD_TIMEOUT: Duration = Duration::from_secs(900);
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Reconcile {
31 Healthy,
33 NotManaged,
35 Backoff {
37 retry_in: Duration,
39 },
40 Restarted {
42 attempt: u32,
44 },
45 GaveUp {
47 attempts: u32,
49 },
50 StoppedExposed {
53 reachable_on: Vec<IpAddr>,
55 },
56}
57
58fn unknown(h: &InstanceHandle) -> RuntimeError {
59 RuntimeError::UnknownInstance(h.instance_id.clone())
60}
61
62fn unlimited() -> ProcLimits {
63 ProcLimits {
67 mem_bytes: 1 << 46,
68 cpu_secs: None,
69 nofile: 4096,
70 fsize_bytes: 1 << 44,
71 }
72}
73
74fn spawn(rt: &InferRuntime, inst: &mut Instance) -> Result<(), RuntimeError> {
75 let run_as = rt.managed_cfg().and_then(|m| m.run_as);
76 let Some(Managed {
77 plan: ManagedPlan::Process(l),
78 proc,
79 wanted,
80 exited_at,
81 started_at,
82 exposed,
83 ..
84 }) = inst.managed.as_mut()
85 else {
86 return Err(RuntimeError::Unsupported("not a supervised process".into()));
87 };
88 if let Some(a) = exposed {
89 return Err(RuntimeError::InvalidState(format!(
90 "the server was stopped for listening beyond loopback ({a:?}); reload the instance"
91 )));
92 }
93 *proc = Some(Supervised::spawn(LaunchSpec {
94 program: l.program.clone(),
95 args: l.args.clone(),
96 env: l.env.clone(),
97 cwd: l.dir.clone(),
98 limits: unlimited(),
99 output_limit: OUTPUT_LIMIT,
100 run_as,
101 })?);
102 *wanted = true;
103 *exited_at = None;
104 *started_at = Some(Instant::now());
105 Ok(())
106}
107
108fn take_exited(mg: &mut Managed) -> Option<(Supervised, Option<i32>)> {
111 let code = mg.proc.as_mut()?.try_exit()?.code();
112 mg.exited_at.get_or_insert_with(Instant::now);
113 Some((mg.proc.take()?, code))
114}
115
116fn map_report(
117 flavor: InferFlavor,
118 model: Option<&str>,
119 r: &ServerReport,
120 alive: bool,
121 adopted: bool,
122 load: Option<&LoadState>,
123) -> InstanceStatus {
124 let degraded = |d: String| InstanceStatus {
125 state: InstanceState::Degraded,
126 exit_code: None,
127 detail: Some(d),
128 };
129 let running = |d: &str| InstanceStatus {
130 detail: Some(d.into()),
131 ..InstanceStatus::of(InstanceState::Running)
132 };
133 match &r.health {
134 Health::Loading => degraded("loading model".into()),
135 Health::Unhealthy(w) => degraded(w.clone()),
136 Health::Unreachable(w) if alive => degraded(format!("starting: not listening yet ({w})")),
137 Health::Unreachable(w) => InstanceStatus {
138 state: InstanceState::Exited,
139 exit_code: None,
140 detail: Some(if adopted {
141 format!("unreachable ({w}); adopted servers are not restarted")
142 } else {
143 format!("unreachable ({w})")
144 }),
145 },
146 Health::Up => match (flavor, model) {
147 (InferFlavor::Ollama, Some(m)) => {
148 if !r.lists(m) {
149 degraded(format!("model {m} is not pulled"))
150 } else if r.resident(m) {
151 running("model resident")
152 } else if let Some(LoadState::Failed(e)) = load {
153 degraded(format!("load failed: {e}"))
154 } else if matches!(load, Some(LoadState::Loading)) {
155 degraded("loading model".into())
156 } else if adopted {
157 running("server up; model not resident")
158 } else {
159 InstanceStatus::of(InstanceState::Loaded)
160 }
161 }
162 (_, Some(m)) if !r.models.is_empty() && !r.lists(m) => {
163 degraded(format!("model {m} is not listed by the server"))
164 }
165 _ => InstanceStatus::of(InstanceState::Running),
166 },
167 }
168}
169
170impl InferRuntime {
171 fn rt_id(&self) -> String {
172 WorkloadRuntime::id(self).to_string()
173 }
174
175 async fn store_last(&self, h: &InstanceHandle, mut ev: RunEvidence) {
177 ev.runtime = self.rt_id();
178 ev.instance_id = h.instance_id.clone();
179 if let Some(mg) = self
180 .instances
181 .lock()
182 .await
183 .get_mut(&h.instance_id)
184 .and_then(|i| i.managed.as_mut())
185 {
186 mg.last = Some(ev);
187 }
188 }
189
190 pub(super) async fn start_instance(&self, h: &InstanceHandle) -> Result<(), RuntimeError> {
191 let (client, spec, supervised) = {
193 let g = self.instances.lock().await;
194 let inst = g.get(&h.instance_id).ok_or_else(|| unknown(h))?;
195 if let Some(a) = inst.managed.as_ref().and_then(|m| m.exposed.as_ref()) {
196 return Err(RuntimeError::InvalidState(format!(
197 "the server was stopped for listening beyond loopback ({a:?}); reload the instance"
198 )));
199 }
200 let supervised = inst
201 .managed
202 .as_ref()
203 .map(|m| matches!(m.plan, ManagedPlan::Process(_)));
204 (inst.client.clone(), inst.spec.clone(), supervised)
205 };
206 match supervised {
207 None => {
208 return match client.probe(spec.runtime).await.health {
210 Health::Unreachable(w) => Err(RuntimeError::Backend(format!(
211 "adopted server is not answering ({w}); WeftOS does not start adopted servers"
212 ))),
213 _ => Ok(()),
214 };
215 }
216 Some(true) => {
217 let (ip, port) = (spec.bind_ip()?, spec.port()?);
219 if port_in_use(ip, port).await {
220 return Err(RuntimeError::Backend(format!(
221 "{ip}:{port} is already taken by something else; not starting over it"
222 )));
223 }
224 }
225 Some(false) => {}
226 }
227 let mut reserved_new = false;
230 if supervised.is_some()
231 && let Some(ledger) = self.managed_cfg().and_then(|m| m.ledger.clone())
232 {
233 match ledger.reserve(&h.instance_id, &spec) {
234 Ok(newly) => reserved_new = newly,
235 Err(e) => return Err(RuntimeError::AdmissionRefused(e.to_string())),
236 }
237 }
238 let result: Result<(), RuntimeError> = async {
239 let mut g = self.instances.lock().await;
241 let inst = g.get_mut(&h.instance_id).ok_or_else(|| unknown(h))?;
242 let Some(mg) = inst.managed.as_mut() else {
243 return Err(unknown(h));
244 };
245 match &mg.plan {
246 ManagedPlan::Process(_) => {
247 if mg.proc.as_mut().is_some_and(|p| p.try_exit().is_none()) {
248 return Err(RuntimeError::InvalidState("already running".into()));
249 }
250 mg.proc = None;
251 spawn(self, inst)
252 }
253 ManagedPlan::Ollama { tag } => {
254 if matches!(&mg.load, Some((s, _))
255 if matches!(*s.lock().unwrap_or_else(|e| e.into_inner()), LoadState::Loading))
256 {
257 return Err(RuntimeError::InvalidState("already loading".into()));
258 }
259 let keep = self
260 .managed_cfg()
261 .map_or("5m".to_string(), |m| m.keep_alive.clone());
262 let client =
263 ServerClient::new(inst.client.base().to_string(), OLLAMA_LOAD_TIMEOUT)?;
264 let state = std::sync::Arc::new(std::sync::Mutex::new(LoadState::Loading));
265 let (s2, tag) = (state.clone(), tag.clone());
266 let we_loaded = mg.we_loaded.clone();
267 let ledger = self.managed_cfg().and_then(|m| m.ledger.clone());
268 let instance = h.instance_id.clone();
269 let task = tokio::spawn(async move {
270 let r = if ollama::resident(&client, &tag).await {
273 we_loaded.store(false, std::sync::atomic::Ordering::SeqCst);
274 Ok(())
275 } else {
276 we_loaded.store(true, std::sync::atomic::Ordering::SeqCst);
279 let r = ollama::load(&client, &tag, &keep).await;
280 if r.is_err() {
281 we_loaded.store(false, std::sync::atomic::Ordering::SeqCst);
282 }
283 r
284 };
285 if r.is_err()
286 && let Some(l) = ledger
287 {
288 l.release(&instance);
290 }
291 *s2.lock().unwrap_or_else(|e| e.into_inner()) =
292 r.map_or_else(LoadState::Failed, |()| LoadState::Done);
293 });
294 mg.load = Some((state, task));
295 mg.wanted = true;
296 Ok(())
297 }
298 }
299 }
300 .await;
301 if result.is_err()
302 && reserved_new
303 && let Some(ledger) = self.managed_cfg().and_then(|m| m.ledger.clone())
304 {
305 ledger.release(&h.instance_id);
306 }
307 result
308 }
309
310 pub async fn reset_restarts(&self, h: &InstanceHandle) {
314 if let Some(mg) = self
315 .instances
316 .lock()
317 .await
318 .get_mut(&h.instance_id)
319 .and_then(|i| i.managed.as_mut())
320 {
321 mg.restarts = 0;
322 }
323 }
324
325 fn release_residency(&self, h: &InstanceHandle) {
327 if let Some(l) = self.managed_cfg().and_then(|m| m.ledger.as_ref()) {
328 l.release(&h.instance_id);
329 }
330 }
331
332 pub(super) async fn stop_instance(
337 &self,
338 h: &InstanceHandle,
339 grace: Duration,
340 ) -> Result<RunEvidence, RuntimeError> {
341 enum Todo {
342 Proc(Supervised),
343 Ollama(ServerClient, String, bool),
344 }
345 let todo = {
346 let mut g = self.instances.lock().await;
347 let inst = g.get_mut(&h.instance_id).ok_or_else(|| unknown(h))?;
348 let client = inst.client.clone();
349 let Some(mg) = inst.managed.as_mut() else {
350 return Err(RuntimeError::Unsupported(format!(
351 "{} is adopted: WeftOS observes it and never stops it; unload forgets the registration",
352 h.instance_id
353 )));
354 };
355 mg.wanted = false;
356 match &mg.plan {
357 ManagedPlan::Process(_) => Todo::Proc(
358 mg.proc
359 .take()
360 .ok_or_else(|| RuntimeError::InvalidState("not running".into()))?,
361 ),
362 ManagedPlan::Ollama { tag } => {
363 if let Some((_, t)) = mg.load.take() {
364 t.abort();
365 }
366 Todo::Ollama(
367 client,
368 tag.clone(),
369 mg.we_loaded.swap(false, std::sync::atomic::Ordering::SeqCst),
370 )
371 }
372 }
373 };
374 match todo {
376 Todo::Proc(p) => {
377 let ev = p.terminate(grace).await;
378 self.release_residency(h);
379 if let Some(mg) = self
380 .instances
381 .lock()
382 .await
383 .get_mut(&h.instance_id)
384 .and_then(|i| i.managed.as_mut())
385 {
386 mg.exited_at = Some(Instant::now());
387 }
388 self.store_last(h, ev.clone()).await;
389 let mut ev = ev;
390 ev.runtime = self.rt_id();
391 ev.instance_id = h.instance_id.clone();
392 Ok(ev)
393 }
394 Todo::Ollama(client, tag, ours) => {
395 if ours {
397 ollama::unload(&client, &tag)
398 .await
399 .map_err(RuntimeError::Backend)?;
400 }
401 self.release_residency(h);
402 Ok(RunEvidence {
403 runtime: self.rt_id(),
404 instance_id: h.instance_id.clone(),
405 ..RunEvidence::default()
406 })
407 }
408 }
409 }
410
411 pub(super) async fn unload_instance(&self, h: &InstanceHandle) -> Result<(), RuntimeError> {
414 let mut inst = self
415 .instances
416 .lock()
417 .await
418 .remove(&h.instance_id)
419 .ok_or_else(|| unknown(h))?;
420 if let Some(mg) = inst.managed.as_mut() {
421 if let Some(p) = mg.proc.take() {
423 p.terminate(UNLOAD_GRACE).await;
424 }
425 if let Some((_, t)) = mg.load.take() {
426 t.abort();
427 }
428 match &mg.plan {
429 ManagedPlan::Process(l) => {
430 let _ = std::fs::remove_dir_all(&l.dir);
431 }
432 ManagedPlan::Ollama { tag } => {
433 if mg.we_loaded.swap(false, std::sync::atomic::Ordering::SeqCst) {
435 let _ = ollama::unload(&inst.client, tag).await;
436 }
437 }
438 }
439 }
440 self.release_residency(h);
444 Ok(())
445 }
446
447 async fn enforce_loopback(&self, h: &InstanceHandle) -> Option<Vec<IpAddr>> {
450 let port = {
451 let g = self.instances.lock().await;
452 let inst = g.get(&h.instance_id)?;
453 let mg = inst.managed.as_ref()?;
454 (matches!(mg.plan, ManagedPlan::Process(_)) && mg.proc.is_some())
455 .then(|| inst.spec.port().ok())
456 .flatten()?
457 };
458 let beyond = reachable_beyond_loopback(port).await;
459 if beyond.is_empty() {
460 return None;
461 }
462 let p = {
463 let mut g = self.instances.lock().await;
464 let mg = g.get_mut(&h.instance_id)?.managed.as_mut()?;
465 mg.wanted = false;
466 mg.exposed = Some(beyond.clone());
467 mg.exited_at = Some(Instant::now());
468 mg.proc.take()
469 };
470 if let Some(p) = p {
471 let ev = p.terminate(Duration::from_secs(3)).await;
472 self.store_last(h, ev).await;
473 }
474 self.release_residency(h);
476 tracing::warn!(instance = %h.instance_id, ?beyond, "model server listened beyond loopback; stopped");
477 Some(beyond)
478 }
479
480 pub(super) async fn status_of(&self, h: &InstanceHandle) -> InstanceStatus {
481 let (client, spec, model, alive, load, adopted) = {
482 let mut g = self.instances.lock().await;
483 let Some(inst) = g.get_mut(&h.instance_id) else {
484 return InstanceStatus::of(InstanceState::Unknown);
485 };
486 let (client, spec) = (inst.client.clone(), inst.spec.clone());
487 match inst.managed.as_mut() {
488 None => {
489 let m = spec.model.clone();
490 (client, spec, m, false, None, true)
491 }
492 Some(mg) => {
493 if let Some(a) = &mg.exposed {
494 return InstanceStatus {
495 state: InstanceState::Exited,
496 exit_code: None,
497 detail: Some(format!(
498 "stopped: the server listened beyond loopback (reachable on {a:?})"
499 )),
500 };
501 }
502 if let Some((p, code)) = take_exited(mg) {
503 drop(g);
504 let ev = p.terminate(Duration::ZERO).await;
505 self.store_last(h, ev).await;
506 return InstanceStatus {
507 state: InstanceState::Exited,
508 exit_code: code,
509 detail: Some("server process exited".into()),
510 };
511 }
512 let alive = mg.proc.is_some();
513 if matches!(mg.plan, ManagedPlan::Process(_)) && !alive {
514 let state = if mg.last.is_some() {
515 InstanceState::Exited
516 } else {
517 InstanceState::Loaded
518 };
519 return InstanceStatus {
520 state,
521 exit_code: mg.last.as_ref().and_then(|e| e.exit_code),
522 detail: None,
523 };
524 }
525 let load = mg
526 .load
527 .as_ref()
528 .map(|(s, _)| s.lock().unwrap_or_else(|e| e.into_inner()).clone());
529 let model = match &mg.plan {
530 ManagedPlan::Ollama { tag } => tag.clone(),
531 ManagedPlan::Process(_) => mg.model_name.clone(),
532 };
533 (client, spec, Some(model), alive, load, false)
534 }
535 }
536 };
537 if alive && let Some(a) = self.enforce_loopback(h).await {
540 return InstanceStatus {
541 state: InstanceState::Exited,
542 exit_code: None,
543 detail: Some(format!(
544 "stopped: the server listened beyond loopback (reachable on {a:?})"
545 )),
546 };
547 }
548 let r = client.probe(spec.runtime).await;
549 map_report(
550 spec.runtime,
551 model.as_deref(),
552 &r,
553 alive,
554 adopted,
555 load.as_ref(),
556 )
557 }
558
559 pub async fn restart(&self, h: &InstanceHandle, grace: Duration) -> Result<(), RuntimeError> {
561 match self.stop_instance(h, grace).await {
562 Ok(_) | Err(RuntimeError::InvalidState(_)) => {}
563 Err(e) => return Err(e),
564 }
565 self.start_instance(h).await
566 }
567
568 pub async fn reconcile(&self, h: &InstanceHandle) -> Result<Reconcile, RuntimeError> {
573 let Some(policy) = self.managed_cfg().map(|m| m.restart) else {
574 return Ok(Reconcile::NotManaged);
575 };
576 enum Next {
577 Done(Reconcile),
578 Restart {
579 ip: IpAddr,
580 port: u16,
581 },
582 Check {
583 client: ServerClient,
584 flavor: InferFlavor,
585 },
586 }
587 let reaped;
588 let next = {
589 let mut g = self.instances.lock().await;
590 let inst = g.get_mut(&h.instance_id).ok_or_else(|| unknown(h))?;
591 let (client, flavor) = (inst.client.clone(), inst.spec.runtime);
592 let (ip, port) = (inst.spec.bind_ip()?, inst.spec.port()?);
593 let Some(mg) = inst.managed.as_mut() else {
594 return Ok(Reconcile::NotManaged);
595 };
596 if !matches!(mg.plan, ManagedPlan::Process(_)) {
597 return Ok(Reconcile::NotManaged);
598 }
599 if let Some(a) = &mg.exposed {
600 let reachable_on = a.clone();
601 drop(g);
602 self.release_residency(h);
603 return Ok(Reconcile::StoppedExposed { reachable_on });
604 }
605 if !mg.wanted {
606 return Ok(Reconcile::Healthy);
607 }
608 reaped = take_exited(mg).map(|(p, _)| p);
609 if mg.proc.is_some() {
610 Next::Check { client, flavor }
611 } else if mg.restarts >= policy.max_restarts {
612 Next::Done(Reconcile::GaveUp {
613 attempts: mg.restarts,
614 })
615 } else {
616 let wait = policy.delay(mg.restarts);
617 let since = mg.exited_at.map_or(wait, |t| t.elapsed());
618 if since < wait {
619 Next::Done(Reconcile::Backoff {
620 retry_in: wait - since,
621 })
622 } else {
623 Next::Restart { ip, port }
624 }
625 }
626 };
627 if let Some(p) = reaped {
628 let ev = p.terminate(Duration::ZERO).await;
629 self.store_last(h, ev).await;
630 }
631 match next {
632 Next::Done(r) => {
633 if matches!(r, Reconcile::GaveUp { .. }) {
635 self.release_residency(h);
636 }
637 Ok(r)
638 }
639 Next::Restart { ip, port } => {
640 if port_in_use(ip, port).await {
641 return Err(RuntimeError::Backend(format!(
642 "{ip}:{port} is already taken by something else; not restarting over it"
643 )));
644 }
645 let mut g = self.instances.lock().await;
646 let inst = g.get_mut(&h.instance_id).ok_or_else(|| unknown(h))?;
647 let attempt = {
648 let mg = inst.managed.as_mut().ok_or_else(|| unknown(h))?;
649 mg.restarts += 1;
650 mg.restarts
651 };
652 spawn(self, inst)?;
653 Ok(Reconcile::Restarted { attempt })
654 }
655 Next::Check { client, flavor } => {
656 if let Some(a) = self.enforce_loopback(h).await {
657 return Ok(Reconcile::StoppedExposed { reachable_on: a });
658 }
659 let up = client.probe(flavor).await.health;
660 if up == Health::Up
664 && let Some(mg) = self
665 .instances
666 .lock()
667 .await
668 .get_mut(&h.instance_id)
669 .and_then(|i| i.managed.as_mut())
670 && mg.started_at.is_some_and(|t| t.elapsed() >= policy.stable_after)
671 {
672 mg.restarts = 0;
673 }
674 Ok(Reconcile::Healthy)
675 }
676 }
677 }
678}