-
Notifications
You must be signed in to change notification settings - Fork 1k
fix(sandbox): terminate sandbox when proxy accept loop exits unexpectedly #2370
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
8d0099f
26a00e8
4e915ed
6a1fe36
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -174,11 +174,11 @@ impl InferenceContext { | |
| } | ||
| } | ||
|
|
||
| #[derive(Debug)] | ||
| pub struct ProxyHandle { | ||
| #[allow(dead_code)] | ||
| http_addr: Option<SocketAddr>, | ||
| join: JoinHandle<()>, | ||
| exited_rx: tokio::sync::oneshot::Receiver<()>, | ||
| } | ||
|
|
||
| impl ProxyHandle { | ||
|
|
@@ -240,7 +240,13 @@ impl ProxyHandle { | |
| ); | ||
| } | ||
|
|
||
| let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); | ||
| let join = tokio::spawn(async move { | ||
| // Hold the sender for the lifetime of this task — when the task | ||
| // exits (panic, abort, or loop break), the sender drops and the | ||
| // receiver fires, notifying the sandbox that the proxy is gone. | ||
| let _proxy_exit_guard = exited_tx; | ||
|
|
||
| // Wait for the OPA engine's symlink resolution reload to complete | ||
| // before accepting connections. This prevents requests from | ||
| // observing a generation transition mid-flight, which would cause | ||
|
|
@@ -265,9 +271,11 @@ impl ProxyHandle { | |
| } | ||
| } | ||
|
|
||
| let mut consecutive_fd_errors: u32 = 0; | ||
| loop { | ||
| match listener.accept().await { | ||
| Ok((stream, _addr)) => { | ||
| consecutive_fd_errors = 0; | ||
| let opa = opa_engine.clone(); | ||
| let cache = identity_cache.clone(); | ||
| let spid = entrypoint_pid.clone(); | ||
|
|
@@ -314,14 +322,34 @@ impl ProxyHandle { | |
| }); | ||
| } | ||
| Err(err) => { | ||
| // EMFILE (24) / ENFILE (23) indicate FD exhaustion — | ||
| // back off exponentially to let connections drain. | ||
| let is_fd_exhaustion = matches!( | ||
| err.raw_os_error(), | ||
| Some(24) | Some(23) | ||
| ); | ||
| let (severity, backoff) = if is_fd_exhaustion { | ||
| consecutive_fd_errors = consecutive_fd_errors.saturating_add(1); | ||
| let backoff_ms = 100u64 | ||
| .saturating_mul( | ||
| 1u64 << consecutive_fd_errors.min(6).saturating_sub(1), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Warning: |
||
| ) | ||
| .min(5_000); | ||
| (SeverityId::Medium, std::time::Duration::from_millis(backoff_ms)) | ||
| } else { | ||
| (SeverityId::Low, std::time::Duration::from_millis(100)) | ||
| }; | ||
| let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) | ||
| .activity(ActivityId::Fail) | ||
| .severity(SeverityId::Low) | ||
| .severity(severity) | ||
| .status(StatusId::Failure) | ||
| .message(format!("Proxy accept error: {err}")) | ||
| .message(format!( | ||
| "Proxy accept error (retrying in {}ms): {err}", | ||
| backoff.as_millis(), | ||
| )) | ||
| .build(); | ||
| ocsf_emit!(event); | ||
| break; | ||
| tokio::time::sleep(backoff).await; | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -330,13 +358,21 @@ impl ProxyHandle { | |
| Ok(Self { | ||
| http_addr: Some(local_addr), | ||
| join, | ||
| exited_rx, | ||
| }) | ||
| } | ||
|
|
||
| #[allow(dead_code)] | ||
| pub const fn http_addr(&self) -> Option<SocketAddr> { | ||
| self.http_addr | ||
| } | ||
|
|
||
| /// Take the exit notification receiver for health monitoring. | ||
| /// Resolves when the proxy accept loop task exits for any reason. | ||
| pub fn take_exit_receiver(&mut self) -> tokio::sync::oneshot::Receiver<()> { | ||
| let (_tx, dummy_rx) = tokio::sync::oneshot::channel(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Warning: Store |
||
| std::mem::replace(&mut self.exited_rx, dummy_rx) | ||
| } | ||
| } | ||
|
|
||
| impl Drop for ProxyHandle { | ||
|
|
@@ -9827,4 +9863,62 @@ network_policies: | |
| } | ||
| } | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_exit_receiver_fires_when_task_exits() { | ||
| let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Warning: These tests recreate standalone Tokio channels, so they still pass if the production exit guard or |
||
| let handle = tokio::spawn(async move { | ||
| let _guard = exited_tx; | ||
| }); | ||
| handle.await.unwrap(); | ||
| // The sender was dropped when the task completed, so the receiver | ||
| // should resolve immediately with an Err (sender dropped). | ||
| assert!(exited_rx.await.is_err()); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_exit_receiver_fires_when_task_is_aborted() { | ||
| let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); | ||
| let handle = tokio::spawn(async move { | ||
| let _guard = exited_tx; | ||
| std::future::pending::<()>().await; | ||
| }); | ||
| handle.abort(); | ||
| // Abort drops the task's locals, including the sender guard. | ||
| assert!(exited_rx.await.is_err()); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_take_exit_receiver_returns_real_receiver() { | ||
| let (tx, rx) = tokio::sync::oneshot::channel::<()>(); | ||
| let join = tokio::spawn(std::future::pending::<()>()); | ||
| let mut handle = ProxyHandle { | ||
| http_addr: None, | ||
| join, | ||
| exited_rx: rx, | ||
| }; | ||
| let mut taken = handle.take_exit_receiver(); | ||
| // Sender still alive — receiver should not be ready yet. | ||
| assert!(taken.try_recv().is_err()); | ||
| // Drop the original sender — the taken receiver should resolve. | ||
| drop(tx); | ||
| assert!(taken.await.is_err()); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_take_exit_receiver_second_call_returns_instantly() { | ||
| let (_tx, rx) = tokio::sync::oneshot::channel::<()>(); | ||
| let join = tokio::spawn(std::future::pending::<()>()); | ||
| let mut handle = ProxyHandle { | ||
| http_addr: None, | ||
| join, | ||
| exited_rx: rx, | ||
| }; | ||
| let _first = handle.take_exit_receiver(); | ||
| let second = handle.take_exit_receiver(); | ||
| // The dummy's sender was dropped inside take_exit_receiver, so | ||
| // the second receiver resolves immediately — this demonstrates | ||
| // the double-call hazard. | ||
| assert!(second.await.is_err()); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Warning: Replace numeric errno values with
libc::EMFILEandlibc::ENFILEthrough a platform-aware helper.raw_os_error()values are platform-specific, so non-Linux targets may miss FD exhaustion and use the wrong backoff and severity. Rebasing onto the final #2369 helper should address this.