1use std::collections::HashMap;
25use std::fmt;
26use std::future::Future;
27use std::panic::AssertUnwindSafe;
28use std::sync::atomic::{AtomicUsize, Ordering};
29use std::sync::{Arc, Mutex};
30use std::time::{Duration, SystemTime, UNIX_EPOCH};
31
32use bytes::Bytes;
33use cronoq::TaskError;
34use cronoq_proto::v1 as pb;
35use futures_util::FutureExt;
36use pb::worker_message::Msg;
37use pb::workers_client::WorkersClient;
38use rustc_hash::FxHashMap;
39use tokio::sync::{Notify, mpsc};
40use tokio_stream::wrappers::ReceiverStream;
41use tokio_util::sync::CancellationToken;
42use tonic::Code;
43use tonic::metadata::MetadataValue;
44use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity};
45
46#[derive(Clone, Default)]
48pub struct WorkerTls {
49 pub ca_pem: Option<Vec<u8>>,
51 pub identity: Option<(Vec<u8>, Vec<u8>)>,
53 pub domain: Option<String>,
55}
56
57impl fmt::Debug for WorkerTls {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.debug_struct("WorkerTls")
60 .field("ca_pem", &self.ca_pem.as_ref().map(|_| "<pem>"))
61 .field("identity", &self.identity.as_ref().map(|_| "<redacted>"))
62 .field("domain", &self.domain)
63 .finish()
64 }
65}
66
67#[derive(Clone)]
69pub struct WorkerOptions {
70 pub endpoint: String,
72 pub pool: String,
74 pub slots: u32,
76 pub token: Option<String>,
78 pub name: String,
80 pub tls: Option<WorkerTls>,
83 pub reconnect: bool,
85 pub initial_backoff: Duration,
87 pub max_backoff: Duration,
89 pub drain_timeout: Duration,
91 pub max_message_bytes: usize,
93 pub labels: Vec<(String, String)>,
96 pub insecure_redirect_http: bool,
99}
100
101impl fmt::Debug for WorkerOptions {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.debug_struct("WorkerOptions")
104 .field("endpoint", &self.endpoint)
105 .field("pool", &self.pool)
106 .field("slots", &self.slots)
107 .field("token", &self.token.as_ref().map(|_| "<redacted>"))
108 .field("name", &self.name)
109 .field("tls", &self.tls)
110 .field("reconnect", &self.reconnect)
111 .field("drain_timeout", &self.drain_timeout)
112 .field("labels", &self.labels)
113 .field("insecure_redirect_http", &self.insecure_redirect_http)
114 .finish_non_exhaustive()
115 }
116}
117
118impl WorkerOptions {
119 pub fn new(endpoint: impl Into<String>, pool: impl Into<String>) -> Self {
121 WorkerOptions {
122 endpoint: endpoint.into(),
123 pool: pool.into(),
124 slots: 1,
125 token: None,
126 name: String::new(),
127 tls: None,
128 reconnect: true,
129 initial_backoff: Duration::from_millis(200),
130 max_backoff: Duration::from_secs(10),
131 drain_timeout: Duration::from_secs(30),
132 max_message_bytes: 64 << 20,
133 labels: Vec::new(),
134 insecure_redirect_http: false,
135 }
136 }
137 pub fn insecure_redirect_http(mut self, on: bool) -> Self {
139 self.insecure_redirect_http = on;
140 self
141 }
142 pub fn label(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
144 self.labels.push((name.into(), value.into()));
145 self
146 }
147 pub fn slots(mut self, n: u32) -> Self {
149 self.slots = n;
150 self
151 }
152 pub fn token(mut self, t: impl Into<String>) -> Self {
154 self.token = Some(t.into());
155 self
156 }
157 pub fn name(mut self, n: impl Into<String>) -> Self {
159 self.name = n.into();
160 self
161 }
162 pub fn tls(mut self, tls: WorkerTls) -> Self {
164 self.tls = Some(tls);
165 self
166 }
167 pub fn reconnect(mut self, on: bool) -> Self {
169 self.reconnect = on;
170 self
171 }
172 pub fn drain_timeout(mut self, d: Duration) -> Self {
174 self.drain_timeout = d;
175 self
176 }
177}
178
179#[derive(Clone, Debug)]
181pub struct LeaseInfo {
182 pub lease: u64,
184 pub task_id: u64,
186 pub attempt: u32,
188 pub pool: String,
190 pub payload: Bytes,
192 pub content_type: String,
194 pub kind: String,
196 pub key: String,
198 pub tags: HashMap<String, String>,
200 pub timeout: Option<Duration>,
202 pub deadline: Option<SystemTime>,
204 pub cancel: CancellationToken,
206}
207
208fn duration(s: Option<f64>) -> Option<Duration> {
209 s.and_then(|s| Duration::try_from_secs_f64(s).ok())
210}
211
212pub const MAX_REDIRECTS: u32 = 3;
214
215const ENDPOINT_HEADER: &str = "cronoq-shard-endpoint";
217
218fn redirect(
221 opts: &WorkerOptions,
222 current: &str,
223 s: &tonic::Status,
224) -> anyhow::Result<Option<String>> {
225 if s.code() != Code::FailedPrecondition {
226 return Ok(None);
227 }
228 let Some(target) = s
229 .metadata()
230 .get(ENDPOINT_HEADER)
231 .and_then(|v| v.to_str().ok())
232 else {
233 return Ok(None);
234 };
235 let https = target.starts_with("https://");
236 anyhow::ensure!(
237 https || target.starts_with("http://"),
238 "the server redirected the worker to '{target}', which is not an http(s) endpoint"
239 );
240 anyhow::ensure!(
241 https || !current.starts_with("https://"),
242 "the server redirected the worker from https to plain http ('{target}'); refusing"
243 );
244 let uri = target.parse::<tonic::transport::Uri>()?;
245 anyhow::ensure!(
246 uri.authority().is_some_and(|a| !a.as_str().contains('@')),
247 "the server redirected the worker to '{target}', which is not a plain host and port"
248 );
249 let plain_remote = target.starts_with("http://") && !is_loopback_endpoint(target);
250 if opts.token.is_some() && plain_remote {
251 anyhow::ensure!(
252 opts.insecure_redirect_http,
253 "the server redirected the worker to '{target}', which would send its token over \
254 plain http to another host; refusing (insecure_redirect_http allows it)"
255 );
256 tracing::warn!(endpoint = %target, "following a redirect that sends the worker token over plain http");
257 }
258 Ok(Some(target.to_string()))
259}
260
261fn fatal(code: Code) -> bool {
263 matches!(
264 code,
265 Code::Unauthenticated
266 | Code::PermissionDenied
267 | Code::NotFound
268 | Code::InvalidArgument
269 | Code::FailedPrecondition
270 | Code::Unimplemented
271 )
272}
273
274#[derive(Default)]
276struct Running {
277 leases: Mutex<FxHashMap<u64, Lease>>,
278 count: AtomicUsize,
279 idle: Notify,
280}
281
282struct Lease {
283 cancel: CancellationToken,
284 aborted: Arc<std::sync::atomic::AtomicBool>,
286}
287
288struct Slot {
290 running: Arc<Running>,
291 lease: u64,
292}
293
294impl Running {
295 fn release(&self, lease: u64) -> Option<Lease> {
298 let l = self.leases.lock().unwrap().remove(&lease)?;
299 if self.count.fetch_sub(1, Ordering::SeqCst) == 1 {
300 self.idle.notify_waiters();
301 }
302 Some(l)
303 }
304}
305
306impl Drop for Slot {
307 fn drop(&mut self) {
308 self.running.release(self.lease);
309 }
310}
311
312struct AbortOnDrop(tokio::task::JoinHandle<()>);
314
315impl Drop for AbortOnDrop {
316 fn drop(&mut self) {
317 self.0.abort();
318 }
319}
320
321enum SessionEnd {
322 Shutdown,
324 Ended(String),
326 Failed(tonic::Status),
328}
329
330pub async fn run_worker<F, Fut>(
335 opts: WorkerOptions,
336 handler: F,
337 shutdown: impl Future<Output = ()>,
338) -> anyhow::Result<()>
339where
340 F: Fn(LeaseInfo) -> Fut + Send + Sync + 'static,
341 Fut: Future<Output = Result<Bytes, TaskError>> + Send + 'static,
342{
343 endpoint(&opts, &opts.endpoint)?;
344 let auth: Option<MetadataValue<_>> = match &opts.token {
345 Some(t) => {
346 let mut v: MetadataValue<_> = format!("Bearer {t}").parse()?;
347 v.set_sensitive(true);
348 Some(v)
349 }
350 None => None,
351 };
352 let handler = Arc::new(handler);
353 let running = Arc::new(Running::default());
354 let stop = CancellationToken::new();
355 let _stop_on_drop = stop.clone().drop_guard();
357 tokio::pin!(shutdown);
358 let mut backoff = opts.initial_backoff;
359 let mut current = opts.endpoint.clone();
361 let mut redirects = 0;
362 let result = loop {
363 let mut connect_failed = false;
364 let session = async {
365 let channel = endpoint(&opts, ¤t)
366 .map_err(|e| tonic::Status::invalid_argument(e.to_string()))?
367 .connect()
368 .await
369 .map_err(|e| {
370 connect_failed = true;
371 tonic::Status::unavailable(format!("connect: {e}"))
372 })?;
373 let auth = auth.clone();
374 let client =
375 WorkersClient::with_interceptor(channel, move |mut req: tonic::Request<()>| {
376 if let Some(a) = &auth {
377 req.metadata_mut().insert("authorization", a.clone());
378 }
379 Ok(req)
380 })
381 .max_decoding_message_size(opts.max_message_bytes);
382 Ok::<_, tonic::Status>(client)
383 };
384 let end = tokio::select! {
385 c = session => match c {
386 Ok(client) => serve(&opts, client, handler.clone(), running.clone(), &stop, &mut shutdown, &mut backoff).await,
387 Err(s) => SessionEnd::Failed(s),
388 },
389 _ = &mut shutdown => SessionEnd::Shutdown,
390 };
391 if let SessionEnd::Failed(s) = &end {
392 match redirect(&opts, ¤t, s) {
393 Ok(Some(target)) => {
394 redirects += 1;
395 if redirects > MAX_REDIRECTS {
396 break Err(anyhow::anyhow!(
397 "worker redirected {redirects} times in a row, more than the {MAX_REDIRECTS} it follows: {s}"
398 ));
399 }
400 tracing::info!(from = %current, to = %target, "pool is served by another shard; reconnecting there");
401 current = target;
402 continue;
403 }
404 Ok(None) => {}
405 Err(e) => break Err(e),
406 }
407 }
408 redirects = 0;
409 if connect_failed && current != opts.endpoint {
410 tracing::warn!(endpoint = %current, "shard unreachable; returning to the configured endpoint");
412 current = opts.endpoint.clone();
413 }
414 match end {
415 SessionEnd::Shutdown => break Ok(()),
416 SessionEnd::Failed(s) if fatal(s.code()) => {
417 break Err(anyhow::anyhow!("worker rejected: {s}"));
418 }
419 SessionEnd::Failed(s) if !opts.reconnect => {
420 break Err(anyhow::anyhow!("worker stream failed: {s}"));
421 }
422 SessionEnd::Ended(why) if !opts.reconnect => {
423 break Err(anyhow::anyhow!("worker stream ended: {why}"));
424 }
425 SessionEnd::Failed(s) => {
426 tracing::warn!(error = %s, retry_in = ?backoff, "worker connection failed")
427 }
428 SessionEnd::Ended(why) => {
429 tracing::info!(reason = %why, retry_in = ?backoff, "worker stream ended; reconnecting")
430 }
431 }
432 tokio::select! {
433 _ = tokio::time::sleep(backoff) => {}
434 _ = &mut shutdown => break Ok(()),
435 }
436 backoff = (backoff * 2).min(opts.max_backoff);
437 };
438 stop.cancel();
440 for (_, l) in running.leases.lock().unwrap().drain() {
441 l.cancel.cancel();
442 }
443 result
444}
445
446fn endpoint(opts: &WorkerOptions, url: &str) -> anyhow::Result<Endpoint> {
449 let mut ep = Endpoint::from_shared(url.to_string())?
450 .connect_timeout(Duration::from_secs(10))
451 .tcp_keepalive(Some(Duration::from_secs(20)))
452 .http2_keep_alive_interval(Duration::from_secs(20))
453 .keep_alive_timeout(Duration::from_secs(20))
454 .keep_alive_while_idle(true);
455 let https = url.starts_with("https://");
456 anyhow::ensure!(
458 https || opts.tls.is_none(),
459 "worker TLS settings need an https:// endpoint, not '{url}'"
460 );
461 if !https && opts.token.is_some() && !is_loopback_endpoint(url) {
462 tracing::warn!(endpoint = %url, "the worker token is sent over plain http; use https outside a trusted network");
463 }
464 if https {
465 let tls = opts.tls.clone().unwrap_or_default();
466 let mut cfg = ClientTlsConfig::new();
467 cfg = match &tls.ca_pem {
468 Some(ca) => cfg.ca_certificate(Certificate::from_pem(ca)),
469 None => cfg.with_native_roots(),
470 };
471 if let Some((cert, key)) = &tls.identity {
472 cfg = cfg.identity(Identity::from_pem(cert, key));
473 }
474 if let Some(d) = &tls.domain
475 && url == opts.endpoint
476 {
477 cfg = cfg.domain_name(d.clone());
478 }
479 ep = ep.tls_config(cfg)?;
480 }
481 Ok(ep)
482}
483
484fn is_loopback_endpoint(endpoint: &str) -> bool {
485 let Ok(uri) = endpoint.parse::<tonic::transport::Uri>() else {
487 return false;
488 };
489 let host = uri.host().unwrap_or("");
490 let host = host.trim_start_matches('[').trim_end_matches(']');
491 host.eq_ignore_ascii_case("localhost")
492 || host
493 .parse::<std::net::IpAddr>()
494 .is_ok_and(|ip| ip.is_loopback())
495}
496
497#[allow(clippy::too_many_arguments)]
498async fn serve<F, Fut, I>(
499 opts: &WorkerOptions,
500 mut client: WorkersClient<
501 tonic::service::interceptor::InterceptedService<tonic::transport::Channel, I>,
502 >,
503 handler: Arc<F>,
504 running: Arc<Running>,
505 stop: &CancellationToken,
506 shutdown: &mut std::pin::Pin<&mut impl Future<Output = ()>>,
507 backoff: &mut Duration,
508) -> SessionEnd
509where
510 F: Fn(LeaseInfo) -> Fut + Send + Sync + 'static,
511 Fut: Future<Output = Result<Bytes, TaskError>> + Send + 'static,
512 I: tonic::service::Interceptor,
513{
514 let cap = (opts.slots as usize).saturating_mul(4).clamp(64, 65_536);
515 let (tx, rx) = mpsc::channel::<pb::WorkerMessage>(cap);
516 let register = pb::WorkerMessage {
517 msg: Some(Msg::Register(pb::Register {
518 pool: opts.pool.clone(),
519 slots: opts.slots,
520 worker_name: opts.name.clone(),
521 protocol_version: cronoq_proto::PROTOCOL_VERSION,
522 labels: opts.labels.iter().cloned().collect(),
523 })),
524 };
525 if tx.send(register).await.is_err() {
526 return SessionEnd::Ended("local channel closed".into());
527 }
528 let mut inbound = match client.work(ReceiverStream::new(rx)).await {
529 Ok(r) => r.into_inner(),
530 Err(s) => return SessionEnd::Failed(s),
531 };
532 let registered = match inbound.message().await {
533 Ok(Some(pb::ServerMessage {
534 msg: Some(pb::server_message::Msg::Registered(r)),
535 })) => r,
536 Ok(Some(pb::ServerMessage {
537 msg: Some(pb::server_message::Msg::Shutdown(s)),
538 })) => {
539 return SessionEnd::Ended(format!("server shutting down: {}", s.reason));
540 }
541 Ok(other) => {
542 return SessionEnd::Failed(tonic::Status::internal(format!(
543 "unexpected first server message: {other:?}"
544 )));
545 }
546 Err(s) => return SessionEnd::Failed(s),
547 };
548 *backoff = opts.initial_backoff;
549 let ttl = duration(registered.lease_ttl_s);
550 let slots = registered.slots as usize;
551 let max_output = (registered.max_message_bytes as usize).saturating_sub(1024);
553 tracing::info!(worker = registered.worker_id, pool = %opts.pool, slots, "worker registered");
554 let mut draining: Option<tokio::time::Instant> = None;
555 loop {
556 let drain_done = async {
557 match draining {
558 Some(deadline) => loop {
559 let idle = running.idle.notified();
560 if running.count.load(Ordering::SeqCst) == 0 {
561 return;
562 }
563 tokio::select! {
564 _ = idle => {}
565 _ = tokio::time::sleep_until(deadline) => return,
566 }
567 },
568 None => std::future::pending().await,
569 }
570 };
571 let msg = tokio::select! {
572 m = inbound.message() => m,
573 _ = shutdown.as_mut(), if draining.is_none() => {
574 tracing::info!(running = running.count.load(Ordering::SeqCst), "worker draining");
575 draining = Some(tokio::time::Instant::now() + opts.drain_timeout);
576 continue;
577 }
578 _ = drain_done => {
579 for l in running.leases.lock().unwrap().values() {
581 l.cancel.cancel();
582 }
583 drop(tx);
585 let flush = async { while let Ok(Some(_)) = inbound.message().await {} };
586 let _ = tokio::time::timeout(Duration::from_secs(5), flush).await;
587 return SessionEnd::Shutdown;
588 }
589 };
590 let msg = match msg {
591 Ok(Some(m)) => m.msg,
592 Ok(None) => {
593 return if draining.is_some() {
594 SessionEnd::Shutdown
595 } else {
596 SessionEnd::Ended("server closed the stream".into())
597 };
598 }
599 Err(s) => {
600 return if draining.is_some() {
601 SessionEnd::Shutdown
602 } else {
603 SessionEnd::Failed(s)
604 };
605 }
606 };
607 match msg {
608 Some(pb::server_message::Msg::Lease(l)) => {
609 let busy = running.count.load(Ordering::SeqCst);
610 if draining.is_some() || busy >= slots {
611 let reason = if draining.is_some() {
612 "worker is draining"
613 } else {
614 "no free slot"
615 };
616 let _ = tx.try_send(pb::WorkerMessage {
617 msg: Some(Msg::Nack(pb::Nack {
618 lease: l.lease,
619 reason: reason.into(),
620 })),
621 });
622 continue;
623 }
624 start_lease(
625 l,
626 handler.clone(),
627 running.clone(),
628 tx.clone(),
629 ttl,
630 max_output,
631 stop,
632 );
633 }
634 Some(pb::server_message::Msg::Abort(a)) => {
635 if let Some(l) = running.release(a.lease) {
636 l.aborted.store(true, Ordering::SeqCst);
637 l.cancel.cancel();
638 }
639 }
640 Some(pb::server_message::Msg::Shutdown(s)) => {
641 if draining.is_some() {
642 return SessionEnd::Shutdown;
643 }
644 let why = if s.not_leader {
645 format!("server is no longer the leader: {}", s.reason)
646 } else {
647 s.reason
648 };
649 return SessionEnd::Ended(why);
650 }
651 Some(pb::server_message::Msg::Registered(_)) | None => {}
652 }
653 }
654}
655
656fn start_lease<F, Fut>(
657 l: pb::Lease,
658 handler: Arc<F>,
659 running: Arc<Running>,
660 tx: mpsc::Sender<pb::WorkerMessage>,
661 ttl: Option<Duration>,
662 max_output: usize,
663 stop: &CancellationToken,
664) where
665 F: Fn(LeaseInfo) -> Fut + Send + Sync + 'static,
666 Fut: Future<Output = Result<Bytes, TaskError>> + Send + 'static,
667{
668 let lease = l.lease;
669 let cancel = stop.child_token();
670 let aborted = Arc::new(std::sync::atomic::AtomicBool::new(false));
671 running.count.fetch_add(1, Ordering::SeqCst);
672 running.leases.lock().unwrap().insert(
673 lease,
674 Lease {
675 cancel: cancel.clone(),
676 aborted: aborted.clone(),
677 },
678 );
679 let slot = Slot { running, lease };
680 let info = LeaseInfo {
681 lease,
682 task_id: l.task_id,
683 attempt: l.attempt,
684 pool: l.pool,
685 payload: Bytes::from(l.payload),
686 content_type: l.content_type,
687 kind: l.kind,
688 key: l.key,
689 tags: l.tags,
690 timeout: duration(l.timeout_s),
691 deadline: l
692 .deadline_unix_ns
693 .filter(|ns| *ns >= 0)
694 .map(|ns| UNIX_EPOCH + Duration::from_nanos(ns as u64)),
695 cancel: cancel.clone(),
696 };
697 let ttl = duration(l.lease_ttl_s).or(ttl);
698 tokio::spawn(async move {
699 let _slot = slot;
700 let _beat = ttl.map(|t| {
701 let tx = tx.clone();
702 AbortOnDrop(tokio::spawn(async move {
703 let mut iv = tokio::time::interval((t / 3).max(Duration::from_millis(10)));
704 iv.tick().await;
705 loop {
706 iv.tick().await;
707 if tx
708 .send(pb::WorkerMessage {
709 msg: Some(Msg::Heartbeat(pb::Heartbeat { lease })),
710 })
711 .await
712 .is_err()
713 {
714 break;
715 }
716 }
717 }))
718 });
719 let run = AssertUnwindSafe(handler(info)).catch_unwind();
720 let result = tokio::select! {
721 r = run => Some(r),
722 _ = cancel.cancelled() => None,
723 };
724 let msg = match result {
725 None if aborted.load(Ordering::SeqCst) => return,
726 None => Msg::Fail(pb::Fail {
727 lease,
728 message: "worker stopped before the attempt finished".into(),
729 permanent: false,
730 retry_after_s: None,
731 }),
732 Some(Err(panic)) => {
733 let what = panic
734 .downcast_ref::<&str>()
735 .map(|s| s.to_string())
736 .or_else(|| panic.downcast_ref::<String>().cloned())
737 .unwrap_or_else(|| "unknown panic".into());
738 tracing::error!(lease, panic = %what, "handler panicked");
739 Msg::Fail(pb::Fail {
740 lease,
741 message: format!("handler panicked: {what}"),
742 permanent: false,
743 retry_after_s: None,
744 })
745 }
746 Some(Ok(Ok(output))) if max_output > 0 && output.len() > max_output => {
747 Msg::Fail(pb::Fail {
748 lease,
749 message: format!(
750 "output of {} bytes exceeds the server limit of {max_output} bytes",
751 output.len()
752 ),
753 permanent: true,
754 retry_after_s: None,
755 })
756 }
757 Some(Ok(Ok(output))) => Msg::Complete(pb::Complete {
758 lease,
759 output: output.to_vec(),
760 content_type: String::new(),
761 }),
762 Some(Ok(Err(TaskError::Permanent { message }))) => Msg::Fail(pb::Fail {
763 lease,
764 message: message.to_string(),
765 permanent: true,
766 retry_after_s: None,
767 }),
768 Some(Ok(Err(TaskError::Retryable {
769 message,
770 retry_after,
771 }))) => Msg::Fail(pb::Fail {
772 lease,
773 message: message.to_string(),
774 permanent: false,
775 retry_after_s: retry_after.map(|d| d.as_secs_f64()),
776 }),
777 Some(Ok(Err(TaskError::Cancelled))) => Msg::Cancelled(pb::Cancelled { lease }),
778 };
779 let _ = tx.send(pb::WorkerMessage { msg: Some(msg) }).await;
780 });
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786
787 fn refusal(code: Code, endpoint: Option<&'static str>) -> tonic::Status {
788 let mut s = tonic::Status::new(code, "pool 'llm' is served by shard 'gpu'");
789 if let Some(e) = endpoint {
790 s.metadata_mut()
791 .insert(ENDPOINT_HEADER, MetadataValue::from_static(e));
792 }
793 s
794 }
795
796 #[test]
797 fn redirects_never_weaken_the_connection() {
798 let opts = WorkerOptions::new("https://cpu.internal:7070", "llm").token("t");
799 let follow = |current: &str, s| redirect(&opts, current, &s);
800 let fp = Code::FailedPrecondition;
801 assert_eq!(
802 follow(
803 current(&opts),
804 refusal(fp, Some("https://gpu.internal:7070"))
805 )
806 .unwrap(),
807 Some("https://gpu.internal:7070".into())
808 );
809 assert_eq!(
811 follow(current(&opts), refusal(Code::NotFound, Some("https://x:1"))).unwrap(),
812 None
813 );
814 assert_eq!(follow(current(&opts), refusal(fp, None)).unwrap(), None);
815 assert!(follow(current(&opts), refusal(fp, Some("http://127.0.0.1:7070"))).is_err());
817 assert!(follow(current(&opts), refusal(fp, Some("unix:/tmp/s"))).is_err());
818 assert!(
819 follow(
820 current(&opts),
821 refusal(fp, Some("https://u:p@gpu.internal:7070"))
822 )
823 .is_err()
824 );
825
826 let local = WorkerOptions::new("http://127.0.0.1:7070", "llm").token("t");
828 let r = |t| redirect(&local, "http://127.0.0.1:7070", &refusal(fp, Some(t)));
829 assert!(r("http://127.0.0.1:7071").unwrap().is_some());
830 assert!(r("https://gpu.internal:7070").unwrap().is_some());
831 assert!(r("http://gpu.internal:7070").is_err());
832 assert!(r("http://127.0.0.1:1@gpu.internal:7070").is_err());
834 assert!(!is_loopback_endpoint(
835 "http://127.0.0.1:1@gpu.internal:7070"
836 ));
837 assert!(is_loopback_endpoint("http://[::1]:7070"));
838 let anon = WorkerOptions::new("http://127.0.0.1:7070", "llm");
840 let to_remote = refusal(fp, Some("http://gpu.internal:7070"));
841 assert!(
842 redirect(&anon, "http://127.0.0.1:7070", &to_remote)
843 .unwrap()
844 .is_some()
845 );
846 let remote = WorkerOptions::new("http://cpu.internal:7070", "llm").token("t");
848 assert!(redirect(&remote, "http://cpu.internal:7070", &to_remote).is_err());
849 let trusted = remote.insecure_redirect_http(true);
851 assert!(
852 redirect(&trusted, "http://cpu.internal:7070", &to_remote)
853 .unwrap()
854 .is_some()
855 );
856 }
857
858 fn current(opts: &WorkerOptions) -> &str {
859 &opts.endpoint
860 }
861}