1use std::sync::{
13 atomic::{AtomicBool, Ordering},
14 Arc,
15};
16use std::time::Duration;
17
18use anyhow::{anyhow, Result};
19use parking_lot::Mutex;
20use tokio::sync::mpsc;
21use tracing::{info, warn, Instrument as _};
22
23use crate::config::SharedConfig;
24use crate::engine::Engine;
25use crate::http::ApiClient;
26use crate::job_run::JobRun;
27use crate::runtime::{
28 is_unsupported_kind, prompt_for, push_log_with_observers, set_session_state, truncate_prompt,
29 wait_with_stop, CurrentJob, JobOutcome, JobSource, SessionState, WorkerObservers,
30};
31use crate::types::{LogEntry, TaskResult};
32use crate::ws::client::{connect, WsClientError, WsResult, WsSender};
33use crate::ws::types::{HelloFrame, JobOfferClaim, WorkerInbound, WorkerOutbound};
34
35const TRACE_TARGET: &str = "studio_worker::ws::session";
37
38const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
39const LOG_FLUSH_INTERVAL: Duration = Duration::from_secs(1);
40const SHUTDOWN_TICK: Duration = Duration::from_millis(250);
41const BASE_BACKOFF_MS: u64 = 1_000;
42const MAX_BACKOFF_MS: u64 = 30_000;
43const DEFAULT_RECONNECT_ATTEMPTS: u32 = 0;
55const UPLOAD_RETRIES: u32 = 2;
59const UPLOAD_RETRY_PAUSE: Duration = Duration::from_secs(1);
61const READ_IDLE_TIMEOUT: Duration = Duration::from_secs(20);
66
67#[derive(Debug)]
70pub enum SessionOutcome {
71 Stopped,
73 Disconnected,
75 AuthFailed(String),
77 Fatal(String),
79}
80
81#[derive(Debug, Clone, Copy)]
83pub struct SessionSchedule {
84 pub heartbeat: Duration,
85 pub log_flush: Duration,
86 pub shutdown_tick: Duration,
87 pub base_backoff_ms: u64,
88 pub max_backoff_ms: u64,
89 pub read_idle_timeout: Duration,
91}
92
93impl Default for SessionSchedule {
94 fn default() -> Self {
95 Self {
96 heartbeat: HEARTBEAT_INTERVAL,
97 log_flush: LOG_FLUSH_INTERVAL,
98 shutdown_tick: SHUTDOWN_TICK,
99 base_backoff_ms: BASE_BACKOFF_MS,
100 max_backoff_ms: MAX_BACKOFF_MS,
101 read_idle_timeout: READ_IDLE_TIMEOUT,
102 }
103 }
104}
105
106impl SessionSchedule {
107 pub fn fast_for_tests() -> Self {
108 Self {
109 heartbeat: Duration::from_millis(5),
110 log_flush: Duration::from_millis(5),
111 shutdown_tick: Duration::from_millis(5),
112 base_backoff_ms: 1,
113 max_backoff_ms: 10,
114 read_idle_timeout: Duration::from_secs(5),
117 }
118 }
119}
120
121#[cfg_attr(coverage_nightly, coverage(off))]
129pub async fn spawn_ws_session(
130 cfg: SharedConfig,
131 stop: Arc<AtomicBool>,
132 logs: Arc<Mutex<Vec<LogEntry>>>,
133 busy: Arc<AtomicBool>,
134 paused: Arc<AtomicBool>,
135 observers: WorkerObservers,
136 schedule: SessionSchedule,
137) -> Result<()> {
138 let max_attempts = {
139 let guard = cfg.lock();
140 guard
141 .ws_reconnect_attempts
142 .unwrap_or(DEFAULT_RECONNECT_ATTEMPTS)
143 };
144
145 let mut attempt: u32 = 0;
146 let mut waiting_for_creds_logged = false;
147 loop {
148 if stop.load(Ordering::SeqCst) {
149 return Ok(());
150 }
151 if !has_credentials(&cfg) {
157 if !waiting_for_creds_logged {
158 set_session_state(&observers, SessionState::WaitingForApproval);
159 push_log_with_observers(
160 &logs,
161 Some(&observers),
162 "info",
163 "ws",
164 "waiting for operator approval before opening the session",
165 None,
166 );
167 waiting_for_creds_logged = true;
168 }
169 wait_with_stop(Duration::from_secs(1), &stop, schedule.shutdown_tick).await;
170 continue;
171 }
172 waiting_for_creds_logged = false;
173
174 set_session_state(&observers, SessionState::Connecting);
175 let welcomed = AtomicBool::new(false);
176 match run_one_session(
177 &cfg, &stop, &logs, &busy, &paused, &observers, schedule, &welcomed,
178 )
179 .await
180 {
181 Ok(SessionOutcome::Stopped) => {
182 set_session_state(&observers, SessionState::Stopped);
183 return Ok(());
184 }
185 Ok(SessionOutcome::AuthFailed(reason)) => {
186 set_session_state(
187 &observers,
188 SessionState::AuthFailed {
189 reason: reason.clone(),
190 },
191 );
192 push_log_with_observers(
193 &logs,
194 Some(&observers),
195 "error",
196 "ws",
197 &format!("auth failed: {reason}. Re-register the worker."),
198 None,
199 );
200 return Err(anyhow!("ws auth failed: {reason}"));
201 }
202 Ok(SessionOutcome::Fatal(reason)) => {
203 set_session_state(
204 &observers,
205 SessionState::Fatal {
206 reason: reason.clone(),
207 },
208 );
209 push_log_with_observers(
210 &logs,
211 Some(&observers),
212 "error",
213 "ws",
214 &format!("fatal: {reason}"),
215 None,
216 );
217 return Err(anyhow!("ws fatal: {reason}"));
218 }
219 outcome @ (Ok(SessionOutcome::Disconnected) | Err(_)) => {
220 if welcomed.load(Ordering::SeqCst) {
224 attempt = 0;
225 }
226 attempt += 1;
227 if reconnect_exhausted(max_attempts, attempt) {
228 set_session_state(
229 &observers,
230 SessionState::Fatal {
231 reason: format!("gave up after {attempt} reconnect attempts"),
232 },
233 );
234 push_log_with_observers(
235 &logs,
236 Some(&observers),
237 "error",
238 "ws",
239 &format!("giving up after {attempt} reconnect attempts"),
240 None,
241 );
242 return Err(anyhow!("ws reconnect cap reached"));
243 }
244 set_session_state(&observers, SessionState::Reconnecting { attempt });
245 let backoff = backoff_for(attempt, schedule);
246 push_log_with_observers(
247 &logs,
248 Some(&observers),
249 "warn",
250 "ws",
251 &reconnect_breadcrumb(outcome.as_ref().err(), attempt, backoff),
252 None,
253 );
254 wait_with_stop(backoff, &stop, schedule.shutdown_tick).await;
255 }
256 }
257 }
258}
259
260enum WelcomeOutcome {
265 Welcomed,
266 AuthFailed(String),
267 Fatal(String),
268 Disconnected,
269}
270
271#[cfg_attr(coverage_nightly, coverage(off))]
277async fn wait_for_welcome(
278 event_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
279 logs: &Arc<Mutex<Vec<LogEntry>>>,
280 observers: &WorkerObservers,
281) -> WelcomeOutcome {
282 while let Some(event) = event_rx.recv().await {
283 match event {
284 SessionEvent::Frame(WorkerOutbound::Welcome {
285 worker_id: wid,
286 server_time,
287 }) => {
288 push_log_with_observers(
289 logs,
290 Some(observers),
291 "info",
292 "ws",
293 &welcome_breadcrumb(&wid, &server_time),
294 None,
295 );
296 return WelcomeOutcome::Welcomed;
297 }
298 SessionEvent::Frame(WorkerOutbound::Error { code, message }) => {
299 push_log_with_observers(
300 logs,
301 Some(observers),
302 "error",
303 "ws",
304 &format!("server error before welcome {code:?}: {message}"),
305 None,
306 );
307 return match code {
308 crate::ws::types::WorkerErrorCode::AuthFailed => {
309 WelcomeOutcome::AuthFailed(message)
310 }
311 _ => WelcomeOutcome::Fatal(message),
312 };
313 }
314 SessionEvent::Frame(other) => {
315 push_log_with_observers(
316 logs,
317 Some(observers),
318 "warn",
319 "ws",
320 &format!("server sent unexpected frame before welcome: {other:?}"),
321 None,
322 );
323 }
325 SessionEvent::Disconnected(WsClientError::AuthFailed { reason }) => {
326 return WelcomeOutcome::AuthFailed(reason);
327 }
328 SessionEvent::Disconnected(_) => return WelcomeOutcome::Disconnected,
329 SessionEvent::Stopped => return WelcomeOutcome::Disconnected,
330 }
331 }
332 WelcomeOutcome::Disconnected
333}
334
335fn has_credentials(cfg: &SharedConfig) -> bool {
339 let guard = cfg.lock();
340 guard
341 .worker_id
342 .as_deref()
343 .map(|s| !s.is_empty())
344 .unwrap_or(false)
345 && guard
346 .auth_token
347 .as_deref()
348 .map(|s| !s.is_empty())
349 .unwrap_or(false)
350}
351
352#[cfg_attr(coverage_nightly, coverage(off))]
355#[allow(clippy::too_many_arguments)]
358async fn run_one_session(
359 cfg: &SharedConfig,
360 stop: &Arc<AtomicBool>,
361 logs: &Arc<Mutex<Vec<LogEntry>>>,
362 busy: &Arc<AtomicBool>,
363 paused: &Arc<AtomicBool>,
364 observers: &WorkerObservers,
365 schedule: SessionSchedule,
366 welcomed: &AtomicBool,
367) -> Result<SessionOutcome> {
368 let (api_base_url, worker_id, auth_token) = {
369 let guard = cfg.lock();
370 (
371 guard.api_base_url.clone(),
372 guard.worker_id.clone().unwrap_or_default(),
373 guard.auth_token.clone().unwrap_or_default(),
374 )
375 };
376 if worker_id.is_empty() || auth_token.is_empty() {
377 return Ok(SessionOutcome::Fatal(
378 "worker_id or auth_token missing; run register".to_string(),
379 ));
380 }
381
382 push_log_with_observers(
383 logs,
384 Some(observers),
385 "info",
386 "ws",
387 &format!("connecting to {api_base_url}"),
388 None,
389 );
390 let client = match connect(&api_base_url, &worker_id, &auth_token).await {
391 Ok(c) => c,
392 Err(WsClientError::AuthFailed { reason }) => {
393 return Ok(SessionOutcome::AuthFailed(reason));
394 }
395 Err(e) => {
396 push_log_with_observers(
397 logs,
398 Some(observers),
399 "warn",
400 "ws",
401 &format!("connect failed: {e}"),
402 None,
403 );
404 return Ok(SessionOutcome::Disconnected);
405 }
406 };
407 let (sender, receiver) = client.split();
408
409 let engine = crate::engine::build(&cfg.lock())?;
411 let capabilities = crate::runtime::build_capabilities_with(
412 &cfg.lock(),
413 &*engine,
414 !paused.load(Ordering::SeqCst),
415 );
416 push_log_with_observers(
421 logs,
422 Some(observers),
423 "info",
424 "ws",
425 &crate::runtime::summarize_capabilities(&capabilities),
426 None,
427 );
428 if let Some(warning) = crate::runtime::vram_threshold_warning(&capabilities) {
433 push_log_with_observers(logs, Some(observers), "warn", "ws", &warning, None);
434 }
435 sender
436 .send(&WorkerInbound::Hello(HelloFrame {
437 auth_token: auth_token.clone(),
438 capabilities: capabilities.clone(),
439 }))
440 .await
441 .map_err(|e| anyhow!("hello send failed: {e}"))?;
442 info!(target: TRACE_TARGET, worker_id = %worker_id, "hello sent");
443
444 let (event_tx, event_rx) = mpsc::unbounded_channel::<SessionEvent>();
445
446 let reader = spawn_reader(receiver, event_tx.clone(), schedule.read_idle_timeout);
448
449 let mut event_rx = event_rx;
458 match wait_for_welcome(&mut event_rx, logs, observers).await {
459 WelcomeOutcome::Welcomed => {
460 welcomed.store(true, Ordering::SeqCst);
461 set_session_state(observers, SessionState::Connected);
462 }
463 WelcomeOutcome::AuthFailed(reason) => {
464 let _ = sender.close(1000, "auth failed").await;
465 let _ = reader.await;
466 return Ok(SessionOutcome::AuthFailed(reason));
467 }
468 WelcomeOutcome::Fatal(reason) => {
469 let _ = sender.close(1000, "protocol violation").await;
470 let _ = reader.await;
471 return Ok(SessionOutcome::Fatal(reason));
472 }
473 WelcomeOutcome::Disconnected => {
474 let _ = reader.await;
475 return Ok(SessionOutcome::Disconnected);
476 }
477 }
478
479 let engine_arc: Arc<dyn Engine> = engine.into();
486 let heartbeat = spawn_heartbeat_pump(
487 cfg.clone(),
488 engine_arc.clone(),
489 sender.clone(),
490 stop.clone(),
491 paused.clone(),
492 logs.clone(),
493 observers.clone(),
494 schedule,
495 );
496
497 let log_shipper = spawn_log_shipper_pump(sender.clone(), logs.clone(), stop.clone(), schedule);
499
500 let shutdown_observer = spawn_shutdown_observer(stop.clone(), event_tx.clone(), schedule);
502 drop(event_tx);
503
504 let ctx = SessionContext {
505 sender: sender.clone(),
506 engine: engine_arc,
507 logs: logs.clone(),
508 busy: busy.clone(),
509 paused: paused.clone(),
510 observers: observers.clone(),
511 api_base_url: api_base_url.clone(),
512 worker_id: worker_id.clone(),
513 auth_token: auth_token.clone(),
514 };
515 let outcome = run_dispatch_loop(ctx, event_rx).await;
516
517 reader.abort();
524 heartbeat.abort();
525 log_shipper.abort();
526 shutdown_observer.abort();
527 let _ = sender.close(1000, "session ended").await;
528 let _ = reader.await;
529 let _ = heartbeat.await;
530 let _ = log_shipper.await;
531 let _ = shutdown_observer.await;
532 Ok(outcome)
533}
534
535#[derive(Debug)]
537enum SessionEvent {
538 Frame(WorkerOutbound),
540 Stopped,
542 Disconnected(WsClientError),
544}
545
546#[derive(Clone)]
551struct SessionContext {
552 sender: WsSender,
553 engine: Arc<dyn Engine>,
554 logs: Arc<Mutex<Vec<LogEntry>>>,
555 busy: Arc<AtomicBool>,
556 paused: Arc<AtomicBool>,
557 observers: WorkerObservers,
558 api_base_url: String,
559 worker_id: String,
560 auth_token: String,
561}
562
563#[cfg_attr(coverage_nightly, coverage(off))]
564async fn run_dispatch_loop(
565 ctx: SessionContext,
566 mut event_rx: mpsc::UnboundedReceiver<SessionEvent>,
567) -> SessionOutcome {
568 while let Some(event) = event_rx.recv().await {
569 match event {
570 SessionEvent::Disconnected(WsClientError::AuthFailed { reason }) => {
571 return SessionOutcome::AuthFailed(reason);
572 }
573 SessionEvent::Disconnected(_) => return SessionOutcome::Disconnected,
574 SessionEvent::Stopped => return SessionOutcome::Stopped,
575 SessionEvent::Frame(frame) => match frame {
576 WorkerOutbound::Welcome {
577 worker_id: wid,
578 server_time,
579 } => {
580 push_log_with_observers(
581 &ctx.logs,
582 Some(&ctx.observers),
583 "info",
584 "ws",
585 &welcome_breadcrumb(&wid, &server_time),
586 None,
587 );
588 }
589 WorkerOutbound::Offer { claim } => {
590 handle_offer(&ctx, *claim);
591 }
592 WorkerOutbound::Error { code, message } => {
593 push_log_with_observers(
594 &ctx.logs,
595 Some(&ctx.observers),
596 "error",
597 "ws",
598 &format!("server error {code:?}: {message}"),
599 None,
600 );
601 return match code {
602 crate::ws::types::WorkerErrorCode::AuthFailed => {
603 SessionOutcome::AuthFailed(message)
604 }
605 _ => SessionOutcome::Fatal(message),
606 };
607 }
608 WorkerOutbound::CompleteAck { job_id } => {
609 push_log_with_observers(
610 &ctx.logs,
611 Some(&ctx.observers),
612 "info",
613 "ws",
614 &result_ack_breadcrumb("completion", &job_id),
615 Some(job_id),
616 );
617 }
618 WorkerOutbound::FailAck { job_id } => {
619 push_log_with_observers(
620 &ctx.logs,
621 Some(&ctx.observers),
622 "info",
623 "ws",
624 &result_ack_breadcrumb("failure", &job_id),
625 Some(job_id),
626 );
627 }
628 WorkerOutbound::HeartbeatAck => {
629 }
634 },
635 }
636 }
637 SessionOutcome::Disconnected
638}
639
640#[cfg_attr(coverage_nightly, coverage(off))]
641fn handle_offer(ctx: &SessionContext, claim: JobOfferClaim) {
642 let job_id = claim.job_id.clone();
643 push_log_with_observers(
644 &ctx.logs,
645 Some(&ctx.observers),
646 "info",
647 "ws",
648 &offer_received_breadcrumb(
649 &job_id,
650 &claim.game_id,
651 &claim.asset_name,
652 &claim.model,
653 claim.vram_gb_estimate,
654 ),
655 Some(job_id.clone()),
656 );
657 if ctx.paused.load(Ordering::SeqCst) {
661 push_log_with_observers(
662 &ctx.logs,
663 Some(&ctx.observers),
664 "info",
665 "ws",
666 &format!("rejecting offer {job_id}: worker is paused"),
667 Some(job_id.clone()),
668 );
669 spawn_reject_offer(
670 ctx.sender.clone(),
671 ctx.logs.clone(),
672 ctx.observers.clone(),
673 job_id,
674 "worker paused by operator",
675 crate::ws::types::RejectCode::Paused,
676 );
677 return;
678 }
679 let reservation = match crate::job_gate::JobGate::from_shared(ctx.busy.clone()).try_reserve() {
680 Some(reservation) => reservation,
681 None => {
682 push_log_with_observers(
683 &ctx.logs,
684 Some(&ctx.observers),
685 "info",
686 "ws",
687 &format!("rejecting offer {job_id}: worker is already busy"),
688 Some(job_id.clone()),
689 );
690 spawn_reject_offer(
691 ctx.sender.clone(),
692 ctx.logs.clone(),
693 ctx.observers.clone(),
694 job_id,
695 "worker already has an in-flight job",
696 crate::ws::types::RejectCode::Busy,
697 );
698 return;
699 }
700 };
701 let job = claim.into_job_claim();
702 let task_kind = job.task.kind();
703 crate::runtime::sync_studio_model(&ctx.observers, &job.model, task_kind, &job.model_source);
708 let full_prompt = prompt_for(&job.task);
716 let prompt_preview = truncate_prompt(&full_prompt);
717 let started_at = chrono::Utc::now();
718
719 let ctx = ctx.clone();
720 tokio::spawn(async move {
721 let _reservation = reservation;
724 let accept_result = ctx
725 .sender
726 .send(&WorkerInbound::Accept {
727 job_id: job_id.clone(),
728 })
729 .await;
730 if let Some((level, message)) = offer_response_breadcrumb("accept", &job_id, &accept_result)
731 {
732 push_log_with_observers(
733 &ctx.logs,
734 Some(&ctx.observers),
735 level,
736 "ws",
737 &message,
738 Some(job_id.clone()),
739 );
740 }
741 if accept_result.is_err() {
742 return;
743 }
744
745 let current = CurrentJob {
749 job_id: job_id.clone(),
750 kind: task_kind,
751 model: job.model.clone(),
752 prompt: prompt_preview,
753 started_at,
754 source: JobSource::Studio,
755 };
756 *ctx.observers.current_job.lock() = Some(current.clone());
757 let run = JobRun::begin(&ctx.observers, current);
758 let span = run.span().clone();
759 run_offered_job(&ctx, job, run, task_kind, full_prompt)
760 .instrument(span)
761 .await;
762 });
763}
764
765fn spawn_reject_offer(
766 sender: WsSender,
767 logs: Arc<Mutex<Vec<LogEntry>>>,
768 observers: WorkerObservers,
769 job_id: String,
770 reason: &'static str,
771 code: crate::ws::types::RejectCode,
772) {
773 tokio::spawn(async move {
774 let result = sender
775 .send(&WorkerInbound::Reject {
776 job_id: job_id.clone(),
777 reason: reason.to_string(),
778 code: Some(code),
779 })
780 .await;
781 if let Some((level, message)) = offer_response_breadcrumb("reject", &job_id, &result) {
782 push_log_with_observers(&logs, Some(&observers), level, "ws", &message, Some(job_id));
783 }
784 });
785}
786
787#[cfg_attr(coverage_nightly, coverage(off))]
788async fn run_offered_job(
789 ctx: &SessionContext,
790 job: crate::types::JobClaim,
791 run: JobRun,
792 task_kind: crate::types::TaskKind,
793 full_prompt: String,
794) {
795 let start = std::time::Instant::now();
796 let dispatch = tokio::task::spawn_blocking({
801 let model = job.model.clone();
802 let model_source = job.model_source.clone();
803 let task_for_engine = job.task.clone();
804 let engine = ctx.engine.clone();
805 let span = run.span().clone();
806 let thumbnail = run.thumbnail_keeper();
807 move || -> Result<TaskResult> {
808 let result = span
809 .in_scope(|| engine.dispatch_with_source(&model, task_for_engine, &model_source));
810 if let Ok(result) = &result {
811 thumbnail.keep(result);
812 }
813 result
814 }
815 })
816 .await;
817
818 let job_id = job.job_id.clone();
819 let outcome = match dispatch {
823 Ok(Ok(result)) => {
824 push_log_with_observers(
825 &ctx.logs,
826 Some(&ctx.observers),
827 "info",
828 "ws",
829 &format!("{} dispatched in {:?}", task_kind.as_str(), start.elapsed()),
830 Some(job_id.clone()),
831 );
832 deliver_result(ctx, &job_id, result, &full_prompt).await
833 }
834 Ok(Err(e)) => {
835 warn!(target: TRACE_TARGET, error = %e, "engine dispatch failed");
836 push_log_with_observers(
837 &ctx.logs,
838 Some(&ctx.observers),
839 "error",
840 "ws",
841 &format!("dispatch failed: {e}"),
842 Some(job_id.clone()),
843 );
844 let fail_result = ctx
845 .sender
846 .send(&WorkerInbound::Fail {
847 job_id: job_id.clone(),
848 error: e.to_string(),
849 retryable: !is_unsupported_kind(&e),
850 })
851 .await;
852 record_fail_send(&fail_result, &job_id, &ctx.logs, &ctx.observers);
853 JobOutcome::Failed {
854 reason: e.to_string(),
855 }
856 }
857 Err(e) => {
858 push_log_with_observers(
859 &ctx.logs,
860 Some(&ctx.observers),
861 "error",
862 "ws",
863 &format!("dispatch task panic: {e}"),
864 Some(job_id.clone()),
865 );
866 let fail_result = ctx
867 .sender
868 .send(&WorkerInbound::Fail {
869 job_id: job_id.clone(),
870 error: e.to_string(),
871 retryable: true,
872 })
873 .await;
874 record_fail_send(&fail_result, &job_id, &ctx.logs, &ctx.observers);
875 JobOutcome::Failed {
876 reason: e.to_string(),
877 }
878 }
879 };
880
881 *ctx.observers.current_job.lock() = None;
884 run.finish(outcome);
885}
886
887#[cfg_attr(coverage_nightly, coverage(off))]
892async fn deliver_result(
893 ctx: &SessionContext,
894 job_id: &str,
895 result: TaskResult,
896 full_prompt: &str,
897) -> JobOutcome {
898 match result {
899 TaskResult::Image { bytes, ext }
900 | TaskResult::AudioTts { bytes, ext }
901 | TaskResult::Video { bytes, ext } => {
902 let upload_result = tokio::task::spawn_blocking({
903 let api_base_url = ctx.api_base_url.clone();
904 let job_id = job_id.to_string();
905 let auth_token = ctx.auth_token.clone();
906 let worker_id = ctx.worker_id.clone();
907 let prompt = full_prompt.to_string();
908 move || -> Result<()> {
909 let api = ApiClient::new(api_base_url)?;
910 api.complete_with_retry(
911 &worker_id,
912 &auth_token,
913 &job_id,
914 &ext,
915 &prompt,
916 bytes,
917 UPLOAD_RETRIES,
918 UPLOAD_RETRY_PAUSE,
919 )
920 }
921 })
922 .await;
923 let msg = match upload_result {
924 Ok(Ok(())) => None,
925 Ok(Err(e)) => Some(e.to_string()),
926 Err(e) => Some(format!("upload task panic: {e}")),
927 };
928 match msg {
929 Some(msg) => {
930 push_log_with_observers(
931 &ctx.logs,
932 Some(&ctx.observers),
933 "error",
934 "ws",
935 &msg,
936 Some(job_id.to_string()),
937 );
938 let fail_result = ctx
939 .sender
940 .send(&WorkerInbound::Fail {
941 job_id: job_id.to_string(),
942 error: msg.clone(),
943 retryable: true,
944 })
945 .await;
946 record_fail_send(&fail_result, job_id, &ctx.logs, &ctx.observers);
947 JobOutcome::Failed { reason: msg }
948 }
949 None => {
950 push_log_with_observers(
951 &ctx.logs,
952 Some(&ctx.observers),
953 "info",
954 "ws",
955 "binary upload ok",
956 Some(job_id.to_string()),
957 );
958 JobOutcome::Completed
973 }
974 }
975 }
976 TaskResult::Llm { json } | TaskResult::AudioStt { json } => {
977 match ctx
983 .sender
984 .send(&WorkerInbound::CompleteJson {
985 job_id: job_id.to_string(),
986 result: json,
987 prompt: Some(full_prompt.to_string()),
988 })
989 .await
990 {
991 Ok(()) => {
992 push_log_with_observers(
993 &ctx.logs,
994 Some(&ctx.observers),
995 "info",
996 "ws",
997 "json result sent",
998 Some(job_id.to_string()),
999 );
1000 JobOutcome::Completed
1001 }
1002 Err(e) => {
1003 let msg = format!("failed to send result: {e}");
1004 push_log_with_observers(
1005 &ctx.logs,
1006 Some(&ctx.observers),
1007 "error",
1008 "ws",
1009 &msg,
1010 Some(job_id.to_string()),
1011 );
1012 JobOutcome::Failed { reason: msg }
1013 }
1014 }
1015 }
1016 }
1017}
1018
1019#[cfg_attr(coverage_nightly, coverage(off))]
1020fn spawn_reader(
1021 mut receiver: crate::ws::client::WsReceiver,
1022 event_tx: mpsc::UnboundedSender<SessionEvent>,
1023 read_idle_timeout: Duration,
1024) -> tokio::task::JoinHandle<()> {
1025 tokio::spawn(async move {
1026 loop {
1027 match tokio::time::timeout(read_idle_timeout, receiver.recv()).await {
1031 Ok(Ok(Some(frame))) => {
1032 if event_tx.send(SessionEvent::Frame(frame)).is_err() {
1033 break;
1034 }
1035 }
1036 Ok(Ok(None)) => {
1037 let _ =
1038 event_tx.send(SessionEvent::Disconnected(WsClientError::ConnectionClosed));
1039 break;
1040 }
1041 Ok(Err(e)) => {
1042 let _ = event_tx.send(SessionEvent::Disconnected(e));
1043 break;
1044 }
1045 Err(_elapsed) => {
1046 let _ = event_tx.send(SessionEvent::Disconnected(WsClientError::Transport(
1047 format!(
1048 "no frames from server for {:?}; treating connection as dead",
1049 read_idle_timeout
1050 ),
1051 )));
1052 break;
1053 }
1054 }
1055 }
1056 })
1057}
1058
1059#[cfg_attr(coverage_nightly, coverage(off))]
1060#[allow(clippy::too_many_arguments)]
1063fn spawn_heartbeat_pump(
1064 cfg: SharedConfig,
1065 engine: Arc<dyn Engine>,
1066 sender: WsSender,
1067 stop: Arc<AtomicBool>,
1068 paused: Arc<AtomicBool>,
1069 logs: Arc<Mutex<Vec<LogEntry>>>,
1070 observers: WorkerObservers,
1071 schedule: SessionSchedule,
1072) -> tokio::task::JoinHandle<()> {
1073 tokio::spawn(async move {
1074 let mut interval = tokio::time::interval(schedule.heartbeat);
1075 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1076 let mut last_paused = paused.load(Ordering::SeqCst);
1080 loop {
1081 interval.tick().await;
1082 if stop.load(Ordering::SeqCst) {
1083 break;
1084 }
1085 let now_paused = paused.load(Ordering::SeqCst);
1090 if let Some(message) = pause_transition_breadcrumb(last_paused, now_paused) {
1091 push_log_with_observers(&logs, Some(&observers), "info", "ws", message, None);
1092 }
1093 last_paused = now_paused;
1094 let caps = crate::runtime::build_capabilities_with(&cfg.lock(), &*engine, !now_paused);
1098 let current_job_id = heartbeat_current_job_id(&observers);
1099 if let Err(e) = sender
1100 .send(&WorkerInbound::Heartbeat {
1101 capabilities: caps,
1102 current_job_id,
1103 })
1104 .await
1105 {
1106 warn!(target: TRACE_TARGET, error = %e, "heartbeat send failed");
1107 break;
1108 }
1109 }
1110 })
1111}
1112
1113fn heartbeat_current_job_id(observers: &WorkerObservers) -> Option<String> {
1114 observers
1115 .current_job
1116 .lock()
1117 .as_ref()
1118 .map(|job| job.job_id.clone())
1119}
1120
1121#[cfg_attr(coverage_nightly, coverage(off))]
1122fn spawn_log_shipper_pump(
1123 sender: WsSender,
1124 logs: Arc<Mutex<Vec<LogEntry>>>,
1125 stop: Arc<AtomicBool>,
1126 schedule: SessionSchedule,
1127) -> tokio::task::JoinHandle<()> {
1128 tokio::spawn(async move {
1129 let mut interval = tokio::time::interval(schedule.log_flush);
1130 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1131 loop {
1132 interval.tick().await;
1133 if stop.load(Ordering::SeqCst) {
1134 break;
1135 }
1136 let batch = {
1137 let mut guard = logs.lock();
1138 if guard.is_empty() {
1139 continue;
1140 }
1141 std::mem::take(&mut *guard)
1142 };
1143 let frame = WorkerInbound::LogBatch { entries: batch };
1144 if let Err(e) = sender.send(&frame).await {
1145 warn!(target: TRACE_TARGET, error = %e, "log batch send failed; requeueing batch");
1146 if let WorkerInbound::LogBatch { entries } = frame {
1149 crate::runtime::restore_unshipped(&logs, entries);
1150 }
1151 break;
1152 }
1153 }
1154 })
1155}
1156
1157#[cfg_attr(coverage_nightly, coverage(off))]
1158fn spawn_shutdown_observer(
1159 stop: Arc<AtomicBool>,
1160 event_tx: mpsc::UnboundedSender<SessionEvent>,
1161 schedule: SessionSchedule,
1162) -> tokio::task::JoinHandle<()> {
1163 tokio::spawn(async move {
1164 loop {
1165 tokio::time::sleep(schedule.shutdown_tick).await;
1166 if stop.load(Ordering::SeqCst) {
1167 let _ = event_tx.send(SessionEvent::Stopped);
1168 break;
1169 }
1170 if event_tx.is_closed() {
1171 break;
1172 }
1173 }
1174 })
1175}
1176
1177fn reconnect_exhausted(max_attempts: u32, attempt: u32) -> bool {
1181 max_attempts > 0 && attempt > max_attempts
1182}
1183
1184fn backoff_for(attempt: u32, schedule: SessionSchedule) -> Duration {
1185 let factor = 2u64.saturating_pow(attempt.saturating_sub(1));
1186 let raw_ms = schedule.base_backoff_ms.saturating_mul(factor);
1187 Duration::from_millis(raw_ms.min(schedule.max_backoff_ms))
1188}
1189
1190fn reconnect_breadcrumb(error: Option<&anyhow::Error>, attempt: u32, backoff: Duration) -> String {
1205 let in_ms = backoff.as_millis();
1206 match error {
1207 Some(e) => format!("session error: {e:#}; reconnect attempt {attempt} in {in_ms}ms"),
1208 None => format!("disconnected; reconnect attempt {attempt} in {in_ms}ms"),
1209 }
1210}
1211
1212fn welcome_breadcrumb(worker_id: &str, server_time: &str) -> String {
1224 format!("server welcomed {worker_id} server_time={server_time}")
1225}
1226
1227fn offer_received_breadcrumb(
1238 job_id: &str,
1239 game_id: &str,
1240 asset_name: &str,
1241 model: &str,
1242 vram_gb_estimate: f32,
1243) -> String {
1244 format!(
1245 "offer received {job_id} game={game_id} asset={asset_name} model={model} vram={vram_gb_estimate}"
1246 )
1247}
1248
1249fn result_ack_breadcrumb(outcome: &str, job_id: &str) -> String {
1266 format!("studio confirmed {outcome} of job {job_id}")
1267}
1268
1269fn offer_response_breadcrumb(
1284 label: &str,
1285 job_id: &str,
1286 result: &WsResult<()>,
1287) -> Option<(&'static str, String)> {
1288 match result {
1289 Ok(()) => None,
1290 Err(e) => Some((
1291 "error",
1292 format!("{label} send failed for offer {job_id}: {e}"),
1293 )),
1294 }
1295}
1296
1297fn fail_send_breadcrumb(job_id: &str, result: &WsResult<()>) -> Option<(&'static str, String)> {
1312 match result {
1313 Ok(()) => None,
1314 Err(e) => Some((
1315 "error",
1316 format!("failed to notify studio of job {job_id} failure: {e}"),
1317 )),
1318 }
1319}
1320
1321fn record_fail_send(
1329 result: &WsResult<()>,
1330 job_id: &str,
1331 logs: &Arc<Mutex<Vec<LogEntry>>>,
1332 observers: &WorkerObservers,
1333) {
1334 if let Some((level, message)) = fail_send_breadcrumb(job_id, result) {
1335 push_log_with_observers(
1336 logs,
1337 Some(observers),
1338 level,
1339 "ws",
1340 &message,
1341 Some(job_id.to_string()),
1342 );
1343 }
1344}
1345
1346fn pause_transition_breadcrumb(prev: bool, now: bool) -> Option<&'static str> {
1360 match (prev, now) {
1361 (false, true) => Some("claiming paused by operator; new offers are rejected until resumed"),
1362 (true, false) => Some("claiming resumed by operator; accepting new offers again"),
1363 _ => None,
1364 }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369 use super::*;
1370
1371 #[test]
1372 fn offer_response_breadcrumb_is_silent_on_success() {
1373 assert!(offer_response_breadcrumb("accept", "j-1", &Ok(())).is_none());
1377 assert!(offer_response_breadcrumb("reject", "j-2", &Ok(())).is_none());
1378 }
1379
1380 #[test]
1383 fn heartbeat_current_job_id_uses_actual_job_id() {
1384 let observers = WorkerObservers::default();
1385 assert_eq!(heartbeat_current_job_id(&observers), None);
1386 *observers.current_job.lock() = Some(CurrentJob {
1387 job_id: "job-42".into(),
1388 kind: crate::types::TaskKind::Image,
1389 model: "synthetic".into(),
1390 prompt: "prompt".into(),
1391 started_at: chrono::Utc::now(),
1392 source: crate::runtime::JobSource::Studio,
1393 });
1394 assert_eq!(
1395 heartbeat_current_job_id(&observers).as_deref(),
1396 Some("job-42")
1397 );
1398 }
1399
1400 #[test]
1401 fn offer_response_breadcrumb_reports_accept_send_failure() {
1402 let (level, msg) =
1403 offer_response_breadcrumb("accept", "j-1", &Err(WsClientError::ConnectionClosed))
1404 .expect("a failed accept send must surface a breadcrumb");
1405 assert_eq!(level, "error");
1406 assert!(msg.contains("accept send failed"), "got: {msg}");
1407 assert!(msg.contains("j-1"), "must name the job: {msg}");
1408 assert!(
1409 msg.contains("connection closed"),
1410 "must carry the cause: {msg}"
1411 );
1412 }
1413
1414 #[test]
1415 fn offer_response_breadcrumb_reports_reject_send_failure() {
1416 let (level, msg) = offer_response_breadcrumb(
1417 "reject",
1418 "j-9",
1419 &Err(WsClientError::Transport("sink gone".into())),
1420 )
1421 .expect("a failed reject send must surface a breadcrumb");
1422 assert_eq!(level, "error");
1423 assert!(msg.contains("reject send failed"), "got: {msg}");
1424 assert!(msg.contains("j-9"), "must name the job: {msg}");
1425 assert!(msg.contains("sink gone"), "must carry the cause: {msg}");
1426 }
1427
1428 #[test]
1429 fn fail_send_breadcrumb_is_silent_on_success() {
1430 assert!(fail_send_breadcrumb("j-1", &Ok(())).is_none());
1434 }
1435
1436 #[test]
1437 fn fail_send_breadcrumb_reports_send_failure() {
1438 let (level, msg) = fail_send_breadcrumb("j-7", &Err(WsClientError::ConnectionClosed))
1439 .expect("a dropped Fail send must surface a breadcrumb");
1440 assert_eq!(level, "error");
1441 assert!(msg.contains("j-7"), "must name the job: {msg}");
1442 assert!(
1443 msg.contains("connection closed"),
1444 "must carry the cause: {msg}"
1445 );
1446 }
1447
1448 #[test]
1449 fn fail_send_breadcrumb_carries_transport_cause() {
1450 let (level, msg) =
1451 fail_send_breadcrumb("j-3", &Err(WsClientError::Transport("sink gone".into())))
1452 .expect("a dropped Fail send must surface a breadcrumb");
1453 assert_eq!(level, "error");
1454 assert!(msg.contains("j-3"), "must name the job: {msg}");
1455 assert!(msg.contains("sink gone"), "must carry the cause: {msg}");
1456 }
1457
1458 #[test]
1459 fn backoff_grows_exponentially_until_cap() {
1460 let schedule = SessionSchedule {
1461 base_backoff_ms: 100,
1462 max_backoff_ms: 1_000,
1463 heartbeat: Duration::from_secs(1),
1464 log_flush: Duration::from_secs(1),
1465 shutdown_tick: Duration::from_secs(1),
1466 read_idle_timeout: Duration::from_secs(1),
1467 };
1468 assert_eq!(backoff_for(1, schedule), Duration::from_millis(100));
1469 assert_eq!(backoff_for(2, schedule), Duration::from_millis(200));
1470 assert_eq!(backoff_for(3, schedule), Duration::from_millis(400));
1471 assert_eq!(backoff_for(4, schedule), Duration::from_millis(800));
1472 assert_eq!(backoff_for(5, schedule), Duration::from_millis(1_000));
1474 assert_eq!(backoff_for(10, schedule), Duration::from_millis(1_000));
1475 }
1476
1477 #[test]
1478 fn reconnect_is_infinite_by_default_and_finite_only_when_pinned() {
1479 assert!(!reconnect_exhausted(0, 1));
1483 assert!(!reconnect_exhausted(0, 10_000));
1484 assert!(!reconnect_exhausted(DEFAULT_RECONNECT_ATTEMPTS, 6));
1487 assert_eq!(DEFAULT_RECONNECT_ATTEMPTS, 0, "default must be infinite");
1488 assert!(!reconnect_exhausted(5, 5));
1490 assert!(reconnect_exhausted(5, 6));
1491 }
1492
1493 #[test]
1494 fn reconnect_breadcrumb_keeps_legacy_wording_for_a_plain_disconnect() {
1495 let msg = reconnect_breadcrumb(None, 3, Duration::from_millis(800));
1499 assert_eq!(msg, "disconnected; reconnect attempt 3 in 800ms");
1500 }
1501
1502 #[test]
1503 fn reconnect_breadcrumb_surfaces_the_underlying_error() {
1504 let err = anyhow!("hello send failed: connection closed");
1509 let msg = reconnect_breadcrumb(Some(&err), 2, Duration::from_millis(400));
1510 assert!(
1511 msg.contains("reconnect attempt 2 in 400ms"),
1512 "must still name attempt + backoff: {msg}"
1513 );
1514 assert!(
1515 msg.contains("hello send failed: connection closed"),
1516 "must carry the cause: {msg}"
1517 );
1518 }
1519
1520 #[test]
1521 fn reconnect_breadcrumb_includes_the_full_error_chain() {
1522 let err = anyhow!("driver missing").context("engine build failed");
1525 let msg = reconnect_breadcrumb(Some(&err), 1, Duration::from_millis(100));
1526 assert!(msg.contains("engine build failed"), "got: {msg}");
1527 assert!(
1528 msg.contains("driver missing"),
1529 "must include the root cause: {msg}"
1530 );
1531 }
1532
1533 #[test]
1534 fn has_credentials_false_when_either_missing() {
1535 let mut cfg = crate::config::Config::default();
1536 let shared = crate::config::shared(cfg.clone());
1537 assert!(!has_credentials(&shared), "both missing");
1538 cfg.worker_id = Some("w-1".into());
1539 let shared = crate::config::shared(cfg.clone());
1540 assert!(!has_credentials(&shared), "only worker_id");
1541 cfg.worker_id = None;
1542 cfg.auth_token = Some("tok".into());
1543 let shared = crate::config::shared(cfg.clone());
1544 assert!(!has_credentials(&shared), "only auth_token");
1545 }
1546
1547 #[test]
1548 fn has_credentials_true_when_both_present() {
1549 let cfg = crate::config::Config {
1550 worker_id: Some("w-1".into()),
1551 auth_token: Some("tok".into()),
1552 ..crate::config::Config::default()
1553 };
1554 let shared = crate::config::shared(cfg);
1555 assert!(has_credentials(&shared));
1556 }
1557
1558 #[test]
1559 fn has_credentials_false_when_empty_strings() {
1560 let cfg = crate::config::Config {
1561 worker_id: Some("".into()),
1562 auth_token: Some("".into()),
1563 ..crate::config::Config::default()
1564 };
1565 let shared = crate::config::shared(cfg);
1566 assert!(!has_credentials(&shared));
1567 }
1568
1569 #[test]
1570 fn pause_transition_breadcrumb_is_silent_when_unchanged() {
1571 assert!(pause_transition_breadcrumb(false, false).is_none());
1574 assert!(pause_transition_breadcrumb(true, true).is_none());
1575 }
1576
1577 #[test]
1578 fn pause_transition_breadcrumb_reports_pause_and_resume() {
1579 let paused = pause_transition_breadcrumb(false, true).expect("a pause must be reported");
1583 assert!(
1584 paused.contains("paused by operator"),
1585 "expected a pause message, got: {paused}"
1586 );
1587 let resumed = pause_transition_breadcrumb(true, false).expect("a resume must be reported");
1588 assert!(
1589 resumed.contains("resumed by operator"),
1590 "expected a resume message, got: {resumed}"
1591 );
1592 }
1593
1594 #[test]
1595 fn welcome_breadcrumb_surfaces_server_time() {
1596 let line = welcome_breadcrumb("worker-7", "2026-06-15T21:00:00Z");
1603 assert!(
1604 line.contains("server welcomed worker-7"),
1605 "expected the legacy wording + worker id, got: {line}"
1606 );
1607 assert!(
1608 line.contains("server_time=2026-06-15T21:00:00Z"),
1609 "expected the server time, got: {line}"
1610 );
1611 }
1612
1613 #[test]
1614 fn offer_received_breadcrumb_names_game_and_asset() {
1615 let line = offer_received_breadcrumb(
1621 "j-1",
1622 "game-of-elements",
1623 "game-of-elements/creatures/aurora-fox",
1624 "sd-cpp:flux",
1625 12.5,
1626 );
1627 assert!(
1628 line.contains("offer received j-1"),
1629 "expected the job id, got: {line}"
1630 );
1631 assert!(
1632 line.contains("game=game-of-elements"),
1633 "expected the game id, got: {line}"
1634 );
1635 assert!(
1636 line.contains("asset=game-of-elements/creatures/aurora-fox"),
1637 "expected the asset name, got: {line}"
1638 );
1639 assert!(
1640 line.contains("model=sd-cpp:flux"),
1641 "expected the model, got: {line}"
1642 );
1643 assert!(line.contains("vram=12.5"), "expected the vram, got: {line}");
1644 }
1645
1646 #[test]
1647 fn result_ack_breadcrumb_names_the_outcome_and_job() {
1648 assert_eq!(
1658 result_ack_breadcrumb("completion", "j-1"),
1659 "studio confirmed completion of job j-1"
1660 );
1661 assert_eq!(
1662 result_ack_breadcrumb("failure", "j-2"),
1663 "studio confirmed failure of job j-2"
1664 );
1665 }
1666}