chore: document intentional await-holding cases (#18423)

## Why

This PR prepares the stack to enable Clippy await-holding lints that
were left disabled in #18178. The mechanical lock-scope cleanup is
handled separately; this PR is the documentation/configuration layer for
the remaining await-across-guard sites.

Without explicit annotations, reviewers and future maintainers cannot
tell whether an await-holding warning is a real concurrency smell or an
intentional serialization boundary.

## What changed

- Configures `clippy.toml` so `await_holding_invalid_type` also covers
`tokio::sync::{MutexGuard,RwLockReadGuard,RwLockWriteGuard}`.
- Adds targeted `#[expect(clippy::await_holding_invalid_type, reason =
...)]` annotations for intentional async guard lifetimes.
- Documents the main categories of intentional cases: active-turn state
transitions that must remain atomic, session-owned MCP manager accesses,
remote-control websocket serialization, JS REPL kernel/process
serialization, OAuth persistence, external bearer token refresh
serialization, and tests that intentionally serialize shared global or
session-owned state.
- For external bearer token refresh, documents the existing
serialization boundary: holding `cached_token` across the provider
command prevents concurrent cache misses from starting duplicate refresh
commands, and the current behavior is small enough that an explicit
expectation is easier to maintain than adding another synchronization
primitive.

## Verification

- `cargo clippy -p codex-login --all-targets`
- `cargo clippy -p codex-connectors --all-targets`
- `cargo clippy -p codex-core --all-targets`
- The follow-up PR #18698 enables `await_holding_invalid_type` and
`await_holding_lock` as workspace `deny` lints, so any undocumented
remaining offender will fail Clippy.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/18423).
* #18698
* __->__ #18423
This commit is contained in:
Michael Bolin
2026-04-20 22:41:54 -07:00
committed by GitHub
Unverified
parent 4c2e730488
commit d62421d322
24 changed files with 241 additions and 0 deletions
@@ -8126,6 +8126,10 @@ impl CodexMessageProcessor {
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "listener subscription must be serialized against pending thread unloads"
)]
async fn ensure_conversation_listener_task(
listener_task_context: ListenerTaskContext,
conversation_id: ThreadId,
@@ -8933,6 +8937,10 @@ async fn handle_thread_listener_command(
}
#[allow(clippy::too_many_arguments)]
#[expect(
clippy::await_holding_invalid_type,
reason = "running-thread resume subscription must be serialized against pending unloads"
)]
async fn handle_pending_thread_resume_request(
conversation_id: ThreadId,
conversation: &Arc<CodexThread>,
@@ -505,6 +505,10 @@ where
}
#[tokio::test(flavor = "current_thread")]
#[expect(
clippy::await_holding_invalid_type,
reason = "test serializes access to global tracing state for its full duration"
)]
async fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Result<()> {
let _guard = tracing_test_guard().lock().await;
let mut harness = TracingHarness::new().await?;
@@ -584,6 +588,10 @@ async fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() ->
}
#[tokio::test(flavor = "current_thread")]
#[expect(
clippy::await_holding_invalid_type,
reason = "test serializes access to global tracing state for its full duration"
)]
async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
let _guard = tracing_test_guard().lock().await;
let mut harness = TracingHarness::new().await?;
@@ -205,6 +205,10 @@ impl RemoteControlWebsocket {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "remote-control client shutdown must serialize tracker state"
)]
pub(crate) async fn run(
mut self,
app_server_client_name_rx: Option<oneshot::Receiver<String>>,
@@ -416,6 +420,10 @@ impl RemoteControlWebsocket {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "remote-control server event receiver is shared across reconnects"
)]
async fn run_server_writer_inner(
state: Arc<Mutex<WebsocketState>>,
server_event_rx: Arc<Mutex<mpsc::Receiver<super::QueuedServerEnvelope>>>,
@@ -557,6 +565,10 @@ impl RemoteControlWebsocket {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "remote-control client tracking must stay serialized while processing inbound events"
)]
async fn run_websocket_reader_inner(
client_tracker: Arc<Mutex<ClientTracker>>,
state: Arc<Mutex<WebsocketState>>,
+5
View File
@@ -1,5 +1,10 @@
allow-expect-in-tests = true
allow-unwrap-in-tests = true
await-holding-invalid-types = [
"tokio::sync::MutexGuard",
"tokio::sync::RwLockReadGuard",
"tokio::sync::RwLockWriteGuard",
]
disallowed-methods = [
{ path = "ratatui::style::Color::Rgb", reason = "Use ANSI colors, which work better in various terminal themes." },
{ path = "ratatui::style::Color::Indexed", reason = "Use ANSI colors, which work better in various terminal themes." },
@@ -229,6 +229,10 @@ impl ResponsesWebsocketConnection {
let current_span = Span::current();
tokio::spawn(
#[expect(
clippy::await_holding_invalid_type,
reason = "the guard serializes exclusive use of the websocket stream for the lifetime of the response stream"
)]
async move {
if let Some(model) = server_model {
let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await;
+8
View File
@@ -446,6 +446,10 @@ mod tests {
}
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "test serializes access to the shared connector cache for its full duration"
)]
async fn list_all_connectors_uses_shared_cache() -> anyhow::Result<()> {
let _cache_guard = ALL_CONNECTORS_CACHE_TEST_LOCK.lock().await;
@@ -486,6 +490,10 @@ mod tests {
}
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "test serializes access to the shared connector cache for its full duration"
)]
async fn list_all_connectors_merges_and_normalizes_directory_apps() -> anyhow::Result<()> {
let _cache_guard = ALL_CONNECTORS_CACHE_TEST_LOCK.lock().await;
@@ -265,6 +265,10 @@ impl GuardianReviewSessionManager {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "review session selection and trunk spawning must stay serialized"
)]
pub(crate) async fn run_review(
&self,
params: GuardianReviewSessionParams,
+12
View File
@@ -494,6 +494,10 @@ async fn execute_mcp_tool_call(
)
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP sandbox metadata reads through the session-owned manager guard"
)]
async fn augment_mcp_tool_request_meta_with_sandbox_state(
sess: &Session,
turn_context: &TurnContext,
@@ -1067,6 +1071,10 @@ fn mcp_tool_approval_callsite_mode(
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP approval metadata reads through the session-owned manager guard"
)]
pub(crate) async fn lookup_mcp_tool_metadata(
sess: &Session,
turn_context: &TurnContext,
@@ -1149,6 +1157,10 @@ fn get_mcp_app_resource_uri(
})
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP app metadata reads through the session-owned manager guard"
)]
async fn lookup_mcp_app_usage_metadata(
sess: &Session,
server: &str,
+4
View File
@@ -468,6 +468,10 @@ pub async fn reload_user_config(sess: &Arc<Session>) {
sess.reload_user_config_layer().await;
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP tool listing reads through the session-owned manager guard"
)]
pub async fn list_mcp_tools(sess: &Session, config: &Arc<Config>, sub_id: String) {
let mcp_connection_manager = sess.services.mcp_connection_manager.read().await;
let auth = sess.services.auth_manager.auth().await;
+28
View File
@@ -1,6 +1,10 @@
use super::*;
impl Session {
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn request_mcp_server_elicitation(
&self,
turn_context: &TurnContext,
@@ -80,6 +84,10 @@ impl Session {
rx_response.await.ok()
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and manager fallback must stay serialized"
)]
pub async fn resolve_elicitation(
&self,
server_name: String,
@@ -111,6 +119,10 @@ impl Session {
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource calls are serialized through the session-owned manager guard"
)]
pub async fn list_resources(
&self,
server: &str,
@@ -124,6 +136,10 @@ impl Session {
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource calls are serialized through the session-owned manager guard"
)]
pub async fn list_resource_templates(
&self,
server: &str,
@@ -137,6 +153,10 @@ impl Session {
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource calls are serialized through the session-owned manager guard"
)]
pub async fn read_resource(
&self,
server: &str,
@@ -150,6 +170,10 @@ impl Session {
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP tool calls are serialized through the session-owned manager guard"
)]
pub async fn call_tool(
&self,
server: &str,
@@ -165,6 +189,10 @@ impl Session {
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP tool metadata reads through the session-owned manager guard"
)]
pub(crate) async fn resolve_mcp_tool_info(&self, tool_name: &ToolName) -> Option<ToolInfo> {
self.services
.mcp_connection_manager
+60
View File
@@ -1786,6 +1786,10 @@ impl Session {
/// be used to derive the available decisions via
/// [ExecApprovalRequestEvent::default_available_decisions].
#[allow(clippy::too_many_arguments)]
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn request_command_approval(
&self,
turn_context: &TurnContext,
@@ -1857,6 +1861,10 @@ impl Session {
rx_approve.await.unwrap_or(ReviewDecision::Abort)
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn request_patch_approval(
&self,
turn_context: &TurnContext,
@@ -1893,6 +1901,10 @@ impl Session {
rx_approve
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn request_permissions(
self: &Arc<Self>,
turn_context: &Arc<TurnContext>,
@@ -2023,6 +2035,10 @@ impl Session {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn request_user_input(
&self,
turn_context: &TurnContext,
@@ -2055,6 +2071,10 @@ impl Session {
rx_response.await.ok()
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn notify_user_input_response(
&self,
sub_id: &str,
@@ -2080,6 +2100,10 @@ impl Session {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn notify_request_permissions_response(
&self,
call_id: &str,
@@ -2136,6 +2160,10 @@ impl Session {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn reads must stay consistent with the matching turn state"
)]
pub(crate) async fn granted_turn_permissions(&self) -> Option<PermissionProfile> {
let active = self.active_turn.lock().await;
let active = active.as_ref()?;
@@ -2148,6 +2176,10 @@ impl Session {
state.granted_permissions()
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn notify_dynamic_tool_response(&self, call_id: &str, response: DynamicToolResponse) {
let entry = {
let mut active = self.active_turn.lock().await;
@@ -2169,6 +2201,10 @@ impl Session {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn notify_approval(&self, approval_id: &str, decision: ReviewDecision) {
let entry = {
let mut active = self.active_turn.lock().await;
@@ -2329,6 +2365,10 @@ impl Session {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP app context rendering reads through the session-owned manager guard"
)]
pub(crate) async fn build_initial_context(
&self,
turn_context: &TurnContext,
@@ -2791,6 +2831,10 @@ impl Session {
/// Inject additional user input into the currently active turn.
///
/// Returns the active turn id when accepted.
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn steer_input(
&self,
input: Vec<UserInput>,
@@ -2850,6 +2894,10 @@ impl Session {
}
/// Returns the input if there was no task running to inject into.
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn inject_response_items(
&self,
input: Vec<ResponseInputItem>,
@@ -2923,6 +2971,10 @@ impl Session {
self.mailbox_rx.lock().await.has_pending_trigger_turn()
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn prepend_pending_input(&self, input: Vec<ResponseInputItem>) -> Result<(), ()> {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
@@ -2935,6 +2987,10 @@ impl Session {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
let (pending_input, accepts_mailbox_delivery) = {
let mut active = self.active_turn.lock().await;
@@ -2990,6 +3046,10 @@ impl Session {
!self.idle_pending_input.lock().await.is_empty()
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state reads must remain atomic"
)]
pub async fn has_pending_input(&self) -> bool {
let (has_turn_pending_input, accepts_mailbox_delivery) = {
let active = self.active_turn.lock().await;
+4
View File
@@ -209,6 +209,10 @@ pub(crate) struct AppServerClientMetadata {
impl Session {
#[instrument(name = "session_init", level = "info", skip_all)]
#[allow(clippy::too_many_arguments)]
#[expect(
clippy::await_holding_invalid_type,
reason = "session initialization must serialize access through session-owned manager guards"
)]
pub(crate) async fn new(
mut session_configuration: SessionConfiguration,
config: Arc<Config>,
+4
View File
@@ -6359,6 +6359,10 @@ async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
}
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "test builds a router from session-owned MCP manager state"
)]
async fn fatal_tool_error_stops_turn_and_reports_error() {
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
let tools = {
@@ -489,6 +489,10 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message
#[tokio::test]
#[cfg(unix)]
#[expect(
clippy::await_holding_invalid_type,
reason = "test mutates active turn state directly to seed granted permissions"
)]
async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_permissions_feature() {
let (mut session, turn_context_raw) = make_session_and_context().await;
session
+8
View File
@@ -128,6 +128,10 @@ use tracing::warn;
/// - If the model sends only an assistant message, we record it in the
/// conversation history and consider the turn complete.
///
#[expect(
clippy::await_holding_invalid_type,
reason = "turn execution must keep active-turn state transitions atomic"
)]
pub(crate) async fn run_turn(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
@@ -1149,6 +1153,10 @@ async fn run_sampling_request(
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "tool router construction reads through the session-owned manager guard"
)]
pub(crate) async fn built_tools(
sess: &Session,
turn_context: &TurnContext,
+4
View File
@@ -259,6 +259,10 @@ pub(super) async fn build_enabled_tools(
collect_code_mode_tool_definitions(&specs)
}
#[expect(
clippy::await_holding_invalid_type,
reason = "nested tool router construction reads through the session-owned manager guard"
)]
async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
let nested_tools_config = exec.turn.tools_config.for_code_mode_nested_tools();
let listed_mcp_tools = exec
@@ -71,6 +71,10 @@ impl ToolHandler for DynamicToolHandler {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and dynamic tool response registration must remain atomic"
)]
async fn request_dynamic_tool(
session: &Session,
turn_context: &TurnContext,
@@ -240,6 +240,10 @@ impl ToolHandler for McpResourceHandler {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource listing reads through the session-owned manager guard"
)]
async fn handle_list_resources(
session: Arc<Session>,
turn: Arc<TurnContext>,
@@ -344,6 +348,10 @@ async fn handle_list_resources(
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource template listing reads through the session-owned manager guard"
)]
async fn handle_list_resource_templates(
session: Arc<Session>,
turn: Arc<TurnContext>,
@@ -34,6 +34,10 @@ impl ToolHandler for ToolSuggestHandler {
ToolKind::Function
}
#[expect(
clippy::await_holding_invalid_type,
reason = "tool suggestion discovery reads through the session-owned manager guard"
)]
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
payload,
@@ -193,6 +197,10 @@ async fn verify_tool_suggestion_completed(
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "connector cache refresh reads through the session-owned manager guard"
)]
async fn refresh_missing_suggested_connectors(
session: &crate::session::session::Session,
turn: &crate::session::turn_context::TurnContext,
+16
View File
@@ -849,6 +849,10 @@ impl JsReplManager {
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "js_repl kernel initialization must be serialized with kernel state"
)]
pub async fn execute_with_cancellation(
&self,
session: Arc<Session>,
@@ -1177,6 +1181,10 @@ impl JsReplManager {
Ok(kernel_path)
}
#[expect(
clippy::await_holding_invalid_type,
reason = "js_repl stdin writes must be serialized per kernel"
)]
async fn write_message(
stdin: &Arc<Mutex<ChildStdin>>,
msg: &HostToKernel,
@@ -1224,6 +1232,10 @@ impl JsReplManager {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "js_repl child shutdown must serialize process inspection and termination"
)]
async fn kill_kernel_child(child: &Arc<Mutex<Child>>, reason: &'static str) {
let mut guard = child.lock().await;
let pid = guard.id();
@@ -1547,6 +1559,10 @@ impl JsReplManager {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "nested js_repl tool routing reads through the session-owned manager guard"
)]
async fn run_tool_request(exec: ExecContext, req: RunToolRequest) -> RunToolResult {
if is_js_repl_internal_tool(&req.tool_name) {
let error = "js_repl cannot invoke itself".to_string();
+4
View File
@@ -239,6 +239,10 @@ impl ToolRegistry {
// }
// }
#[expect(
clippy::await_holding_invalid_type,
reason = "tool dispatch must keep active-turn accounting atomic"
)]
pub(crate) async fn dispatch_any(
&self,
invocation: ToolInvocation,
+12
View File
@@ -15,6 +15,10 @@ use super::ToolRouter;
use super::ToolRouterParams;
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "test builds a router from session-owned MCP manager state"
)]
async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
let (session, mut turn) = make_session_and_context().await;
turn.tools_config.js_repl_tools_only = true;
@@ -70,6 +74,10 @@ async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
}
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "test builds a router from session-owned MCP manager state"
)]
async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()> {
let (session, mut turn) = make_session_and_context().await;
turn.tools_config.js_repl_tools_only = true;
@@ -175,6 +183,10 @@ async fn js_repl_tools_only_blocks_namespaced_js_repl_tool() -> anyhow::Result<(
}
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "test builds a router from session-owned MCP manager state"
)]
async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow::Result<()> {
let (session, turn) = make_session_and_context().await;
let mcp_tools = session
@@ -33,6 +33,10 @@ impl ExternalAuth for BearerTokenRefresher {
AuthMode::ApiKey
}
#[expect(
clippy::await_holding_invalid_type,
reason = "external bearer cache misses intentionally hold cached_token across the provider command to avoid duplicate refreshes"
)]
async fn resolve(&self) -> io::Result<Option<ExternalAuthTokens>> {
let access_token = {
let mut cached = self.state.cached_token.lock().await;
+8
View File
@@ -283,6 +283,10 @@ impl OAuthPersistor {
/// Persists the latest stored credentials if they have changed.
/// Deletes the credentials if they are no longer present.
#[expect(
clippy::await_holding_invalid_type,
reason = "AuthorizationManager async access must be serialized through its mutex"
)]
pub(crate) async fn persist_if_needed(&self) -> Result<()> {
let (client_id, maybe_credentials) = {
let manager = self.inner.authorization_manager.clone();
@@ -335,6 +339,10 @@ impl OAuthPersistor {
Ok(())
}
#[expect(
clippy::await_holding_invalid_type,
reason = "AuthorizationManager async access must be serialized through its mutex"
)]
pub(crate) async fn refresh_if_needed(&self) -> Result<()> {
let expires_at = {
let guard = self.inner.last_credentials.lock().await;