From bb0117da327217181257754c8f0fd1540ef2b389 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 17:54:30 -0700 Subject: [PATCH 01/35] Add TUI IDE context command Addresses https://github.com/openai/codex/issues/13834. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 28 +- codex-rs/tui/src/bottom_pane/footer.rs | 90 ++- codex-rs/tui/src/bottom_pane/mod.rs | 9 + .../tui/src/bottom_pane/slash_commands.rs | 1 + ...ne_enabled_mode_and_ide_context_right.snap | 5 + codex-rs/tui/src/chatwidget.rs | 26 +- codex-rs/tui/src/chatwidget/ide_context.rs | 167 ++++++ codex-rs/tui/src/chatwidget/realtime.rs | 36 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 7 + .../chatwidget/tests/composer_submission.rs | 51 ++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + codex-rs/tui/src/ide_context.rs | 104 ++++ codex-rs/tui/src/ide_context/ipc.rs | 543 ++++++++++++++++++ codex-rs/tui/src/ide_context/prompt.rs | 351 +++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 19 +- 16 files changed, 1409 insertions(+), 30 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_and_ide_context_right.snap create mode 100644 codex-rs/tui/src/chatwidget/ide_context.rs create mode 100644 codex-rs/tui/src/ide_context.rs create mode 100644 codex-rs/tui/src/ide_context/ipc.rs create mode 100644 codex-rs/tui/src/ide_context/prompt.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c879743425fe..b6bb9307237f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -121,8 +121,6 @@ //! overall state machine, since it affects which transitions are even possible from a given UI //! state. //! -use crate::bottom_pane::footer::goal_status_indicator_line; -use crate::bottom_pane::footer::mode_indicator_line; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::has_ctrl_or_alt; @@ -159,6 +157,7 @@ use super::footer::FooterKeyHints; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::GoalStatusIndicator; +use super::footer::IdeContextStatusIndicator; use super::footer::SummaryLeft; use super::footer::can_show_left_with_context; use super::footer::context_window_line; @@ -176,6 +175,7 @@ use super::footer::render_footer_line; use super::footer::reset_mode_after_activity; use super::footer::side_conversation_context_line; use super::footer::single_line_footer_layout; +use super::footer::status_line_right_indicator_line; use super::footer::toggle_shortcut_mode; use super::footer::uses_passive_footer_status_layout; use super::paste_burst::CharDecision; @@ -384,6 +384,7 @@ pub(crate) struct ChatComposer { config: ChatComposerConfig, collaboration_mode_indicator: Option, goal_status_indicator: Option, + ide_context_status_indicator: Option, connectors_enabled: bool, plugins_command_enabled: bool, fast_command_enabled: bool, @@ -455,15 +456,6 @@ enum SlashValidation { const FOOTER_SPACING_HEIGHT: u16 = 0; -fn status_line_right_indicator( - collaboration_mode_indicator: Option, - goal_status_indicator: Option<&GoalStatusIndicator>, - show_cycle_hint: bool, -) -> Option> { - mode_indicator_line(collaboration_mode_indicator, show_cycle_hint) - .or_else(|| goal_status_indicator_line(goal_status_indicator)) -} - /// Builds the one-line nudge that replaces the ambient footer without adding layout height. fn plan_mode_nudge_line() -> Line<'static> { Line::from(vec![ @@ -571,6 +563,7 @@ impl ChatComposer { config, collaboration_mode_indicator: None, goal_status_indicator: None, + ide_context_status_indicator: None, connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, @@ -728,6 +721,13 @@ impl ChatComposer { self.goal_status_indicator = indicator; } + pub fn set_ide_context_status_indicator( + &mut self, + indicator: Option, + ) { + self.ide_context_status_indicator = indicator; + } + pub fn set_personality_command_enabled(&mut self, enabled: bool) { self.personality_command_enabled = enabled; } @@ -4158,14 +4158,16 @@ impl ChatComposer { } else if let Some(line) = self.shell_mode_footer_line() { Some(line) } else if status_line_active { - let full = status_line_right_indicator( + let full = status_line_right_indicator_line( self.collaboration_mode_indicator, self.goal_status_indicator.as_ref(), + self.ide_context_status_indicator.as_ref(), show_cycle_hint, ); - let compact = status_line_right_indicator( + let compact = status_line_right_indicator_line( self.collaboration_mode_indicator, self.goal_status_indicator.as_ref(), + self.ide_context_status_indicator.as_ref(), /*show_cycle_hint*/ false, ); let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index c17f8fb4f355..071971d02db9 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -104,6 +104,11 @@ pub(crate) enum GoalStatusIndicator { Complete { usage: Option }, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum IdeContextStatusIndicator { + Active, +} + const MODE_CYCLE_HINT: &str = "shift+tab to cycle"; const FOOTER_CONTEXT_GAP_COLS: u16 = 1; @@ -568,6 +573,36 @@ pub(crate) fn goal_status_indicator_line( Some(Line::from(vec![Span::from(label).magenta()])) } +pub(crate) fn status_line_right_indicator_line( + collaboration_mode_indicator: Option, + goal_status_indicator: Option<&GoalStatusIndicator>, + ide_context_indicator: Option<&IdeContextStatusIndicator>, + show_cycle_hint: bool, +) -> Option> { + let primary_indicator = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint) + .or_else(|| goal_status_indicator_line(goal_status_indicator)); + let ide_context_indicator = ide_context_indicator.map(|indicator| match indicator { + IdeContextStatusIndicator::Active => Line::from(vec!["IDE context".cyan()]), + }); + let mut line: Option> = None; + + for indicator in [primary_indicator, ide_context_indicator] + .into_iter() + .flatten() + { + if let Some(line) = line.as_mut() { + line.push_span(" · ".dim()); + for span in indicator.spans { + line.push_span(span); + } + } else { + line = Some(indicator); + } + } + + line +} + pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> { if let Some(rest) = label.strip_prefix("Side ") { Line::from(vec!["Side".magenta().bold(), format!(" {rest}").magenta()]) @@ -1248,6 +1283,7 @@ mod tests { height: u16, props: &FooterProps, collaboration_mode_indicator: Option, + ide_context_indicator: Option, ) { terminal .draw(|f| { @@ -1308,9 +1344,16 @@ mod tests { ) }; let right_line = if status_line_active { - let full = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint); - let compact = mode_indicator_line( + let full = status_line_right_indicator_line( + collaboration_mode_indicator, + /*goal_status_indicator*/ None, + ide_context_indicator.as_ref(), + show_cycle_hint, + ); + let compact = status_line_right_indicator_line( collaboration_mode_indicator, + /*goal_status_indicator*/ None, + ide_context_indicator.as_ref(), /*show_cycle_hint*/ false, ); let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0); @@ -1420,7 +1463,13 @@ mod tests { ) { let height = footer_height(props).max(1); let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator); + draw_footer_frame( + &mut terminal, + height, + props, + collaboration_mode_indicator, + /*ide_context_indicator*/ None, + ); assert_snapshot!(name, terminal.backend()); } @@ -1431,10 +1480,35 @@ mod tests { ) -> String { let height = footer_height(props).max(1); let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal"); - draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator); + draw_footer_frame( + &mut terminal, + height, + props, + collaboration_mode_indicator, + /*ide_context_indicator*/ None, + ); terminal.backend().vt100().screen().contents() } + fn snapshot_footer_with_indicators( + name: &str, + width: u16, + props: &FooterProps, + collaboration_mode_indicator: Option, + ide_context_indicator: Option, + ) { + let height = footer_height(props).max(1); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + draw_footer_frame( + &mut terminal, + height, + props, + collaboration_mode_indicator, + ide_context_indicator, + ); + assert_snapshot!(name, terminal.backend()); + } + #[test] fn footer_snapshots() { snapshot_footer( @@ -1760,6 +1834,14 @@ mod tests { Some(CollaborationModeIndicator::Plan), ); + snapshot_footer_with_indicators( + "footer_status_line_enabled_mode_and_ide_context_right", + /*width*/ 120, + &props, + Some(CollaborationModeIndicator::Plan), + Some(IdeContextStatusIndicator::Active), + ); + let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 02275755b78b..2f80ad15e8d4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -97,6 +97,7 @@ mod skills_toggle_view; pub(crate) mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use footer::GoalStatusIndicator; +pub(crate) use footer::IdeContextStatusIndicator; #[cfg(test)] pub(crate) use footer::goal_status_indicator_line; pub(crate) use list_selection_view::ColumnWidthMode; @@ -376,6 +377,14 @@ impl BottomPane { self.request_redraw(); } + pub fn set_ide_context_status_indicator( + &mut self, + indicator: Option, + ) { + self.composer.set_ide_context_status_indicator(indicator); + self.request_redraw(); + } + pub fn set_personality_command_enabled(&mut self, enabled: bool) { self.composer.set_personality_command_enabled(enabled); self.request_redraw(); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index f75d759d5e4c..9f2c33fbecae 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -165,6 +165,7 @@ mod tests { assert_eq!( commands, vec![ + SlashCommand::Ide, SlashCommand::Copy, SlashCommand::Diff, SlashCommand::Mention, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_and_ide_context_right.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_and_ide_context_right.snap new file mode 100644 index 000000000000..1e340ddc823e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_and_ide_context_right.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Plan mode (shift+tab to cycle) · IDE context " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index cdd31e5329bb..51b2fa4795fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -385,6 +385,8 @@ use self::goal_status::GoalStatusState; #[cfg(test)] use self::goal_status::goal_status_indicator_from_app_goal; mod goal_menu; +mod ide_context; +use self::ide_context::IdeContextState; mod interrupts; mod mcp_startup; use self::interrupts::InterruptManager; @@ -899,6 +901,7 @@ pub(crate) struct ChatWidget { connectors_partial_snapshot: Option, connectors_prefetch_in_flight: bool, connectors_force_refetch_pending: bool, + ide_context: IdeContextState, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, plugin_install_apps_needing_auth: Vec, @@ -5354,6 +5357,7 @@ impl ChatWidget { connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + ide_context: IdeContextState::default(), plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), plugin_install_apps_needing_auth: Vec::new(), @@ -6213,6 +6217,9 @@ impl ChatWidget { )); return (false, None); } + + self.maybe_apply_ide_context(&mut items); + let collaboration_mode = if self.collaboration_modes_enabled() { self.active_collaboration_mask .as_ref() @@ -7770,19 +7777,18 @@ impl ChatWidget { } fn on_user_message_event(&mut self, event: UserMessageEvent) { - self.last_rendered_user_message_event = - Some(Self::rendered_user_message_event_from_event(&event)); - let remote_image_urls = event.images.unwrap_or_default(); - if !event.message.trim().is_empty() - || !event.text_elements.is_empty() - || !remote_image_urls.is_empty() + let rendered = Self::rendered_user_message_event_from_event(&event); + self.last_rendered_user_message_event = Some(rendered.clone()); + if !rendered.message.trim().is_empty() + || !rendered.text_elements.is_empty() + || !rendered.remote_image_urls.is_empty() { self.record_visible_user_turn_for_copy(); self.add_to_history(history_cell::new_user_prompt( - event.message, - event.text_elements, - event.local_images, - remote_image_urls, + rendered.message, + rendered.text_elements, + rendered.local_images, + rendered.remote_image_urls, )); } diff --git a/codex-rs/tui/src/chatwidget/ide_context.rs b/codex-rs/tui/src/chatwidget/ide_context.rs new file mode 100644 index 000000000000..12edfc47f8a3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/ide_context.rs @@ -0,0 +1,167 @@ +//! Chat-widget wiring for the `/ide` command and IDE context prompt injection. + +use std::time::Duration; +use std::time::Instant; + +use codex_protocol::user_input::UserInput; + +use super::ChatWidget; +use crate::bottom_pane::IdeContextStatusIndicator; + +const IDE_CONTEXT_RECENT_TOGGLE_RETRY_WINDOW: Duration = Duration::from_secs(5); +const IDE_CONTEXT_RECENT_TOGGLE_RETRY_DELAY: Duration = Duration::from_millis(250); +const IDE_CONTEXT_RECENT_TOGGLE_RETRY_ATTEMPTS: usize = 12; + +#[derive(Default)] +pub(super) struct IdeContextState { + enabled: bool, + last_disabled_at: Option, +} + +impl IdeContextState { + pub(super) fn is_enabled(&self) -> bool { + self.enabled + } + + fn enable(&mut self) { + self.enabled = true; + } + + fn disable(&mut self) { + self.enabled = false; + self.last_disabled_at = Some(Instant::now()); + } + + fn mark_available(&mut self) { + self.last_disabled_at = None; + } + + fn status_indicator(&self) -> Option { + if !self.enabled { + return None; + } + + Some(IdeContextStatusIndicator::Active) + } + + fn should_retry_recent_toggle(&self) -> bool { + self.last_disabled_at.is_some_and(|disabled_at| { + disabled_at.elapsed() <= IDE_CONTEXT_RECENT_TOGGLE_RETRY_WINDOW + }) + } +} + +impl ChatWidget { + pub(super) fn handle_ide_command(&mut self) { + if self.ide_context.is_enabled() { + self.ide_context.disable(); + self.sync_ide_context_status_indicator(); + self.add_info_message("IDE context is off.".to_string(), /*hint*/ None); + } else { + self.ide_context.enable(); + self.add_ide_context_status_message(); + } + } + + pub(super) fn handle_ide_command_args(&mut self, args: &str) { + match args.to_ascii_lowercase().as_str() { + "" => self.handle_ide_command(), + "on" => { + self.ide_context.enable(); + self.add_ide_context_status_message(); + } + "off" => { + self.ide_context.disable(); + self.sync_ide_context_status_indicator(); + self.add_info_message("IDE context is off.".to_string(), /*hint*/ None); + } + "status" => { + self.add_ide_context_status_message(); + } + _ => { + self.add_error_message("Usage: /ide [on|off|status]".to_string()); + } + } + } + + /// Fetches fresh IDE context for the outgoing user turn and folds it into the prompt. + pub(super) fn maybe_apply_ide_context(&mut self, items: &mut Vec) { + if !self.ide_context.is_enabled() { + return; + } + + match crate::ide_context::fetch_ide_context(&self.config.cwd) { + Ok(context) => { + self.ide_context.mark_available(); + self.sync_ide_context_status_indicator(); + crate::ide_context::apply_ide_context_to_user_input(&context, items); + } + Err(err) => { + self.ide_context.disable(); + self.sync_ide_context_status_indicator(); + self.add_info_message( + "IDE context was turned off because Codex could not fetch IDE context." + .to_string(), + Some(err.user_facing_hint()), + ); + } + } + } + + fn add_ide_context_status_message(&mut self) { + if !self.ide_context.is_enabled() { + self.sync_ide_context_status_indicator(); + self.add_info_message("IDE context is off.".to_string(), /*hint*/ None); + return; + } + + let mut fetch_result = crate::ide_context::fetch_ide_context(&self.config.cwd); + if self.ide_context.should_retry_recent_toggle() { + // The previous short-lived IDE context connection may still be winding down. + for _ in 0..IDE_CONTEXT_RECENT_TOGGLE_RETRY_ATTEMPTS { + if !matches!( + fetch_result, + Err(ref err) if err.is_retryable_after_recent_toggle() + ) { + break; + } + std::thread::sleep(IDE_CONTEXT_RECENT_TOGGLE_RETRY_DELAY); + fetch_result = crate::ide_context::fetch_ide_context(&self.config.cwd); + } + } + + match fetch_result { + Ok(context) => { + self.ide_context.mark_available(); + self.sync_ide_context_status_indicator(); + if crate::ide_context::has_prompt_context(&context) { + self.add_info_message( + "IDE context is on.".to_string(), + Some( + "Future messages will include your current IDE selection and open tabs." + .to_string(), + ), + ); + } else { + self.add_info_message( + "IDE context is on.".to_string(), + Some("Connected to your IDE.".to_string()), + ); + } + } + Err(err) => { + self.ide_context.disable(); + self.sync_ide_context_status_indicator(); + self.add_info_message( + "IDE context could not be enabled.".to_string(), + Some(err.user_facing_hint()), + ); + } + } + } + + pub(super) fn sync_ide_context_status_indicator(&mut self) { + self.bottom_pane + .set_ide_context_status_indicator(self.ide_context.status_indicator()); + } +} diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index bfeaff2eae3b..e059f265a359 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::RealtimeOutputModality; +use codex_protocol::user_input::ByteRange; use codex_realtime_webrtc::RealtimeWebrtcEvent; use codex_realtime_webrtc::RealtimeWebrtcSession; use codex_realtime_webrtc::RealtimeWebrtcSessionHandle; @@ -80,6 +81,31 @@ pub(super) struct PendingSteerCompareKey { pub(super) image_count: usize, } +fn rebase_text_elements_for_prompt_request( + text_elements: &[TextElement], + prompt_request_offset: usize, + prompt_request_len: usize, +) -> Vec { + // Prompt context is folded into the raw user message for the agent, but the transcript shows + // only the user's request. Keep elements inside that visible request and shift their byte + // ranges so mentions/images still line up with the rendered text. + let prompt_request_end = prompt_request_offset + prompt_request_len; + text_elements + .iter() + .filter_map(|element| { + let range = element.byte_range; + if range.start < prompt_request_offset || range.end > prompt_request_end { + return None; + } + + Some(element.map_range(|range| ByteRange { + start: range.start - prompt_request_offset, + end: range.end - prompt_request_offset, + })) + }) + .collect() +} + impl ChatWidget { pub(super) fn rendered_user_message_event_from_parts( message: String, @@ -98,9 +124,15 @@ impl ChatWidget { pub(super) fn rendered_user_message_event_from_event( event: &UserMessageEvent, ) -> RenderedUserMessageEvent { + let (message, prompt_request_offset) = + crate::ide_context::extract_prompt_request_with_offset(&event.message); Self::rendered_user_message_event_from_parts( - event.message.clone(), - event.text_elements.clone(), + message.to_string(), + rebase_text_elements_for_prompt_request( + &event.text_elements, + prompt_request_offset, + message.len(), + ), event.local_images.clone(), event.images.clone().unwrap_or_default(), ) diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 82f366fbb71d..1a991ed37496 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -360,6 +360,9 @@ impl ChatWidget { ); } } + SlashCommand::Ide => { + self.handle_ide_command(); + } SlashCommand::DebugConfig => { self.add_debug_config_output(); } @@ -569,6 +572,9 @@ impl ChatWidget { } } } + SlashCommand::Ide => { + self.handle_ide_command_args(trimmed); + } SlashCommand::Mcp => match trimmed.to_ascii_lowercase().as_str() { "verbose" => self.add_mcp_output(McpServerStatusDetail::Full), _ => self.add_error_message("Usage: /mcp [verbose]".to_string()), @@ -833,6 +839,7 @@ impl ChatWidget { } match cmd { SlashCommand::Fast + | SlashCommand::Ide | SlashCommand::Status | SlashCommand::DebugConfig | SlashCommand::Ps diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index e2ac788da28e..8674734a5871 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1170,6 +1170,57 @@ fn rendered_user_message_event_from_inputs_matches_flattened_user_message_shape( ); } +#[test] +fn rendered_user_message_event_from_event_hides_prompt_context() { + let raw_message = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\nAsk $figma"; + let mention_start = raw_message.find("$figma").expect("mention in raw message"); + let rendered = ChatWidget::rendered_user_message_event_from_event( + &codex_protocol::protocol::UserMessageEvent { + message: raw_message.to_string(), + images: None, + local_images: Vec::new(), + text_elements: vec![TextElement::new( + (mention_start..mention_start + "$figma".len()).into(), + Some("$figma".to_string()), + )], + }, + ); + + assert_eq!( + rendered, + ChatWidget::rendered_user_message_event_from_parts( + "Ask $figma".to_string(), + vec![TextElement::new((4..10).into(), Some("$figma".to_string()))], + Vec::new(), + Vec::new(), + ) + ); +} + +#[tokio::test] +async fn committed_user_message_hides_prompt_context_in_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let raw_message = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\nAsk Codex"; + + complete_user_message(&mut chat, "user-1", raw_message); + + let mut user_message = None; + let mut rendered_cell = None; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_message = Some(cell.message.clone()); + rendered_cell = Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))); + break; + } + } + + assert_eq!(user_message.as_deref(), Some("Ask Codex")); + let rendered_cell = rendered_cell.expect("rendered user history cell"); + assert_snapshot!(rendered_cell.trim(), @"› Ask Codex"); +} + #[tokio::test] async fn interrupt_restores_queued_messages_into_composer() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 015340bbb2a5..5c7bbbc85ec9 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -241,6 +241,7 @@ pub(super) async fn make_chatwidget_manual( newly_installed_marketplace_tab_id: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, + ide_context: super::super::ide_context::IdeContextState::default(), plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), diff --git a/codex-rs/tui/src/ide_context.rs b/codex-rs/tui/src/ide_context.rs new file mode 100644 index 000000000000..486efa7c0df1 --- /dev/null +++ b/codex-rs/tui/src/ide_context.rs @@ -0,0 +1,104 @@ +//! IDE context data model and public helpers for TUI `/ide` support. + +mod ipc; +mod prompt; + +pub(crate) use ipc::fetch_ide_context; +pub(crate) use prompt::apply_ide_context_to_user_input; +pub(crate) use prompt::extract_prompt_request_with_offset; +pub(crate) use prompt::has_prompt_context; + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IdeContext { + pub(crate) active_file: Option, + #[serde(default)] + pub(crate) open_tabs: Vec, + #[serde(default)] + pub(crate) process_env: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ActiveFile { + #[serde(flatten)] + pub(crate) descriptor: FileDescriptor, + pub(crate) selection: Range, + #[serde(default)] + pub(crate) active_selection_content: String, + #[serde(default)] + pub(crate) selections: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FileDescriptor { + pub(crate) label: String, + pub(crate) path: String, + #[serde(rename = "fsPath")] + pub(crate) fs_path: String, + #[serde(default)] + pub(crate) start_line: Option, + #[serde(default)] + pub(crate) end_line: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub(crate) struct Range { + pub(crate) start: Position, + pub(crate) end: Position, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub(crate) struct Position { + pub(crate) line: u32, + pub(crate) character: u32, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub(crate) struct IdeProcessEnv { + pub(crate) path: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn deserializes_existing_ide_context_shape() { + let value = json!({ + "activeFile": { + "label": "lib.rs", + "path": "src/lib.rs", + "fsPath": "/repo/src/lib.rs", + "selection": { + "start": { "line": 1, "character": 2 }, + "end": { "line": 3, "character": 4 } + }, + "activeSelectionContent": "selected", + "selections": [] + }, + "openTabs": [ + { + "label": "main.rs", + "path": "src/main.rs", + "fsPath": "/repo/src/main.rs" + } + ] + }); + + let context: IdeContext = serde_json::from_value(value).expect("deserialize ide context"); + assert_eq!( + context + .active_file + .as_ref() + .map(|file| file.descriptor.path.as_str()), + Some("src/lib.rs") + ); + assert_eq!(context.open_tabs.len(), 1); + } +} diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs new file mode 100644 index 000000000000..9e22910436ec --- /dev/null +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -0,0 +1,543 @@ +//! Private transport for fetching IDE context for TUI `/ide` support. + +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +#[cfg(any(unix, windows))] +use serde_json::Value; +#[cfg(any(unix, windows, test))] +use serde_json::json; +use thiserror::Error; + +use super::IdeContext; + +// The desktop integration can take several seconds to determine whether an IDE can answer a +// request. Keep this long enough that transient local read timeouts do not become user-visible. +const IDE_CONTEXT_REQUEST_TIMEOUT: Duration = Duration::from_secs(6); +// Prompt rendering applies its own smaller cap to selected text before injection. +#[cfg(any(unix, windows))] +const MAX_IPC_FRAME_BYTES: usize = 256 * 1024 * 1024; + +#[derive(Debug, Error)] +pub(crate) enum IdeContextError { + #[cfg(any(unix, windows))] + #[error("failed to connect to IDE context provider: {0}")] + Connect(std::io::Error), + #[cfg(any(unix, windows))] + #[error("failed to request IDE context: {0}")] + Send(std::io::Error), + #[cfg(any(unix, windows))] + #[error("failed to read IDE context: {0}")] + Read(std::io::Error), + #[cfg(any(unix, windows))] + #[error("invalid IDE context response: {0}")] + InvalidResponse(String), + #[cfg(any(unix, windows))] + #[error("IDE context response exceeded maximum size")] + ResponseTooLarge, + #[cfg(any(unix, windows))] + #[error("IDE context request failed")] + RequestFailed(String), + #[cfg(not(any(unix, windows)))] + #[error("IDE context is not supported on this platform")] + UnsupportedPlatform, +} + +impl IdeContextError { + /// Returns true for short-lived states that can appear just after the TUI disconnects. + #[cfg(any(unix, windows))] + pub(crate) fn is_retryable_after_recent_toggle(&self) -> bool { + match self { + IdeContextError::RequestFailed(error) => matches!( + error.as_str(), + "no-client-found" | "client-disconnected" | "request-timeout" + ), + IdeContextError::Read(error) => { + matches!( + error.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) + } + IdeContextError::Connect(_) + | IdeContextError::Send(_) + | IdeContextError::InvalidResponse(_) + | IdeContextError::ResponseTooLarge => false, + } + } + + #[cfg(any(unix, windows))] + pub(crate) fn user_facing_hint(&self) -> String { + match self { + IdeContextError::Connect(_) => { + "Open this project in VS Code or Cursor with the Codex extension active." + .to_string() + } + IdeContextError::RequestFailed(error) if error == "no-client-found" => { + "Open this project in VS Code or Cursor with the Codex extension active." + .to_string() + } + IdeContextError::RequestFailed(_) => { + "The IDE extension did not provide context. Try /ide again.".to_string() + } + IdeContextError::ResponseTooLarge => { + "The selected IDE context is too large. Clear any large selection in your IDE and try /ide again.".to_string() + } + IdeContextError::Send(_) => { + "Codex could not request IDE context. Try /ide again.".to_string() + } + IdeContextError::Read(_) | IdeContextError::InvalidResponse(_) => { + "Codex could not read IDE context. Try /ide again.".to_string() + } + } + } + + #[cfg(not(any(unix, windows)))] + pub(crate) fn is_retryable_after_recent_toggle(&self) -> bool { + false + } + + #[cfg(not(any(unix, windows)))] + pub(crate) fn user_facing_hint(&self) -> String { + self.to_string() + } +} + +pub(crate) fn fetch_ide_context(workspace_root: &Path) -> Result { + fetch_ide_context_from_socket( + default_ipc_socket_path(), + workspace_root, + IDE_CONTEXT_REQUEST_TIMEOUT, + ) +} + +#[cfg(unix)] +fn default_ipc_socket_path() -> PathBuf { + let uid = unsafe { libc::getuid() }; + std::env::temp_dir() + .join("codex-ipc") + .join(format!("ipc-{uid}.sock")) +} + +#[cfg(windows)] +fn default_ipc_socket_path() -> PathBuf { + PathBuf::from(r"\\.\pipe\codex-ipc") +} + +#[cfg(not(any(unix, windows)))] +fn default_ipc_socket_path() -> PathBuf { + PathBuf::new() +} + +#[cfg(unix)] +pub(crate) fn fetch_ide_context_from_socket( + socket_path: PathBuf, + workspace_root: &Path, + timeout: Duration, +) -> Result { + use std::os::unix::net::UnixStream; + + let mut stream = UnixStream::connect(socket_path).map_err(IdeContextError::Connect)?; + stream + .set_read_timeout(Some(timeout)) + .map_err(IdeContextError::Read)?; + stream + .set_write_timeout(Some(timeout)) + .map_err(IdeContextError::Send)?; + + fetch_ide_context_from_stream(&mut stream, workspace_root) +} + +#[cfg(windows)] +pub(crate) fn fetch_ide_context_from_socket( + socket_path: PathBuf, + workspace_root: &Path, + _timeout: Duration, +) -> Result { + let mut stream = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(socket_path) + .map_err(IdeContextError::Connect)?; + + fetch_ide_context_from_stream(&mut stream, workspace_root) +} + +#[cfg(any(unix, windows))] +fn initialize_client( + stream: &mut T, +) -> Result { + let initialize_request_id = uuid::Uuid::new_v4().to_string(); + let initialize_request = json!({ + "type": "request", + "requestId": initialize_request_id.clone(), + "sourceClientId": "initializing-client", + "version": 0, + "method": "initialize", + "params": { + // Match the desktop client type so the current IDE extension can handle us unchanged. + "clientType": "desktop", + }, + }); + write_frame(stream, &initialize_request).map_err(IdeContextError::Send)?; + let initialize_response = read_response_frame(stream, &initialize_request_id)?; + extract_client_id(&initialize_response) +} + +#[cfg(any(unix, windows))] +fn fetch_ide_context_with_client_id( + stream: &mut T, + client_id: &str, + workspace_root: &Path, +) -> Result { + let ide_context_request_id = uuid::Uuid::new_v4().to_string(); + let ide_context_request = json!({ + "type": "request", + "requestId": ide_context_request_id.clone(), + "sourceClientId": client_id, + "version": 0, + "method": "ide-context", + "params": { + "workspaceRoot": workspace_root.to_string_lossy(), + }, + }); + write_frame(stream, &ide_context_request).map_err(IdeContextError::Send)?; + let ide_context_response = read_response_frame(stream, &ide_context_request_id)?; + extract_ide_context(ide_context_response) +} + +#[cfg(any(unix, windows))] +fn fetch_ide_context_from_stream( + stream: &mut T, + workspace_root: &Path, +) -> Result { + let client_id = initialize_client(stream)?; + fetch_ide_context_with_client_id(stream, &client_id, workspace_root) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn fetch_ide_context_from_socket( + _socket_path: PathBuf, + _workspace_root: &Path, + _timeout: Duration, +) -> Result { + Err(IdeContextError::UnsupportedPlatform) +} + +#[cfg(any(unix, windows))] +fn write_frame(stream: &mut T, message: &Value) -> std::io::Result<()> { + let payload = serde_json::to_vec(message).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid IDE context JSON message: {err}"), + ) + })?; + let payload_len = u32::try_from(payload.len()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IDE context payload exceeds u32 length", + ) + })?; + stream.write_all(&payload_len.to_le_bytes())?; + stream.write_all(&payload)?; + stream.flush() +} + +#[cfg(any(unix, windows))] +fn read_frame(stream: &mut T) -> Result { + let mut len_bytes = [0_u8; 4]; + stream + .read_exact(&mut len_bytes) + .map_err(IdeContextError::Read)?; + let len = u32::from_le_bytes(len_bytes) as usize; + if len > MAX_IPC_FRAME_BYTES { + return Err(IdeContextError::ResponseTooLarge); + } + + let mut payload = vec![0_u8; len]; + stream + .read_exact(&mut payload) + .map_err(IdeContextError::Read)?; + serde_json::from_slice(&payload) + .map_err(|err| IdeContextError::InvalidResponse(format!("invalid JSON payload: {err}"))) +} + +#[cfg(any(unix, windows))] +fn read_response_frame( + stream: &mut T, + request_id: &str, +) -> Result { + loop { + let message = read_frame(stream)?; + match message.get("type").and_then(Value::as_str) { + Some("response") => { + if message.get("requestId").and_then(Value::as_str) == Some(request_id) { + return Ok(message); + } + } + Some("broadcast") => {} + Some("client-discovery-request") => { + if let Some(discovery_request_id) = message.get("requestId").and_then(Value::as_str) + { + let response = json!({ + "type": "client-discovery-response", + "requestId": discovery_request_id, + "response": { + "canHandle": false, + }, + }); + write_frame(stream, &response).map_err(IdeContextError::Send)?; + } + } + Some("client-discovery-response") | Some("request") => {} + Some(other) => { + return Err(IdeContextError::InvalidResponse(format!( + "unexpected IDE context message type: {other}" + ))); + } + None => { + return Err(IdeContextError::InvalidResponse( + "IDE context message did not include a type".to_string(), + )); + } + } + } +} + +#[cfg(any(unix, windows))] +fn extract_client_id(response: &Value) -> Result { + ensure_success_response(response)?; + response + .get("result") + .and_then(|result| result.get("clientId")) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| { + IdeContextError::InvalidResponse( + "initialize response did not include result.clientId".to_string(), + ) + }) +} + +#[cfg(any(unix, windows))] +fn extract_ide_context(response: Value) -> Result { + ensure_success_response(&response)?; + let ide_context = response + .get("result") + .and_then(|result| result.get("ideContext")) + .cloned() + .ok_or_else(|| { + IdeContextError::InvalidResponse( + "ide-context response did not include result.ideContext".to_string(), + ) + })?; + serde_json::from_value(ide_context) + .map_err(|err| IdeContextError::InvalidResponse(err.to_string())) +} + +#[cfg(any(unix, windows))] +fn ensure_success_response(response: &Value) -> Result<(), IdeContextError> { + match response.get("resultType").and_then(Value::as_str) { + Some("success") => Ok(()), + Some("error") => Err(IdeContextError::RequestFailed( + response + .get("error") + .and_then(Value::as_str) + .unwrap_or("unknown error") + .to_string(), + )), + _ => Err(IdeContextError::InvalidResponse( + "response did not include a success or error resultType".to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[cfg(any(unix, windows))] + #[test] + fn retryable_after_recent_toggle_covers_transient_errors() { + assert!( + IdeContextError::RequestFailed("no-client-found".to_string()) + .is_retryable_after_recent_toggle() + ); + assert!( + IdeContextError::RequestFailed("client-disconnected".to_string()) + .is_retryable_after_recent_toggle() + ); + assert!( + IdeContextError::RequestFailed("request-timeout".to_string()) + .is_retryable_after_recent_toggle() + ); + assert!( + IdeContextError::Read(std::io::Error::from(std::io::ErrorKind::TimedOut)) + .is_retryable_after_recent_toggle() + ); + assert!( + IdeContextError::Read(std::io::Error::from(std::io::ErrorKind::WouldBlock)) + .is_retryable_after_recent_toggle() + ); + assert!( + !IdeContextError::RequestFailed("other-error".to_string()) + .is_retryable_after_recent_toggle() + ); + assert!( + !IdeContextError::InvalidResponse("bad payload".to_string()) + .is_retryable_after_recent_toggle() + ); + assert!(!IdeContextError::ResponseTooLarge.is_retryable_after_recent_toggle()); + } + + #[cfg(unix)] + #[test] + fn fetch_ide_context_handles_interleaved_messages() { + use std::os::unix::net::UnixListener; + use std::thread; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("codex-ipc.sock"); + let listener = UnixListener::bind(&socket_path).expect("bind socket"); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + + let initialize = read_frame(&mut stream).expect("read initialize"); + assert_eq!( + initialize.get("method").and_then(Value::as_str), + Some("initialize") + ); + assert_eq!( + initialize + .get("params") + .and_then(|params| params.get("clientType")) + .and_then(Value::as_str), + Some("desktop") + ); + let initialize_request_id = initialize + .get("requestId") + .and_then(Value::as_str) + .expect("initialize request id"); + write_frame( + &mut stream, + &json!({ + "type": "response", + "requestId": initialize_request_id, + "resultType": "success", + "method": "initialize", + "handledByClientId": "server", + "result": { + "clientId": "rust-client" + } + }), + ) + .expect("write initialize response"); + + let ide_context = read_frame(&mut stream).expect("read ide-context"); + assert_eq!( + ide_context.get("method").and_then(Value::as_str), + Some("ide-context") + ); + assert_eq!( + ide_context.get("sourceClientId").and_then(Value::as_str), + Some("rust-client") + ); + assert_eq!( + ide_context + .get("params") + .and_then(|params| params.get("workspaceRoot")) + .and_then(Value::as_str), + Some("/repo") + ); + let ide_context_request_id = ide_context + .get("requestId") + .and_then(Value::as_str) + .expect("ide-context request id"); + write_frame( + &mut stream, + &json!({ + "type": "broadcast", + "method": "client-status-changed", + "sourceClientId": "vscode-client", + "version": 0, + "params": { + "clientId": "vscode-client", + "clientType": "vscode", + "status": "connected" + } + }), + ) + .expect("write broadcast before ide-context response"); + + write_frame( + &mut stream, + &json!({ + "type": "client-discovery-request", + "requestId": "discovery-request", + "request": ide_context.clone(), + }), + ) + .expect("write client discovery request"); + let discovery_response = + read_frame(&mut stream).expect("read client discovery response"); + assert_eq!( + discovery_response.get("type").and_then(Value::as_str), + Some("client-discovery-response") + ); + assert_eq!( + discovery_response.get("requestId").and_then(Value::as_str), + Some("discovery-request") + ); + assert_eq!( + discovery_response + .get("response") + .and_then(|response| response.get("canHandle")) + .and_then(Value::as_bool), + Some(false) + ); + + write_frame( + &mut stream, + &json!({ + "type": "response", + "requestId": ide_context_request_id, + "resultType": "success", + "method": "ide-context", + "handledByClientId": "vscode-client", + "result": { + "ideContext": { + "activeFile": { + "label": "lib.rs", + "path": "src/lib.rs", + "fsPath": "/repo/src/lib.rs", + "selection": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 3 } + }, + "activeSelectionContent": "use", + "selections": [] + }, + "openTabs": [] + } + } + }), + ) + .expect("write ide-context response"); + }); + + let context = + fetch_ide_context_from_socket(socket_path, Path::new("/repo"), Duration::from_secs(1)) + .expect("fetch ide context"); + + server.join().expect("server joins"); + assert_eq!( + context + .active_file + .as_ref() + .map(|file| file.active_selection_content.as_str()), + Some("use") + ); + } +} diff --git a/codex-rs/tui/src/ide_context/prompt.rs b/codex-rs/tui/src/ide_context/prompt.rs new file mode 100644 index 000000000000..b1f4ab02584b --- /dev/null +++ b/codex-rs/tui/src/ide_context/prompt.rs @@ -0,0 +1,351 @@ +//! Prompt rendering for IDE context injected into TUI user turns. + +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; +use codex_protocol::user_input::UserInput; + +use super::IdeContext; + +const MAX_ACTIVE_SELECTION_CHARS: usize = 200_000; +const PROMPT_REQUEST_BEGIN: &str = "## My request for Codex:"; + +pub(crate) fn apply_ide_context_to_user_input( + context: &IdeContext, + items: &mut Vec, +) -> bool { + let Some(context_text) = render_prompt_context(context) else { + return false; + }; + + let prefix = format!("{context_text}\n{PROMPT_REQUEST_BEGIN}\n"); + if let Some(text_index) = items + .iter() + .position(|item| matches!(item, UserInput::Text { .. })) + { + let item = items.remove(text_index); + let UserInput::Text { + text, + text_elements, + } = item + else { + unreachable!("position matched a text item"); + }; + items.insert(0, prefixed_text_input(prefix, text, text_elements)); + } else { + items.insert( + 0, + UserInput::Text { + text: prefix, + text_elements: Vec::new(), + }, + ); + } + + true +} + +pub(crate) fn has_prompt_context(context: &IdeContext) -> bool { + render_prompt_context(context).is_some() +} + +pub(crate) fn extract_prompt_request_with_offset(message: &str) -> (&str, usize) { + let Some((before_request, request)) = message.rsplit_once(PROMPT_REQUEST_BEGIN) else { + return (message, 0); + }; + + let request_start = before_request.len() + PROMPT_REQUEST_BEGIN.len(); + let trimmed_request = request.trim(); + let leading_trimmed_len = request.len() - request.trim_start().len(); + (trimmed_request, request_start + leading_trimmed_len) +} + +fn prefixed_text_input(prefix: String, text: String, text_elements: Vec) -> UserInput { + let prefix_len = prefix.len(); + UserInput::Text { + text: format!("{prefix}{text}"), + text_elements: text_elements + .into_iter() + .map(|element| { + element.map_range(|range| ByteRange { + start: range.start + prefix_len, + end: range.end + prefix_len, + }) + }) + .collect(), + } +} + +fn render_prompt_context(context: &IdeContext) -> Option { + let mut ide_context_section = String::new(); + + if let Some(active_file) = &context.active_file { + ide_context_section.push_str(&format!( + "\n## Active file: {}\n", + active_file.descriptor.path + )); + } + + if let Some(active_file) = &context.active_file { + let selected_ranges = if active_file.selections.is_empty() { + std::slice::from_ref(&active_file.selection) + } else { + active_file.selections.as_slice() + } + .iter() + .filter(|range| range.start != range.end) + .collect::>(); + + if !selected_ranges.is_empty() + && (active_file.active_selection_content.is_empty() || selected_ranges.len() > 1) + { + if selected_ranges.len() == 1 { + ide_context_section.push_str("\n## Active selection range:\n"); + } else { + ide_context_section.push_str("\n## Active selection ranges:\n"); + } + for range in selected_ranges { + // Render ranges as 1-based positions for the prompt. + let start_line = range.start.line + 1; + let start_column = range.start.character + 1; + let end_line = range.end.line + 1; + let end_column = range.end.character + 1; + ide_context_section.push_str(&format!( + "- {}: line {start_line}, column {start_column} to line {end_line}, column {end_column}\n", + active_file.descriptor.path + )); + } + } + } + + if let Some(active_file) = &context.active_file + && !active_file.active_selection_content.is_empty() + { + ide_context_section.push_str("\n## Active selection of the file:\n"); + let selection = active_file.active_selection_content.as_str(); + if let Some((truncate_at, _)) = selection.char_indices().nth(MAX_ACTIVE_SELECTION_CHARS) { + ide_context_section.push_str(&selection[..truncate_at]); + ide_context_section.push_str(&format!( + "\n[Selection truncated to {MAX_ACTIVE_SELECTION_CHARS} characters.]\n" + )); + } else { + ide_context_section.push_str(selection); + } + } + + if !context.open_tabs.is_empty() { + ide_context_section.push_str("\n## Open tabs:\n"); + for tab in &context.open_tabs { + ide_context_section.push_str(&format!("- {}: {}\n", tab.label, tab.path)); + } + } + + if ide_context_section.is_empty() { + None + } else { + Some(format!( + "# Context from my IDE setup:\n{ide_context_section}" + )) + } +} + +#[cfg(test)] +mod tests { + use super::super::ActiveFile; + use super::super::FileDescriptor; + use super::super::IdeContext; + use super::super::Position; + use super::super::Range; + use super::*; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + fn descriptor(label: &str, path: &str) -> FileDescriptor { + FileDescriptor { + label: label.to_string(), + path: path.to_string(), + fs_path: format!("/repo/{path}"), + start_line: None, + end_line: None, + } + } + + #[test] + fn render_prompt_context_matches_app_format() { + let context = IdeContext { + active_file: Some(ActiveFile { + descriptor: descriptor("lib.rs", "src/lib.rs"), + selection: Range { + start: Position { + line: 4, + character: 0, + }, + end: Position { + line: 6, + character: 1, + }, + }, + active_selection_content: "fn selected() {}".to_string(), + selections: Vec::new(), + }), + open_tabs: vec![ + descriptor("lib.rs", "src/lib.rs"), + descriptor("main.rs", "src/main.rs"), + ], + process_env: None, + }; + + assert_eq!( + render_prompt_context(&context), + Some( + "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## Active selection of the file:\nfn selected() {}\n## Open tabs:\n- lib.rs: src/lib.rs\n- main.rs: src/main.rs\n" + .to_string() + ) + ); + } + + #[test] + fn render_prompt_context_omits_empty_context() { + let context = IdeContext { + active_file: None, + open_tabs: Vec::new(), + process_env: None, + }; + + assert_eq!(render_prompt_context(&context), None); + } + + #[test] + fn apply_ide_context_uses_desktop_prompt_request_delimiter() { + let context = IdeContext { + active_file: Some(ActiveFile { + descriptor: descriptor("lib.rs", "src/lib.rs"), + selection: Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: 0, + character: 0, + }, + }, + active_selection_content: String::new(), + selections: Vec::new(), + }), + open_tabs: Vec::new(), + process_env: None, + }; + let text = "Ask $figma".to_string(); + let mut items = vec![ + UserInput::LocalImage { + path: PathBuf::from("/tmp/screenshot.png"), + }, + UserInput::Text { + text, + text_elements: vec![TextElement::new((4..10).into(), Some("$figma".to_string()))], + }, + ]; + + assert!(apply_ide_context_to_user_input(&context, &mut items)); + + let expected_prefix = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\n"; + let prefix_len = expected_prefix.len(); + assert_eq!( + items, + vec![ + UserInput::Text { + text: format!("{expected_prefix}Ask $figma"), + text_elements: vec![TextElement::new( + (prefix_len + 4..prefix_len + 10).into(), + Some("$figma".to_string()), + )], + }, + UserInput::LocalImage { + path: PathBuf::from("/tmp/screenshot.png"), + }, + ] + ); + } + + #[test] + fn extract_prompt_request_returns_text_after_last_delimiter() { + let message = + "# Context\n## My request for Codex:\nFirst\n## My request for Codex:\n Second\n"; + + assert_eq!( + extract_prompt_request_with_offset(message), + ("Second", message.find("Second").expect("request offset")) + ); + } + + #[test] + fn render_prompt_context_includes_selection_ranges_without_content() { + let first_range = Range { + start: Position { + line: 1, + character: 2, + }, + end: Position { + line: 1, + character: 5, + }, + }; + let second_range = Range { + start: Position { + line: 3, + character: 0, + }, + end: Position { + line: 4, + character: 1, + }, + }; + let context = IdeContext { + active_file: Some(ActiveFile { + descriptor: descriptor("lib.rs", "src/lib.rs"), + selection: first_range.clone(), + active_selection_content: String::new(), + selections: vec![first_range, second_range], + }), + open_tabs: Vec::new(), + process_env: None, + }; + + assert_eq!( + render_prompt_context(&context), + Some( + "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## Active selection ranges:\n- src/lib.rs: line 2, column 3 to line 2, column 6\n- src/lib.rs: line 4, column 1 to line 5, column 2\n" + .to_string() + ) + ); + } + + #[test] + fn render_prompt_context_truncates_large_selection() { + let context = IdeContext { + active_file: Some(ActiveFile { + descriptor: descriptor("large.txt", "large.txt"), + selection: Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: 0, + character: 1, + }, + }, + active_selection_content: format!("{}tail", "a".repeat(MAX_ACTIVE_SELECTION_CHARS)), + selections: Vec::new(), + }), + open_tabs: Vec::new(), + process_env: None, + }; + + let rendered = render_prompt_context(&context).expect("rendered IDE context"); + assert!(rendered.contains(&format!( + "[Selection truncated to {MAX_ACTIVE_SELECTION_CHARS} characters.]" + ))); + assert!(!rendered.contains("tail")); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 39b4294b4aae..8749308e7e19 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -128,6 +128,7 @@ mod frames; mod get_git_diff; mod goal_display; mod history_cell; +mod ide_context; pub(crate) mod insert_history; pub use insert_history::insert_history_lines; mod key_hint; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 4f7714505c49..9f29ec4c774d 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Fast, + Ide, Approvals, Permissions, Keymap, @@ -104,6 +105,9 @@ impl SlashCommand { SlashCommand::Fast => { "toggle Fast mode to enable fastest inference with increased plan usage" } + SlashCommand::Ide => { + "include current selection, open files, and other context from your IDE" + } SlashCommand::Personality => "choose a communication style for Codex", SlashCommand::Realtime => "toggle realtime voice mode (experimental)", SlashCommand::Settings => "configure realtime microphone/speaker", @@ -146,6 +150,7 @@ impl SlashCommand { | SlashCommand::Plan | SlashCommand::Goal | SlashCommand::Fast + | SlashCommand::Ide | SlashCommand::Mcp | SlashCommand::Side | SlashCommand::Resume @@ -157,7 +162,11 @@ impl SlashCommand { pub fn available_in_side_conversation(self) -> bool { matches!( self, - SlashCommand::Copy | SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status + SlashCommand::Copy + | SlashCommand::Diff + | SlashCommand::Mention + | SlashCommand::Status + | SlashCommand::Ide ) } @@ -203,6 +212,7 @@ impl SlashCommand { | SlashCommand::Statusline | SlashCommand::AutoReview | SlashCommand::Feedback + | SlashCommand::Ide | SlashCommand::Quit | SlashCommand::Exit | SlashCommand::Side => true, @@ -254,10 +264,17 @@ mod tests { #[test] fn certain_commands_are_available_during_task() { assert!(SlashCommand::Goal.available_during_task()); + assert!(SlashCommand::Ide.available_during_task()); assert!(SlashCommand::Title.available_during_task()); assert!(SlashCommand::Statusline.available_during_task()); } + #[test] + fn ide_command_accepts_state_args() { + assert_eq!(SlashCommand::from_str("ide"), Ok(SlashCommand::Ide)); + assert!(SlashCommand::Ide.supports_inline_args()); + } + #[test] fn auto_review_command_is_autoreview() { assert_eq!(SlashCommand::AutoReview.command(), "autoreview"); From ce500f4b06bab5e853f5a1ab9954ff3f58632da0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 18:12:11 -0700 Subject: [PATCH 02/35] codex: address PR review feedback (#20294) --- codex-rs/tui/src/ide_context/ipc.rs | 104 ++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 9e22910436ec..d1e9b3437aa3 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -3,6 +3,7 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; +use std::time::Instant; #[cfg(any(unix, windows))] use serde_json::Value; @@ -145,14 +146,51 @@ pub(crate) fn fetch_ide_context_from_socket( .set_write_timeout(Some(timeout)) .map_err(IdeContextError::Send)?; - fetch_ide_context_from_stream(&mut stream, workspace_root) + fetch_ide_context_from_stream(&mut stream, workspace_root, Instant::now() + timeout) } #[cfg(windows)] pub(crate) fn fetch_ide_context_from_socket( socket_path: PathBuf, workspace_root: &Path, - _timeout: Duration, + timeout: Duration, +) -> Result { + // Unlike UnixStream, std::fs::File does not let us put a read/write timeout on a Windows + // named-pipe client. Run the blocking exchange on a worker thread and bound the caller with + // recv_timeout so a stuck IDE provider cannot freeze the TUI. + let workspace_root = workspace_root.to_path_buf(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("codex-tui-ide-context-ipc".to_string()) + .spawn(move || { + let result = + fetch_ide_context_from_socket_blocking(socket_path, &workspace_root, timeout); + let _ = tx.send(result); + }) + .map_err(|err| { + IdeContextError::Read(std::io::Error::new( + std::io::ErrorKind::Other, + format!("failed to spawn IDE context worker: {err}"), + )) + })?; + + match rx.recv_timeout(timeout) { + Ok(result) => result, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(timeout_error()), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + Err(IdeContextError::Read(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "IDE context worker disconnected", + ))) + } + } +} + +#[cfg(windows)] +fn fetch_ide_context_from_socket_blocking( + socket_path: PathBuf, + workspace_root: &Path, + timeout: Duration, ) -> Result { let mut stream = std::fs::OpenOptions::new() .read(true) @@ -160,12 +198,13 @@ pub(crate) fn fetch_ide_context_from_socket( .open(socket_path) .map_err(IdeContextError::Connect)?; - fetch_ide_context_from_stream(&mut stream, workspace_root) + fetch_ide_context_from_stream(&mut stream, workspace_root, Instant::now() + timeout) } #[cfg(any(unix, windows))] fn initialize_client( stream: &mut T, + deadline: Instant, ) -> Result { let initialize_request_id = uuid::Uuid::new_v4().to_string(); let initialize_request = json!({ @@ -180,7 +219,7 @@ fn initialize_client( }, }); write_frame(stream, &initialize_request).map_err(IdeContextError::Send)?; - let initialize_response = read_response_frame(stream, &initialize_request_id)?; + let initialize_response = read_response_frame(stream, &initialize_request_id, deadline)?; extract_client_id(&initialize_response) } @@ -189,6 +228,7 @@ fn fetch_ide_context_with_client_id( stream: &mut T, client_id: &str, workspace_root: &Path, + deadline: Instant, ) -> Result { let ide_context_request_id = uuid::Uuid::new_v4().to_string(); let ide_context_request = json!({ @@ -202,7 +242,7 @@ fn fetch_ide_context_with_client_id( }, }); write_frame(stream, &ide_context_request).map_err(IdeContextError::Send)?; - let ide_context_response = read_response_frame(stream, &ide_context_request_id)?; + let ide_context_response = read_response_frame(stream, &ide_context_request_id, deadline)?; extract_ide_context(ide_context_response) } @@ -210,9 +250,10 @@ fn fetch_ide_context_with_client_id( fn fetch_ide_context_from_stream( stream: &mut T, workspace_root: &Path, + deadline: Instant, ) -> Result { - let client_id = initialize_client(stream)?; - fetch_ide_context_with_client_id(stream, &client_id, workspace_root) + let client_id = initialize_client(stream, deadline)?; + fetch_ide_context_with_client_id(stream, &client_id, workspace_root, deadline) } #[cfg(not(any(unix, windows)))] @@ -266,8 +307,10 @@ fn read_frame(stream: &mut T) -> Result( stream: &mut T, request_id: &str, + deadline: Instant, ) -> Result { loop { + ensure_deadline_not_expired(deadline)?; let message = read_frame(stream)?; match message.get("type").and_then(Value::as_str) { Some("response") => { @@ -304,6 +347,23 @@ fn read_response_frame( } } +#[cfg(any(unix, windows))] +fn ensure_deadline_not_expired(deadline: Instant) -> Result<(), IdeContextError> { + if Instant::now() >= deadline { + return Err(timeout_error()); + } + + Ok(()) +} + +#[cfg(any(unix, windows))] +fn timeout_error() -> IdeContextError { + IdeContextError::Read(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out waiting for IDE context", + )) +} + #[cfg(any(unix, windows))] fn extract_client_id(response: &Value) -> Result { ensure_success_response(response)?; @@ -391,6 +451,36 @@ mod tests { assert!(!IdeContextError::ResponseTooLarge.is_retryable_after_recent_toggle()); } + #[cfg(any(unix, windows))] + #[test] + fn read_response_frame_respects_expired_deadline() { + let mut stream = std::io::Cursor::new(Vec::new()); + write_frame( + &mut stream, + &json!({ + "type": "broadcast", + "method": "client-status-changed", + "sourceClientId": "vscode-client", + "version": 0, + "params": { + "clientId": "vscode-client", + "clientType": "vscode", + "status": "connected" + } + }), + ) + .expect("write broadcast frame"); + stream.set_position(0); + + let err = read_response_frame(&mut stream, "missing-request", Instant::now()) + .expect_err("expired deadline should fail before reading"); + + assert!(matches!( + err, + IdeContextError::Read(error) if error.kind() == std::io::ErrorKind::TimedOut + )); + } + #[cfg(unix)] #[test] fn fetch_ide_context_handles_interleaved_messages() { From 7019ac74f18f53ebd6d12f1b35d787cab38205fc Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 18:22:00 -0700 Subject: [PATCH 03/35] codex: fix CI failure on PR #20294 --- codex-rs/tui/src/ide_context/ipc.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index d1e9b3437aa3..75793f80ddf3 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -168,10 +168,9 @@ pub(crate) fn fetch_ide_context_from_socket( let _ = tx.send(result); }) .map_err(|err| { - IdeContextError::Read(std::io::Error::new( - std::io::ErrorKind::Other, - format!("failed to spawn IDE context worker: {err}"), - )) + IdeContextError::Read(std::io::Error::other(format!( + "failed to spawn IDE context worker: {err}" + ))) })?; match rx.recv_timeout(timeout) { @@ -415,6 +414,7 @@ fn ensure_success_response(response: &Value) -> Result<(), IdeContextError> { #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] use pretty_assertions::assert_eq; #[cfg(any(unix, windows))] From f278fb31afb4c469ef48e3e3e18654f416134f5c Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 18:31:51 -0700 Subject: [PATCH 04/35] codex: bound IDE frame reads by deadline (#20294) --- codex-rs/tui/src/ide_context/ipc.rs | 98 +++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 75793f80ddf3..79e277ea95be 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -284,24 +284,52 @@ fn write_frame(stream: &mut T, message: &Value) -> s } #[cfg(any(unix, windows))] -fn read_frame(stream: &mut T) -> Result { +fn read_frame( + stream: &mut T, + deadline: Instant, +) -> Result { let mut len_bytes = [0_u8; 4]; - stream - .read_exact(&mut len_bytes) - .map_err(IdeContextError::Read)?; + read_exact_before_deadline(stream, &mut len_bytes, deadline)?; let len = u32::from_le_bytes(len_bytes) as usize; if len > MAX_IPC_FRAME_BYTES { return Err(IdeContextError::ResponseTooLarge); } let mut payload = vec![0_u8; len]; - stream - .read_exact(&mut payload) - .map_err(IdeContextError::Read)?; + read_exact_before_deadline(stream, &mut payload, deadline)?; serde_json::from_slice(&payload) .map_err(|err| IdeContextError::InvalidResponse(format!("invalid JSON payload: {err}"))) } +#[cfg(any(unix, windows))] +fn read_exact_before_deadline( + stream: &mut T, + buf: &mut [u8], + deadline: Instant, +) -> Result<(), IdeContextError> { + // std::io::Read::read_exact has no way to observe our request deadline between partial reads. + // Keep the frame header and payload under the same budget as the surrounding response wait. + let mut read_so_far = 0; + while read_so_far < buf.len() { + ensure_deadline_not_expired(deadline)?; + match stream.read(&mut buf[read_so_far..]) { + Ok(0) => { + return Err(IdeContextError::Read(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "failed to fill whole IDE context frame", + ))); + } + Ok(bytes_read) => { + read_so_far += bytes_read; + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => return Err(IdeContextError::Read(error)), + } + } + + ensure_deadline_not_expired(deadline) +} + #[cfg(any(unix, windows))] fn read_response_frame( stream: &mut T, @@ -310,7 +338,7 @@ fn read_response_frame( ) -> Result { loop { ensure_deadline_not_expired(deadline)?; - let message = read_frame(stream)?; + let message = read_frame(stream, deadline)?; match message.get("type").and_then(Value::as_str) { Some("response") => { if message.get("requestId").and_then(Value::as_str) == Some(request_id) { @@ -417,6 +445,11 @@ mod tests { #[cfg(unix)] use pretty_assertions::assert_eq; + #[cfg(any(unix, windows))] + fn test_deadline() -> Instant { + Instant::now() + Duration::from_secs(1) + } + #[cfg(any(unix, windows))] #[test] fn retryable_after_recent_toggle_covers_transient_errors() { @@ -481,6 +514,49 @@ mod tests { )); } + #[cfg(any(unix, windows))] + #[test] + fn read_frame_respects_deadline_while_reading_payload() { + struct SlowPayloadReader { + header: [u8; 4], + header_sent: bool, + payload: Vec, + } + + impl std::io::Read for SlowPayloadReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if !self.header_sent { + self.header_sent = true; + buf[..self.header.len()].copy_from_slice(&self.header); + return Ok(self.header.len()); + } + + std::thread::sleep(Duration::from_millis(20)); + let bytes_to_copy = self.payload.len().min(buf.len()); + buf[..bytes_to_copy].copy_from_slice(&self.payload[..bytes_to_copy]); + self.payload.drain(..bytes_to_copy); + Ok(bytes_to_copy) + } + } + + let payload = br#"{"type":"response"}"#.to_vec(); + let mut stream = SlowPayloadReader { + header: u32::try_from(payload.len()) + .expect("payload length fits u32") + .to_le_bytes(), + header_sent: false, + payload, + }; + + let err = read_frame(&mut stream, Instant::now() + Duration::from_millis(1)) + .expect_err("expired deadline should fail while reading payload"); + + assert!(matches!( + err, + IdeContextError::Read(error) if error.kind() == std::io::ErrorKind::TimedOut + )); + } + #[cfg(unix)] #[test] fn fetch_ide_context_handles_interleaved_messages() { @@ -494,7 +570,7 @@ mod tests { let server = thread::spawn(move || { let (mut stream, _) = listener.accept().expect("accept"); - let initialize = read_frame(&mut stream).expect("read initialize"); + let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); assert_eq!( initialize.get("method").and_then(Value::as_str), Some("initialize") @@ -525,7 +601,7 @@ mod tests { ) .expect("write initialize response"); - let ide_context = read_frame(&mut stream).expect("read ide-context"); + let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); assert_eq!( ide_context.get("method").and_then(Value::as_str), Some("ide-context") @@ -571,7 +647,7 @@ mod tests { ) .expect("write client discovery request"); let discovery_response = - read_frame(&mut stream).expect("read client discovery response"); + read_frame(&mut stream, test_deadline()).expect("read client discovery response"); assert_eq!( discovery_response.get("type").and_then(Value::as_str), Some("client-discovery-response") From e2b0f4f5307390106c221ca279817fc220cc05c0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 18:43:35 -0700 Subject: [PATCH 05/35] codex: fix CI failure on PR #20294 --- codex-rs/tui/src/ide_context/ipc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 79e277ea95be..5603c875b381 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -445,7 +445,7 @@ mod tests { #[cfg(unix)] use pretty_assertions::assert_eq; - #[cfg(any(unix, windows))] + #[cfg(unix)] fn test_deadline() -> Instant { Instant::now() + Duration::from_secs(1) } From c1ac634794a0a3da9e376d4b0968128e594fd0ad Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 18:57:06 -0700 Subject: [PATCH 06/35] codex: cancel Windows IDE IPC timeouts (#20294) --- codex-rs/tui/Cargo.toml | 4 + codex-rs/tui/src/ide_context.rs | 2 + codex-rs/tui/src/ide_context/ipc.rs | 44 +--- codex-rs/tui/src/ide_context/windows_pipe.rs | 224 +++++++++++++++++++ 4 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 codex-rs/tui/src/ide_context/windows_pipe.rs diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 300449a4140c..43671392ebbd 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -130,7 +130,11 @@ libc = { workspace = true } which = { workspace = true } windows-sys = { version = "0.52", features = [ "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", "Win32_System_Console", + "Win32_System_IO", + "Win32_System_Threading", ] } winsplit = "0.1" diff --git a/codex-rs/tui/src/ide_context.rs b/codex-rs/tui/src/ide_context.rs index 486efa7c0df1..46a556acc25a 100644 --- a/codex-rs/tui/src/ide_context.rs +++ b/codex-rs/tui/src/ide_context.rs @@ -2,6 +2,8 @@ mod ipc; mod prompt; +#[cfg(windows)] +mod windows_pipe; pub(crate) use ipc::fetch_ide_context; pub(crate) use prompt::apply_ide_context_to_user_input; diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 5603c875b381..6d86c89b0453 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -155,49 +155,11 @@ pub(crate) fn fetch_ide_context_from_socket( workspace_root: &Path, timeout: Duration, ) -> Result { - // Unlike UnixStream, std::fs::File does not let us put a read/write timeout on a Windows - // named-pipe client. Run the blocking exchange on a worker thread and bound the caller with - // recv_timeout so a stuck IDE provider cannot freeze the TUI. - let workspace_root = workspace_root.to_path_buf(); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::Builder::new() - .name("codex-tui-ide-context-ipc".to_string()) - .spawn(move || { - let result = - fetch_ide_context_from_socket_blocking(socket_path, &workspace_root, timeout); - let _ = tx.send(result); - }) - .map_err(|err| { - IdeContextError::Read(std::io::Error::other(format!( - "failed to spawn IDE context worker: {err}" - ))) - })?; - - match rx.recv_timeout(timeout) { - Ok(result) => result, - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(timeout_error()), - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - Err(IdeContextError::Read(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "IDE context worker disconnected", - ))) - } - } -} - -#[cfg(windows)] -fn fetch_ide_context_from_socket_blocking( - socket_path: PathBuf, - workspace_root: &Path, - timeout: Duration, -) -> Result { - let mut stream = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(socket_path) + let deadline = Instant::now() + timeout; + let mut stream = super::windows_pipe::WindowsPipeStream::connect(socket_path, deadline) .map_err(IdeContextError::Connect)?; - fetch_ide_context_from_stream(&mut stream, workspace_root, Instant::now() + timeout) + fetch_ide_context_from_stream(&mut stream, workspace_root, deadline) } #[cfg(any(unix, windows))] diff --git a/codex-rs/tui/src/ide_context/windows_pipe.rs b/codex-rs/tui/src/ide_context/windows_pipe.rs new file mode 100644 index 000000000000..4ee8463ad738 --- /dev/null +++ b/codex-rs/tui/src/ide_context/windows_pipe.rs @@ -0,0 +1,224 @@ +//! Windows named-pipe transport for the IDE context IPC client. + +use std::io; +use std::io::Read; +use std::io::Write; +use std::os::windows::ffi::OsStrExt; +use std::path::PathBuf; +use std::ptr; +use std::time::Instant; + +use windows_sys::Win32::Foundation::BOOL; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Foundation::ERROR_IO_PENDING; +use windows_sys::Win32::Foundation::GENERIC_READ; +use windows_sys::Win32::Foundation::GENERIC_WRITE; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::Foundation::WAIT_FAILED; +use windows_sys::Win32::Foundation::WAIT_OBJECT_0; +use windows_sys::Win32::Foundation::WAIT_TIMEOUT; +use windows_sys::Win32::Storage::FileSystem::CreateFileW; +use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; +use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OVERLAPPED; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; +use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; +use windows_sys::Win32::Storage::FileSystem::ReadFile; +use windows_sys::Win32::Storage::FileSystem::WriteFile; +use windows_sys::Win32::System::IO::CancelIoEx; +use windows_sys::Win32::System::IO::GetOverlappedResult; +use windows_sys::Win32::System::IO::OVERLAPPED; +use windows_sys::Win32::System::Threading::CreateEventW; +use windows_sys::Win32::System::Threading::WaitForSingleObject; + +const TRUE: BOOL = 1; +const FALSE: BOOL = 0; +const NULL_HANDLE: HANDLE = 0; + +pub(super) struct WindowsPipeStream { + handle: OwnedHandle, + deadline: Instant, +} + +impl WindowsPipeStream { + pub(super) fn connect(pipe_path: PathBuf, deadline: Instant) -> io::Result { + let wide_path = pipe_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + let handle = unsafe { + CreateFileW( + wide_path.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + ptr::null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, + NULL_HANDLE, + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + + Ok(Self { + handle: OwnedHandle(handle), + deadline, + }) + } +} + +impl Read for WindowsPipeStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + + let bytes_to_read = u32::try_from(buf.len()).unwrap_or(u32::MAX); + let mut operation = OverlappedOperation::new()?; + let result = unsafe { + ReadFile( + self.handle.raw(), + buf.as_mut_ptr(), + bytes_to_read, + ptr::null_mut(), + operation.as_mut_ptr(), + ) + }; + + operation.complete(self.handle.raw(), result, self.deadline) + } +} + +impl Write for WindowsPipeStream { + fn write(&mut self, buf: &[u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + + let bytes_to_write = u32::try_from(buf.len()).unwrap_or(u32::MAX); + let mut operation = OverlappedOperation::new()?; + let result = unsafe { + WriteFile( + self.handle.raw(), + buf.as_ptr(), + bytes_to_write, + ptr::null_mut(), + operation.as_mut_ptr(), + ) + }; + + operation.complete(self.handle.raw(), result, self.deadline) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct OverlappedOperation { + event: OwnedHandle, + overlapped: OVERLAPPED, +} + +impl OverlappedOperation { + fn new() -> io::Result { + let event = unsafe { CreateEventW(ptr::null(), TRUE, FALSE, ptr::null()) }; + if event == 0 { + return Err(io::Error::last_os_error()); + } + + let mut overlapped = unsafe { std::mem::zeroed::() }; + overlapped.hEvent = event; + Ok(Self { + event: OwnedHandle(event), + overlapped, + }) + } + + fn as_mut_ptr(&mut self) -> *mut OVERLAPPED { + &mut self.overlapped + } + + fn complete( + &mut self, + handle: HANDLE, + initial_result: BOOL, + deadline: Instant, + ) -> io::Result { + if initial_result == 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_IO_PENDING as i32) { + return Err(error); + } + + match unsafe { WaitForSingleObject(self.event.raw(), remaining_timeout_ms(deadline)?) } + { + WAIT_OBJECT_0 => {} + WAIT_TIMEOUT => return Err(self.cancel_and_timeout(handle)), + WAIT_FAILED => return Err(io::Error::last_os_error()), + other => { + return Err(io::Error::other(format!( + "unexpected WaitForSingleObject result: {other}" + ))); + } + } + } + + let mut bytes_transferred = 0; + let result = unsafe { + GetOverlappedResult(handle, self.as_mut_ptr(), &mut bytes_transferred, FALSE) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(bytes_transferred as usize) + } + + fn cancel_and_timeout(&mut self, handle: HANDLE) -> io::Error { + unsafe { + CancelIoEx(handle, self.as_mut_ptr()); + } + let mut bytes_transferred = 0; + unsafe { + GetOverlappedResult(handle, self.as_mut_ptr(), &mut bytes_transferred, TRUE); + } + timeout_io_error() + } +} + +struct OwnedHandle(HANDLE); + +impl OwnedHandle { + fn raw(&self) -> HANDLE { + self.0 + } +} + +impl Drop for OwnedHandle { + fn drop(&mut self) { + if self.0 != 0 && self.0 != INVALID_HANDLE_VALUE { + unsafe { + CloseHandle(self.0); + } + } + } +} + +fn remaining_timeout_ms(deadline: Instant) -> io::Result { + let now = Instant::now(); + if now >= deadline { + return Err(timeout_io_error()); + } + + let millis = deadline.duration_since(now).as_millis().max(1); + Ok(u32::try_from(millis).unwrap_or(u32::MAX)) +} + +fn timeout_io_error() -> io::Error { + io::Error::new(io::ErrorKind::TimedOut, "timed out waiting for IDE context") +} From 88627eb626b128b345d3ad0360adaf19d4b845f8 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 18:59:54 -0700 Subject: [PATCH 07/35] codex: avoid retrying IDE timeouts (#20294) --- codex-rs/tui/src/ide_context/ipc.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 6d86c89b0453..dc83a7d58567 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -54,12 +54,7 @@ impl IdeContextError { error.as_str(), "no-client-found" | "client-disconnected" | "request-timeout" ), - IdeContextError::Read(error) => { - matches!( - error.kind(), - std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock - ) - } + IdeContextError::Read(error) => error.kind() == std::io::ErrorKind::WouldBlock, IdeContextError::Connect(_) | IdeContextError::Send(_) | IdeContextError::InvalidResponse(_) @@ -428,11 +423,11 @@ mod tests { .is_retryable_after_recent_toggle() ); assert!( - IdeContextError::Read(std::io::Error::from(std::io::ErrorKind::TimedOut)) + IdeContextError::Read(std::io::Error::from(std::io::ErrorKind::WouldBlock)) .is_retryable_after_recent_toggle() ); assert!( - IdeContextError::Read(std::io::Error::from(std::io::ErrorKind::WouldBlock)) + !IdeContextError::Read(std::io::Error::from(std::io::ErrorKind::TimedOut)) .is_retryable_after_recent_toggle() ); assert!( From c38f7d5e1099500c73537748c23593f506821653 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 20:37:32 -0700 Subject: [PATCH 08/35] codex: enforce Unix IDE IPC deadline (#20294) --- codex-rs/tui/src/ide_context/ipc.rs | 98 +++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index dc83a7d58567..731c1c4af572 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -131,17 +131,60 @@ pub(crate) fn fetch_ide_context_from_socket( workspace_root: &Path, timeout: Duration, ) -> Result { - use std::os::unix::net::UnixStream; + let deadline = Instant::now() + timeout; + let mut stream = + UnixDeadlineStream::connect(socket_path, deadline).map_err(IdeContextError::Connect)?; + + fetch_ide_context_from_stream(&mut stream, workspace_root, deadline) +} + +#[cfg(unix)] +struct UnixDeadlineStream { + stream: std::os::unix::net::UnixStream, + deadline: Instant, +} + +#[cfg(unix)] +impl UnixDeadlineStream { + fn connect(socket_path: PathBuf, deadline: Instant) -> std::io::Result { + let stream = std::os::unix::net::UnixStream::connect(socket_path)?; + Ok(Self::new(stream, deadline)) + } + + fn new(stream: std::os::unix::net::UnixStream, deadline: Instant) -> Self { + Self { stream, deadline } + } - let mut stream = UnixStream::connect(socket_path).map_err(IdeContextError::Connect)?; - stream - .set_read_timeout(Some(timeout)) - .map_err(IdeContextError::Read)?; - stream - .set_write_timeout(Some(timeout)) - .map_err(IdeContextError::Send)?; + fn remaining_timeout(&self) -> std::io::Result { + self.deadline + .checked_duration_since(Instant::now()) + .filter(|duration| !duration.is_zero()) + .ok_or_else(deadline_timeout_io_error) + } +} - fetch_ide_context_from_stream(&mut stream, workspace_root, Instant::now() + timeout) +#[cfg(unix)] +impl std::io::Read for UnixDeadlineStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.stream + .set_read_timeout(Some(self.remaining_timeout()?))?; + self.stream.read(buf).map_err(normalize_timeout_io_error) + } +} + +#[cfg(unix)] +impl std::io::Write for UnixDeadlineStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.stream + .set_write_timeout(Some(self.remaining_timeout()?))?; + self.stream.write(buf).map_err(normalize_timeout_io_error) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.stream + .set_write_timeout(Some(self.remaining_timeout()?))?; + self.stream.flush().map_err(normalize_timeout_io_error) + } } #[cfg(windows)] @@ -342,10 +385,25 @@ fn ensure_deadline_not_expired(deadline: Instant) -> Result<(), IdeContextError> #[cfg(any(unix, windows))] fn timeout_error() -> IdeContextError { - IdeContextError::Read(std::io::Error::new( + IdeContextError::Read(deadline_timeout_io_error()) +} + +#[cfg(any(unix, windows))] +fn deadline_timeout_io_error() -> std::io::Error { + std::io::Error::new( std::io::ErrorKind::TimedOut, "timed out waiting for IDE context", - )) + ) +} + +#[cfg(unix)] +fn normalize_timeout_io_error(error: std::io::Error) -> std::io::Error { + match error.kind() { + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => { + deadline_timeout_io_error() + } + _ => error, + } } #[cfg(any(unix, windows))] @@ -514,6 +572,24 @@ mod tests { )); } + #[cfg(unix)] + #[test] + fn unix_deadline_stream_uses_remaining_deadline_for_blocking_reads() { + use std::os::unix::net::UnixStream; + + let (client, _server) = UnixStream::pair().expect("create unix stream pair"); + let mut stream = + UnixDeadlineStream::new(client, Instant::now() + Duration::from_millis(50)); + let start = Instant::now(); + let mut buf = [0_u8; 1]; + + let err = std::io::Read::read(&mut stream, &mut buf) + .expect_err("read should time out at the request deadline"); + + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); + assert!(start.elapsed() < Duration::from_secs(2)); + } + #[cfg(unix)] #[test] fn fetch_ide_context_handles_interleaved_messages() { From 072e3bde072c5a56d5d4e68e4c0dfbd9cf24591a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 20:53:43 -0700 Subject: [PATCH 09/35] codex: address PR review feedback (#20294) --- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/ide_context/ipc.rs | 141 ++++++++++++++++++- codex-rs/tui/src/ide_context/windows_pipe.rs | 93 +++++++++++- 3 files changed, 226 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 43671392ebbd..e152bf0f3b31 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -134,6 +134,7 @@ windows-sys = { version = "0.52", features = [ "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", + "Win32_System_Pipes", "Win32_System_Threading", ] } winsplit = "0.1" diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 731c1c4af572..872a74cbd005 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -50,10 +50,9 @@ impl IdeContextError { #[cfg(any(unix, windows))] pub(crate) fn is_retryable_after_recent_toggle(&self) -> bool { match self { - IdeContextError::RequestFailed(error) => matches!( - error.as_str(), - "no-client-found" | "client-disconnected" | "request-timeout" - ), + IdeContextError::RequestFailed(error) => { + matches!(error.as_str(), "no-client-found" | "client-disconnected") + } IdeContextError::Read(error) => error.kind() == std::io::ErrorKind::WouldBlock, IdeContextError::Connect(_) | IdeContextError::Send(_) @@ -147,7 +146,9 @@ struct UnixDeadlineStream { #[cfg(unix)] impl UnixDeadlineStream { fn connect(socket_path: PathBuf, deadline: Instant) -> std::io::Result { + validate_unix_socket_path(&socket_path)?; let stream = std::os::unix::net::UnixStream::connect(socket_path)?; + validate_unix_peer_owner(&stream)?; Ok(Self::new(stream, deadline)) } @@ -187,6 +188,113 @@ impl std::io::Write for UnixDeadlineStream { } } +#[cfg(unix)] +fn validate_unix_socket_path(socket_path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::FileTypeExt; + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + + let uid = unsafe { libc::getuid() }; + let parent = socket_path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "IDE context socket has no parent directory", + ) + })?; + let parent_metadata = std::fs::symlink_metadata(parent)?; + if !parent_metadata.is_dir() || parent_metadata.uid() != uid { + return Err(permission_denied_io_error( + "IDE context socket directory is not owned by the current user", + )); + } + if parent_metadata.permissions().mode() & 0o022 != 0 { + return Err(permission_denied_io_error( + "IDE context socket directory is writable by other users", + )); + } + + let socket_metadata = std::fs::symlink_metadata(socket_path)?; + if !socket_metadata.file_type().is_socket() || socket_metadata.uid() != uid { + return Err(permission_denied_io_error( + "IDE context socket is not owned by the current user", + )); + } + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn validate_unix_peer_owner(stream: &std::os::unix::net::UnixStream) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + let mut credentials = unsafe { std::mem::zeroed::() }; + let mut credentials_len: libc::socklen_t = + std::mem::size_of::().try_into().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid peer credential length", + ) + })?; + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut credentials as *mut _ as *mut libc::c_void, + &mut credentials_len, + ) + }; + if result != 0 { + return Err(std::io::Error::last_os_error()); + } + + ensure_peer_uid_matches_current_user(credentials.uid) +} + +#[cfg(any( + target_os = "macos", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" +))] +fn validate_unix_peer_owner(stream: &std::os::unix::net::UnixStream) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + let mut peer_uid: libc::uid_t = 0; + let mut peer_gid: libc::gid_t = 0; + let result = unsafe { libc::getpeereid(stream.as_raw_fd(), &mut peer_uid, &mut peer_gid) }; + if result != 0 { + return Err(std::io::Error::last_os_error()); + } + + ensure_peer_uid_matches_current_user(peer_uid) +} + +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" +)))] +fn validate_unix_peer_owner(_stream: &std::os::unix::net::UnixStream) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn ensure_peer_uid_matches_current_user(peer_uid: libc::uid_t) -> std::io::Result<()> { + if peer_uid != unsafe { libc::getuid() } { + return Err(permission_denied_io_error( + "IDE context provider is not owned by the current user", + )); + } + + Ok(()) +} + #[cfg(windows)] pub(crate) fn fetch_ide_context_from_socket( socket_path: PathBuf, @@ -396,6 +504,11 @@ fn deadline_timeout_io_error() -> std::io::Error { ) } +#[cfg(unix)] +fn permission_denied_io_error(message: &'static str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::PermissionDenied, message) +} + #[cfg(unix)] fn normalize_timeout_io_error(error: std::io::Error) -> std::io::Error { match error.kind() { @@ -477,7 +590,7 @@ mod tests { .is_retryable_after_recent_toggle() ); assert!( - IdeContextError::RequestFailed("request-timeout".to_string()) + !IdeContextError::RequestFailed("request-timeout".to_string()) .is_retryable_after_recent_toggle() ); assert!( @@ -590,6 +703,24 @@ mod tests { assert!(start.elapsed() < Duration::from_secs(2)); } + #[cfg(unix)] + #[test] + fn validate_unix_socket_path_rejects_unsafe_parent_directory() { + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::UnixListener; + + let tempdir = tempfile::tempdir().expect("tempdir"); + std::fs::set_permissions(tempdir.path(), std::fs::Permissions::from_mode(0o777)) + .expect("set unsafe permissions"); + let socket_path = tempdir.path().join("codex-ipc.sock"); + let _listener = UnixListener::bind(&socket_path).expect("bind socket"); + + let err = validate_unix_socket_path(&socket_path) + .expect_err("world-writable parent directory should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + } + #[cfg(unix)] #[test] fn fetch_ide_context_handles_interleaved_messages() { diff --git a/codex-rs/tui/src/ide_context/windows_pipe.rs b/codex-rs/tui/src/ide_context/windows_pipe.rs index 4ee8463ad738..43fce7a04505 100644 --- a/codex-rs/tui/src/ide_context/windows_pipe.rs +++ b/codex-rs/tui/src/ide_context/windows_pipe.rs @@ -18,6 +18,11 @@ use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Foundation::WAIT_FAILED; use windows_sys::Win32::Foundation::WAIT_OBJECT_0; use windows_sys::Win32::Foundation::WAIT_TIMEOUT; +use windows_sys::Win32::Security::EqualSid; +use windows_sys::Win32::Security::GetTokenInformation; +use windows_sys::Win32::Security::TOKEN_QUERY; +use windows_sys::Win32::Security::TOKEN_USER; +use windows_sys::Win32::Security::TokenUser; use windows_sys::Win32::Storage::FileSystem::CreateFileW; use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OVERLAPPED; @@ -29,7 +34,12 @@ use windows_sys::Win32::Storage::FileSystem::WriteFile; use windows_sys::Win32::System::IO::CancelIoEx; use windows_sys::Win32::System::IO::GetOverlappedResult; use windows_sys::Win32::System::IO::OVERLAPPED; +use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId; use windows_sys::Win32::System::Threading::CreateEventW; +use windows_sys::Win32::System::Threading::GetCurrentProcess; +use windows_sys::Win32::System::Threading::OpenProcess; +use windows_sys::Win32::System::Threading::OpenProcessToken; +use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; use windows_sys::Win32::System::Threading::WaitForSingleObject; const TRUE: BOOL = 1; @@ -64,10 +74,10 @@ impl WindowsPipeStream { return Err(io::Error::last_os_error()); } - Ok(Self { - handle: OwnedHandle(handle), - deadline, - }) + let handle = OwnedHandle(handle); + validate_pipe_server_owner(handle.raw())?; + + Ok(Self { handle, deadline }) } } @@ -209,6 +219,81 @@ impl Drop for OwnedHandle { } } +struct TokenUserBuffer { + buffer: Vec, +} + +impl TokenUserBuffer { + fn sid(&self) -> windows_sys::Win32::Foundation::PSID { + let token_user = unsafe { &*(self.buffer.as_ptr() as *const TOKEN_USER) }; + token_user.User.Sid + } +} + +fn validate_pipe_server_owner(pipe_handle: HANDLE) -> io::Result<()> { + let mut server_process_id = 0; + let result = unsafe { GetNamedPipeServerProcessId(pipe_handle, &mut server_process_id) }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + + let server_process = + unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, server_process_id) }; + if server_process == 0 { + return Err(io::Error::last_os_error()); + } + let server_process = OwnedHandle(server_process); + let server_token = open_process_token(server_process.raw())?; + let current_token = open_process_token(unsafe { GetCurrentProcess() })?; + let server_user = token_user(server_token.raw())?; + let current_user = token_user(current_token.raw())?; + + if unsafe { EqualSid(server_user.sid(), current_user.sid()) } == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "IDE context provider is not owned by the current user", + )); + } + + Ok(()) +} + +fn open_process_token(process: HANDLE) -> io::Result { + let mut token = 0; + let result = unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(OwnedHandle(token)) +} + +fn token_user(token: HANDLE) -> io::Result { + let mut return_length = 0; + unsafe { + GetTokenInformation(token, TokenUser, ptr::null_mut(), 0, &mut return_length); + } + if return_length == 0 { + return Err(io::Error::last_os_error()); + } + + let mut buffer = vec![0_u8; return_length as usize]; + let result = unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr() as *mut _, + return_length, + &mut return_length, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(TokenUserBuffer { buffer }) +} + fn remaining_timeout_ms(deadline: Instant) -> io::Result { let now = Instant::now(); if now >= deadline { From a9e1f5cb745a0db4801ebdfd69ad3d83084a051e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 21:01:35 -0700 Subject: [PATCH 10/35] codex: fix Windows IDE IPC build (#20294) --- codex-rs/tui/src/ide_context/ipc.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 872a74cbd005..37e6a9bf34af 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -271,15 +271,18 @@ fn validate_unix_peer_owner(stream: &std::os::unix::net::UnixStream) -> std::io: ensure_peer_uid_matches_current_user(peer_uid) } -#[cfg(not(any( - target_os = "linux", - target_os = "android", - target_os = "macos", - target_os = "freebsd", - target_os = "openbsd", - target_os = "netbsd", - target_os = "dragonfly" -)))] +#[cfg(all( + unix, + not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" + )) +))] fn validate_unix_peer_owner(_stream: &std::os::unix::net::UnixStream) -> std::io::Result<()> { Ok(()) } From 47f88369b12d6a476b4e8e7c55f08179944035fa Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 21:13:50 -0700 Subject: [PATCH 11/35] codex: fix IDE context submit latency (#20294) --- codex-rs/tui/src/chatwidget/ide_context.rs | 2 +- codex-rs/tui/src/ide_context.rs | 1 + codex-rs/tui/src/ide_context/ipc.rs | 105 ++++++++++++++++----- 3 files changed, 84 insertions(+), 24 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/ide_context.rs b/codex-rs/tui/src/chatwidget/ide_context.rs index 12edfc47f8a3..6e4fb8992e5f 100644 --- a/codex-rs/tui/src/chatwidget/ide_context.rs +++ b/codex-rs/tui/src/chatwidget/ide_context.rs @@ -90,7 +90,7 @@ impl ChatWidget { return; } - match crate::ide_context::fetch_ide_context(&self.config.cwd) { + match crate::ide_context::fetch_ide_context_for_prompt(&self.config.cwd) { Ok(context) => { self.ide_context.mark_available(); self.sync_ide_context_status_indicator(); diff --git a/codex-rs/tui/src/ide_context.rs b/codex-rs/tui/src/ide_context.rs index 46a556acc25a..cae7958c648d 100644 --- a/codex-rs/tui/src/ide_context.rs +++ b/codex-rs/tui/src/ide_context.rs @@ -6,6 +6,7 @@ mod prompt; mod windows_pipe; pub(crate) use ipc::fetch_ide_context; +pub(crate) use ipc::fetch_ide_context_for_prompt; pub(crate) use prompt::apply_ide_context_to_user_input; pub(crate) use prompt::extract_prompt_request_with_offset; pub(crate) use prompt::has_prompt_context; diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 37e6a9bf34af..b49f604111c8 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -13,9 +13,11 @@ use thiserror::Error; use super::IdeContext; -// The desktop integration can take several seconds to determine whether an IDE can answer a -// request. Keep this long enough that transient local read timeouts do not become user-visible. -const IDE_CONTEXT_REQUEST_TIMEOUT: Duration = Duration::from_secs(6); +// The desktop integration can take several seconds to determine whether an IDE can answer an +// initial probe. Keep this long enough that transient local read timeouts do not prevent enabling +// the feature, but use a much shorter budget on the prompt-submit path below. +const IDE_CONTEXT_PROBE_TIMEOUT: Duration = Duration::from_secs(6); +const IDE_CONTEXT_PROMPT_TIMEOUT: Duration = Duration::from_millis(500); // Prompt rendering applies its own smaller cap to selected text before injection. #[cfg(any(unix, windows))] const MAX_IPC_FRAME_BYTES: usize = 256 * 1024 * 1024; @@ -102,7 +104,17 @@ pub(crate) fn fetch_ide_context(workspace_root: &Path) -> Result Result { + fetch_ide_context_from_socket( + default_ipc_socket_path(), + workspace_root, + IDE_CONTEXT_PROMPT_TIMEOUT, ) } @@ -149,6 +161,7 @@ impl UnixDeadlineStream { validate_unix_socket_path(&socket_path)?; let stream = std::os::unix::net::UnixStream::connect(socket_path)?; validate_unix_peer_owner(&stream)?; + stream.set_nonblocking(true)?; Ok(Self::new(stream, deadline)) } @@ -162,29 +175,85 @@ impl UnixDeadlineStream { .filter(|duration| !duration.is_zero()) .ok_or_else(deadline_timeout_io_error) } + + fn remaining_timeout_ms(&self) -> std::io::Result { + let millis = self.remaining_timeout()?.as_millis().max(1); + Ok(libc::c_int::try_from(millis).unwrap_or(libc::c_int::MAX)) + } + + fn wait_for_ready(&self, events: libc::c_short) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + loop { + // Keep deadline handling in user space. Some macOS Unix socket environments reject + // SO_RCVTIMEO/SO_SNDTIMEO, but poll works consistently for our request-scoped timeout. + let mut poll_fd = libc::pollfd { + fd: self.stream.as_raw_fd(), + events, + revents: 0, + }; + let result = unsafe { libc::poll(&mut poll_fd, 1, self.remaining_timeout_ms()?) }; + if result == 0 { + return Err(deadline_timeout_io_error()); + } + if result < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error); + } + if poll_fd.revents & libc::POLLNVAL != 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid IDE context Unix socket", + )); + } + if poll_fd.revents & (events | libc::POLLERR | libc::POLLHUP) != 0 { + return Ok(()); + } + } + } } #[cfg(unix)] impl std::io::Read for UnixDeadlineStream { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - self.stream - .set_read_timeout(Some(self.remaining_timeout()?))?; - self.stream.read(buf).map_err(normalize_timeout_io_error) + if buf.is_empty() { + return Ok(0); + } + + loop { + self.wait_for_ready(libc::POLLIN)?; + match self.stream.read(buf) { + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + result => return result, + } + } } } #[cfg(unix)] impl std::io::Write for UnixDeadlineStream { fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.stream - .set_write_timeout(Some(self.remaining_timeout()?))?; - self.stream.write(buf).map_err(normalize_timeout_io_error) + if buf.is_empty() { + return Ok(0); + } + + loop { + self.wait_for_ready(libc::POLLOUT)?; + match self.stream.write(buf) { + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + result => return result, + } + } } fn flush(&mut self) -> std::io::Result<()> { - self.stream - .set_write_timeout(Some(self.remaining_timeout()?))?; - self.stream.flush().map_err(normalize_timeout_io_error) + self.wait_for_ready(libc::POLLOUT)?; + self.stream.flush() } } @@ -512,16 +581,6 @@ fn permission_denied_io_error(message: &'static str) -> std::io::Error { std::io::Error::new(std::io::ErrorKind::PermissionDenied, message) } -#[cfg(unix)] -fn normalize_timeout_io_error(error: std::io::Error) -> std::io::Error { - match error.kind() { - std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => { - deadline_timeout_io_error() - } - _ => error, - } -} - #[cfg(any(unix, windows))] fn extract_client_id(response: &Value) -> Result { ensure_success_response(response)?; From de8faf949146bce73e18b81aecd3e7d7ca198b0b Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 21:30:54 -0700 Subject: [PATCH 12/35] codex: keep IDE IPC connection alive (#20294) --- codex-rs/tui/src/chatwidget/ide_context.rs | 69 +++- codex-rs/tui/src/ide_context.rs | 4 +- codex-rs/tui/src/ide_context/ipc.rs | 386 ++++++++++++++----- codex-rs/tui/src/ide_context/windows_pipe.rs | 22 +- 4 files changed, 379 insertions(+), 102 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/ide_context.rs b/codex-rs/tui/src/chatwidget/ide_context.rs index 6e4fb8992e5f..b3ec34cc0441 100644 --- a/codex-rs/tui/src/chatwidget/ide_context.rs +++ b/codex-rs/tui/src/chatwidget/ide_context.rs @@ -1,5 +1,6 @@ //! Chat-widget wiring for the `/ide` command and IDE context prompt injection. +use std::path::Path; use std::time::Duration; use std::time::Instant; @@ -7,6 +8,9 @@ use codex_protocol::user_input::UserInput; use super::ChatWidget; use crate::bottom_pane::IdeContextStatusIndicator; +use crate::ide_context::IdeContext; +use crate::ide_context::IdeContextClient; +use crate::ide_context::IdeContextError; const IDE_CONTEXT_RECENT_TOGGLE_RETRY_WINDOW: Duration = Duration::from_secs(5); const IDE_CONTEXT_RECENT_TOGGLE_RETRY_DELAY: Duration = Duration::from_millis(250); @@ -15,7 +19,9 @@ const IDE_CONTEXT_RECENT_TOGGLE_RETRY_ATTEMPTS: usize = 12; #[derive(Default)] pub(super) struct IdeContextState { enabled: bool, + prompt_fetch_warned: bool, last_disabled_at: Option, + client: Option, } impl IdeContextState { @@ -25,14 +31,18 @@ impl IdeContextState { fn enable(&mut self) { self.enabled = true; + self.prompt_fetch_warned = false; } fn disable(&mut self) { self.enabled = false; + self.prompt_fetch_warned = false; self.last_disabled_at = Some(Instant::now()); + self.client = None; } fn mark_available(&mut self) { + self.prompt_fetch_warned = false; self.last_disabled_at = None; } @@ -49,6 +59,44 @@ impl IdeContextState { disabled_at.elapsed() <= IDE_CONTEXT_RECENT_TOGGLE_RETRY_WINDOW }) } + + fn fetch_context_for_probe( + &mut self, + workspace_root: &Path, + ) -> Result { + let client = if let Some(client) = self.client.as_mut() { + client + } else { + self.client.insert(IdeContextClient::connect()?) + }; + + let result = client.fetch_ide_context(workspace_root); + if let Err(err) = &result + && err.should_reset_client() + { + self.client = None; + } + result + } + + fn fetch_context_for_prompt( + &mut self, + workspace_root: &Path, + ) -> Result { + let client = if let Some(client) = self.client.as_mut() { + client + } else { + self.client.insert(IdeContextClient::connect_for_prompt()?) + }; + + let result = client.fetch_ide_context_for_prompt(workspace_root); + if let Err(err) = &result + && err.should_reset_client() + { + self.client = None; + } + result + } } impl ChatWidget { @@ -90,20 +138,21 @@ impl ChatWidget { return; } - match crate::ide_context::fetch_ide_context_for_prompt(&self.config.cwd) { + match self.ide_context.fetch_context_for_prompt(&self.config.cwd) { Ok(context) => { self.ide_context.mark_available(); self.sync_ide_context_status_indicator(); crate::ide_context::apply_ide_context_to_user_input(&context, items); } Err(err) => { - self.ide_context.disable(); self.sync_ide_context_status_indicator(); - self.add_info_message( - "IDE context was turned off because Codex could not fetch IDE context." - .to_string(), - Some(err.user_facing_hint()), - ); + if !self.ide_context.prompt_fetch_warned { + self.ide_context.prompt_fetch_warned = true; + self.add_info_message( + "IDE context was skipped for this message.".to_string(), + Some(err.prompt_skip_hint()), + ); + } } } } @@ -115,9 +164,9 @@ impl ChatWidget { return; } - let mut fetch_result = crate::ide_context::fetch_ide_context(&self.config.cwd); + let mut fetch_result = self.ide_context.fetch_context_for_probe(&self.config.cwd); if self.ide_context.should_retry_recent_toggle() { - // The previous short-lived IDE context connection may still be winding down. + // The previous IDE context connection may still be winding down. for _ in 0..IDE_CONTEXT_RECENT_TOGGLE_RETRY_ATTEMPTS { if !matches!( fetch_result, @@ -126,7 +175,7 @@ impl ChatWidget { break; } std::thread::sleep(IDE_CONTEXT_RECENT_TOGGLE_RETRY_DELAY); - fetch_result = crate::ide_context::fetch_ide_context(&self.config.cwd); + fetch_result = self.ide_context.fetch_context_for_probe(&self.config.cwd); } } diff --git a/codex-rs/tui/src/ide_context.rs b/codex-rs/tui/src/ide_context.rs index cae7958c648d..33d3da6b78b4 100644 --- a/codex-rs/tui/src/ide_context.rs +++ b/codex-rs/tui/src/ide_context.rs @@ -5,8 +5,8 @@ mod prompt; #[cfg(windows)] mod windows_pipe; -pub(crate) use ipc::fetch_ide_context; -pub(crate) use ipc::fetch_ide_context_for_prompt; +pub(crate) use ipc::IdeContextClient; +pub(crate) use ipc::IdeContextError; pub(crate) use prompt::apply_ide_context_to_user_input; pub(crate) use prompt::extract_prompt_request_with_offset; pub(crate) use prompt::has_prompt_context; diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index b49f604111c8..a2154115c7af 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -89,6 +89,42 @@ impl IdeContextError { } } + #[cfg(any(unix, windows))] + pub(crate) fn prompt_skip_hint(&self) -> String { + match self { + IdeContextError::ResponseTooLarge => { + "The selected IDE context is too large. Clear any large selection in your IDE." + .to_string() + } + IdeContextError::Connect(_) => { + "Open this project in VS Code or Cursor with the Codex extension active." + .to_string() + } + IdeContextError::RequestFailed(error) if error == "no-client-found" => { + "Open this project in VS Code or Cursor with the Codex extension active." + .to_string() + } + IdeContextError::Send(_) + | IdeContextError::Read(_) + | IdeContextError::InvalidResponse(_) + | IdeContextError::RequestFailed(_) => { + "Codex will keep trying on future messages.".to_string() + } + } + } + + #[cfg(any(unix, windows))] + pub(crate) fn should_reset_client(&self) -> bool { + match self { + IdeContextError::Connect(_) + | IdeContextError::Send(_) + | IdeContextError::Read(_) + | IdeContextError::InvalidResponse(_) + | IdeContextError::ResponseTooLarge => true, + IdeContextError::RequestFailed(_) => false, + } + } + #[cfg(not(any(unix, windows)))] pub(crate) fn is_retryable_after_recent_toggle(&self) -> bool { false @@ -98,24 +134,122 @@ impl IdeContextError { pub(crate) fn user_facing_hint(&self) -> String { self.to_string() } + + #[cfg(not(any(unix, windows)))] + pub(crate) fn prompt_skip_hint(&self) -> String { + self.to_string() + } + + #[cfg(not(any(unix, windows)))] + pub(crate) fn should_reset_client(&self) -> bool { + false + } } -pub(crate) fn fetch_ide_context(workspace_root: &Path) -> Result { - fetch_ide_context_from_socket( - default_ipc_socket_path(), - workspace_root, - IDE_CONTEXT_PROBE_TIMEOUT, - ) +/// Persistent IPC client used while TUI `/ide` mode is enabled. +/// +/// The initial connection and initialize handshake happen once on `/ide on`, and each user turn +/// asks for a fresh IDE context snapshot over the same route with a short prompt-time deadline. +#[cfg(any(unix, windows))] +pub(crate) struct IdeContextClient { + stream: IdeContextStream, + client_id: String, } -pub(crate) fn fetch_ide_context_for_prompt( - workspace_root: &Path, -) -> Result { - fetch_ide_context_from_socket( - default_ipc_socket_path(), - workspace_root, - IDE_CONTEXT_PROMPT_TIMEOUT, - ) +#[cfg(unix)] +type IdeContextStream = UnixDeadlineStream; + +#[cfg(windows)] +type IdeContextStream = super::windows_pipe::WindowsPipeStream; + +#[cfg(any(unix, windows))] +impl IdeContextClient { + pub(crate) fn connect() -> Result { + Self::connect_to_socket(default_ipc_socket_path(), IDE_CONTEXT_PROBE_TIMEOUT) + } + + pub(crate) fn connect_for_prompt() -> Result { + Self::connect_to_socket(default_ipc_socket_path(), IDE_CONTEXT_PROMPT_TIMEOUT) + } + + pub(crate) fn fetch_ide_context( + &mut self, + workspace_root: &Path, + ) -> Result { + self.fetch_ide_context_with_timeout(workspace_root, IDE_CONTEXT_PROBE_TIMEOUT) + } + + pub(crate) fn fetch_ide_context_for_prompt( + &mut self, + workspace_root: &Path, + ) -> Result { + self.fetch_ide_context_with_timeout(workspace_root, IDE_CONTEXT_PROMPT_TIMEOUT) + } + + fn connect_to_socket(socket_path: PathBuf, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + Self::connect_to_socket_before_deadline(socket_path, deadline) + } + + fn connect_to_socket_before_deadline( + socket_path: PathBuf, + deadline: Instant, + ) -> Result { + let mut stream = connect_stream(socket_path, deadline)?; + let client_id = initialize_client(&mut stream, deadline)?; + Ok(Self { stream, client_id }) + } + + fn fetch_ide_context_with_timeout( + &mut self, + workspace_root: &Path, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + timeout; + self.fetch_ide_context_before_deadline(workspace_root, deadline) + } + + fn fetch_ide_context_before_deadline( + &mut self, + workspace_root: &Path, + deadline: Instant, + ) -> Result { + self.stream.set_deadline(deadline); + fetch_ide_context_with_client_id( + &mut self.stream, + &self.client_id, + workspace_root, + deadline, + ) + } +} + +#[cfg(not(any(unix, windows)))] +pub(crate) struct IdeContextClient; + +#[cfg(not(any(unix, windows)))] +impl IdeContextClient { + pub(crate) fn connect() -> Result { + Err(IdeContextError::UnsupportedPlatform) + } + + pub(crate) fn connect_for_prompt() -> Result { + Err(IdeContextError::UnsupportedPlatform) + } + + pub(crate) fn fetch_ide_context( + &mut self, + _workspace_root: &Path, + ) -> Result { + Err(IdeContextError::UnsupportedPlatform) + } + + pub(crate) fn fetch_ide_context_for_prompt( + &mut self, + _workspace_root: &Path, + ) -> Result { + Err(IdeContextError::UnsupportedPlatform) + } } #[cfg(unix)] @@ -136,17 +270,23 @@ fn default_ipc_socket_path() -> PathBuf { PathBuf::new() } -#[cfg(unix)] -pub(crate) fn fetch_ide_context_from_socket( +#[cfg(all(test, unix))] +fn fetch_ide_context_from_socket( socket_path: PathBuf, workspace_root: &Path, timeout: Duration, ) -> Result { let deadline = Instant::now() + timeout; - let mut stream = - UnixDeadlineStream::connect(socket_path, deadline).map_err(IdeContextError::Connect)?; + let mut client = IdeContextClient::connect_to_socket_before_deadline(socket_path, deadline)?; + client.fetch_ide_context_before_deadline(workspace_root, deadline) +} - fetch_ide_context_from_stream(&mut stream, workspace_root, deadline) +#[cfg(unix)] +fn connect_stream( + socket_path: PathBuf, + deadline: Instant, +) -> Result { + UnixDeadlineStream::connect(socket_path, deadline).map_err(IdeContextError::Connect) } #[cfg(unix)] @@ -169,6 +309,10 @@ impl UnixDeadlineStream { Self { stream, deadline } } + fn set_deadline(&mut self, deadline: Instant) { + self.deadline = deadline; + } + fn remaining_timeout(&self) -> std::io::Result { self.deadline .checked_duration_since(Instant::now()) @@ -368,16 +512,12 @@ fn ensure_peer_uid_matches_current_user(peer_uid: libc::uid_t) -> std::io::Resul } #[cfg(windows)] -pub(crate) fn fetch_ide_context_from_socket( +fn connect_stream( socket_path: PathBuf, - workspace_root: &Path, - timeout: Duration, -) -> Result { - let deadline = Instant::now() + timeout; - let mut stream = super::windows_pipe::WindowsPipeStream::connect(socket_path, deadline) - .map_err(IdeContextError::Connect)?; - - fetch_ide_context_from_stream(&mut stream, workspace_root, deadline) + deadline: Instant, +) -> Result { + super::windows_pipe::WindowsPipeStream::connect(socket_path, deadline) + .map_err(IdeContextError::Connect) } #[cfg(any(unix, windows))] @@ -425,25 +565,6 @@ fn fetch_ide_context_with_client_id( extract_ide_context(ide_context_response) } -#[cfg(any(unix, windows))] -fn fetch_ide_context_from_stream( - stream: &mut T, - workspace_root: &Path, - deadline: Instant, -) -> Result { - let client_id = initialize_client(stream, deadline)?; - fetch_ide_context_with_client_id(stream, &client_id, workspace_root, deadline) -} - -#[cfg(not(any(unix, windows)))] -pub(crate) fn fetch_ide_context_from_socket( - _socket_path: PathBuf, - _workspace_root: &Path, - _timeout: Duration, -) -> Result { - Err(IdeContextError::UnsupportedPlatform) -} - #[cfg(any(unix, windows))] fn write_frame(stream: &mut T, message: &Value) -> std::io::Result<()> { let payload = serde_json::to_vec(message).map_err(|err| { @@ -640,6 +761,59 @@ mod tests { Instant::now() + Duration::from_secs(1) } + #[cfg(unix)] + fn write_initialize_response(stream: &mut impl std::io::Write, request_id: &str) { + write_frame( + stream, + &json!({ + "type": "response", + "requestId": request_id, + "resultType": "success", + "method": "initialize", + "handledByClientId": "server", + "result": { + "clientId": "rust-client" + } + }), + ) + .expect("write initialize response"); + } + + #[cfg(unix)] + fn write_ide_context_response( + stream: &mut impl std::io::Write, + request_id: &str, + active_selection_content: &str, + ) { + write_frame( + stream, + &json!({ + "type": "response", + "requestId": request_id, + "resultType": "success", + "method": "ide-context", + "handledByClientId": "vscode-client", + "result": { + "ideContext": { + "activeFile": { + "label": "lib.rs", + "path": "src/lib.rs", + "fsPath": "/repo/src/lib.rs", + "selection": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 3 } + }, + "activeSelectionContent": active_selection_content, + "selections": [] + }, + "openTabs": [] + } + } + }), + ) + .expect("write ide-context response"); + } + #[cfg(any(unix, windows))] #[test] fn retryable_after_recent_toggle_covers_transient_errors() { @@ -812,20 +986,7 @@ mod tests { .get("requestId") .and_then(Value::as_str) .expect("initialize request id"); - write_frame( - &mut stream, - &json!({ - "type": "response", - "requestId": initialize_request_id, - "resultType": "success", - "method": "initialize", - "handledByClientId": "server", - "result": { - "clientId": "rust-client" - } - }), - ) - .expect("write initialize response"); + write_initialize_response(&mut stream, initialize_request_id); let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); assert_eq!( @@ -890,33 +1051,7 @@ mod tests { Some(false) ); - write_frame( - &mut stream, - &json!({ - "type": "response", - "requestId": ide_context_request_id, - "resultType": "success", - "method": "ide-context", - "handledByClientId": "vscode-client", - "result": { - "ideContext": { - "activeFile": { - "label": "lib.rs", - "path": "src/lib.rs", - "fsPath": "/repo/src/lib.rs", - "selection": { - "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 3 } - }, - "activeSelectionContent": "use", - "selections": [] - }, - "openTabs": [] - } - } - }), - ) - .expect("write ide-context response"); + write_ide_context_response(&mut stream, ide_context_request_id, "use"); }); let context = @@ -932,4 +1067,79 @@ mod tests { Some("use") ); } + + #[cfg(unix)] + #[test] + fn ide_context_client_reuses_initialized_connection_for_prompt_requests() { + use std::os::unix::net::UnixListener; + use std::thread; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("codex-ipc.sock"); + let listener = UnixListener::bind(&socket_path).expect("bind socket"); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + + let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); + let initialize_request_id = initialize + .get("requestId") + .and_then(Value::as_str) + .expect("initialize request id"); + write_initialize_response(&mut stream, initialize_request_id); + + for active_selection_content in ["first", "second"] { + let ide_context = + read_frame(&mut stream, test_deadline()).expect("read ide-context"); + assert_eq!( + ide_context.get("method").and_then(Value::as_str), + Some("ide-context") + ); + assert_eq!( + ide_context.get("sourceClientId").and_then(Value::as_str), + Some("rust-client") + ); + assert_eq!( + ide_context + .get("params") + .and_then(|params| params.get("workspaceRoot")) + .and_then(Value::as_str), + Some("/repo") + ); + let ide_context_request_id = ide_context + .get("requestId") + .and_then(Value::as_str) + .expect("ide-context request id"); + write_ide_context_response( + &mut stream, + ide_context_request_id, + active_selection_content, + ); + } + }); + + let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) + .expect("connect IDE context client"); + let first = client + .fetch_ide_context(Path::new("/repo")) + .expect("fetch first IDE context"); + let second = client + .fetch_ide_context_for_prompt(Path::new("/repo")) + .expect("fetch second IDE context"); + + server.join().expect("server joins"); + assert_eq!( + [ + first + .active_file + .as_ref() + .map(|file| file.active_selection_content.as_str()), + second + .active_file + .as_ref() + .map(|file| file.active_selection_content.as_str()), + ], + [Some("first"), Some("second")] + ); + } } diff --git a/codex-rs/tui/src/ide_context/windows_pipe.rs b/codex-rs/tui/src/ide_context/windows_pipe.rs index 43fce7a04505..8074b65e8045 100644 --- a/codex-rs/tui/src/ide_context/windows_pipe.rs +++ b/codex-rs/tui/src/ide_context/windows_pipe.rs @@ -11,6 +11,7 @@ use std::time::Instant; use windows_sys::Win32::Foundation::BOOL; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::ERROR_IO_PENDING; +use windows_sys::Win32::Foundation::ERROR_NOT_FOUND; use windows_sys::Win32::Foundation::GENERIC_READ; use windows_sys::Win32::Foundation::GENERIC_WRITE; use windows_sys::Win32::Foundation::HANDLE; @@ -79,6 +80,10 @@ impl WindowsPipeStream { Ok(Self { handle, deadline }) } + + pub(super) fn set_deadline(&mut self, deadline: Instant) { + self.deadline = deadline; + } } impl Read for WindowsPipeStream { @@ -190,9 +195,22 @@ impl OverlappedOperation { } fn cancel_and_timeout(&mut self, handle: HANDLE) -> io::Error { - unsafe { - CancelIoEx(handle, self.as_mut_ptr()); + let cancel_result = unsafe { CancelIoEx(handle, self.as_mut_ptr()) }; + if cancel_result == 0 { + let cancel_error = io::Error::last_os_error(); + if cancel_error.raw_os_error() != Some(ERROR_NOT_FOUND as i32) { + return cancel_error; + } + + // ERROR_NOT_FOUND means the operation completed before cancellation was issued. Drain + // it without waiting so the timeout path cannot block past the caller's deadline. + let mut bytes_transferred = 0; + unsafe { + GetOverlappedResult(handle, self.as_mut_ptr(), &mut bytes_transferred, FALSE) + }; + return timeout_io_error(); } + let mut bytes_transferred = 0; unsafe { GetOverlappedResult(handle, self.as_mut_ptr(), &mut bytes_transferred, TRUE); From bb84d7cc5bc35409674ed2d3d3eb80116413185e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 21:58:36 -0700 Subject: [PATCH 13/35] codex: drain IDE IPC messages continuously (#20294) --- codex-rs/tui/src/ide_context/ipc.rs | 334 +++++++++++++++++++++++++--- 1 file changed, 306 insertions(+), 28 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index a2154115c7af..b64d92cb7427 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -2,6 +2,10 @@ use std::path::Path; use std::path::PathBuf; +use std::sync::mpsc; +use std::sync::mpsc::Receiver; +use std::sync::mpsc::Sender; +use std::thread::JoinHandle; use std::time::Duration; use std::time::Instant; @@ -18,6 +22,8 @@ use super::IdeContext; // the feature, but use a much shorter budget on the prompt-submit path below. const IDE_CONTEXT_PROBE_TIMEOUT: Duration = Duration::from_secs(6); const IDE_CONTEXT_PROMPT_TIMEOUT: Duration = Duration::from_millis(500); +#[cfg(any(unix, windows))] +const IDE_CONTEXT_IDLE_READ_TIMEOUT: Duration = Duration::from_millis(50); // Prompt rendering applies its own smaller cap to selected text before injection. #[cfg(any(unix, windows))] const MAX_IPC_FRAME_BYTES: usize = 256 * 1024 * 1024; @@ -152,8 +158,8 @@ impl IdeContextError { /// asks for a fresh IDE context snapshot over the same route with a short prompt-time deadline. #[cfg(any(unix, windows))] pub(crate) struct IdeContextClient { - stream: IdeContextStream, - client_id: String, + commands: Sender, + reader: Option>, } #[cfg(unix)] @@ -162,6 +168,23 @@ type IdeContextStream = UnixDeadlineStream; #[cfg(windows)] type IdeContextStream = super::windows_pipe::WindowsPipeStream; +#[cfg(any(unix, windows))] +enum IdeContextClientCommand { + FetchIdeContext { + workspace_root: PathBuf, + timeout: Duration, + response_tx: Sender>, + }, + Shutdown, +} + +#[cfg(any(unix, windows))] +struct PendingIdeContextRequest { + request_id: String, + deadline: Instant, + response_tx: Sender>, +} + #[cfg(any(unix, windows))] impl IdeContextClient { pub(crate) fn connect() -> Result { @@ -197,7 +220,7 @@ impl IdeContextClient { ) -> Result { let mut stream = connect_stream(socket_path, deadline)?; let client_id = initialize_client(&mut stream, deadline)?; - Ok(Self { stream, client_id }) + Ok(Self::spawn_reader(stream, client_id)) } fn fetch_ide_context_with_timeout( @@ -205,22 +228,40 @@ impl IdeContextClient { workspace_root: &Path, timeout: Duration, ) -> Result { - let deadline = Instant::now() + timeout; - self.fetch_ide_context_before_deadline(workspace_root, deadline) + let (response_tx, response_rx) = mpsc::channel(); + self.commands + .send(IdeContextClientCommand::FetchIdeContext { + workspace_root: workspace_root.to_path_buf(), + timeout, + response_tx, + }) + .map_err(|_| IdeContextError::Send(ipc_reader_closed_io_error()))?; + + response_rx + .recv_timeout(timeout + IDE_CONTEXT_IDLE_READ_TIMEOUT) + .unwrap_or_else(|_| Err(timeout_error())) } - fn fetch_ide_context_before_deadline( - &mut self, - workspace_root: &Path, - deadline: Instant, - ) -> Result { - self.stream.set_deadline(deadline); - fetch_ide_context_with_client_id( - &mut self.stream, - &self.client_id, - workspace_root, - deadline, - ) + fn spawn_reader(stream: IdeContextStream, client_id: String) -> Self { + let (commands_tx, commands_rx) = mpsc::channel(); + let reader = std::thread::spawn(move || { + run_ide_context_reader(stream, client_id, commands_rx); + }); + + Self { + commands: commands_tx, + reader: Some(reader), + } + } +} + +#[cfg(any(unix, windows))] +impl Drop for IdeContextClient { + fn drop(&mut self) { + let _ = self.commands.send(IdeContextClientCommand::Shutdown); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } } } @@ -276,9 +317,8 @@ fn fetch_ide_context_from_socket( workspace_root: &Path, timeout: Duration, ) -> Result { - let deadline = Instant::now() + timeout; - let mut client = IdeContextClient::connect_to_socket_before_deadline(socket_path, deadline)?; - client.fetch_ide_context_before_deadline(workspace_root, deadline) + let mut client = IdeContextClient::connect_to_socket(socket_path, timeout)?; + client.fetch_ide_context_with_timeout(workspace_root, timeout) } #[cfg(unix)] @@ -520,6 +560,158 @@ fn connect_stream( .map_err(IdeContextError::Connect) } +// This mirrors the desktop app's attached IPC message reader, not a context poller. IDE context is +// requested only by FetchIdeContext commands; idle reads just keep router broadcasts and discovery +// requests from piling up until the next prompt-submit deadline. +#[cfg(any(unix, windows))] +fn run_ide_context_reader( + mut stream: IdeContextStream, + client_id: String, + commands_rx: Receiver, +) { + let mut pending_request: Option = None; + + while let IdeContextReaderCommandState::Continue = drain_ide_context_reader_commands( + &mut stream, + &client_id, + &commands_rx, + &mut pending_request, + ) { + if pending_request + .as_ref() + .is_some_and(|pending| Instant::now() >= pending.deadline) + && let Some(pending) = pending_request.take() + { + let _ = pending.response_tx.send(Err(timeout_error())); + } + + let read_deadline = next_reader_deadline(pending_request.as_ref()); + stream.set_deadline(read_deadline); + match read_frame(&mut stream, read_deadline) { + Ok(message) => { + if let Err(err) = handle_reader_message(&mut stream, &mut pending_request, message) + { + if let Some(pending) = pending_request.take() { + let _ = pending.response_tx.send(Err(err)); + } + break; + } + } + Err(IdeContextError::Read(err)) if err.kind() == std::io::ErrorKind::TimedOut => {} + Err(err) => { + if let Some(pending) = pending_request.take() { + let _ = pending.response_tx.send(Err(err)); + } + break; + } + } + } +} + +#[cfg(any(unix, windows))] +enum IdeContextReaderCommandState { + Continue, + Shutdown, +} + +#[cfg(any(unix, windows))] +fn drain_ide_context_reader_commands( + stream: &mut IdeContextStream, + client_id: &str, + commands_rx: &Receiver, + pending_request: &mut Option, +) -> IdeContextReaderCommandState { + loop { + match commands_rx.try_recv() { + Ok(IdeContextClientCommand::FetchIdeContext { + workspace_root, + timeout, + response_tx, + }) => { + if pending_request.is_some() { + let _ = response_tx.send(Err(IdeContextError::RequestFailed( + "request-in-flight".to_string(), + ))); + continue; + } + + let request_id = uuid::Uuid::new_v4().to_string(); + let deadline = Instant::now() + timeout; + stream.set_deadline(deadline); + match write_ide_context_request(stream, &request_id, client_id, &workspace_root) { + Ok(()) => { + *pending_request = Some(PendingIdeContextRequest { + request_id, + deadline, + response_tx, + }); + } + Err(err) => { + let _ = response_tx.send(Err(IdeContextError::Send(err))); + return IdeContextReaderCommandState::Shutdown; + } + } + } + Ok(IdeContextClientCommand::Shutdown) | Err(mpsc::TryRecvError::Disconnected) => { + return IdeContextReaderCommandState::Shutdown; + } + Err(mpsc::TryRecvError::Empty) => { + return IdeContextReaderCommandState::Continue; + } + } + } +} + +#[cfg(any(unix, windows))] +fn next_reader_deadline(pending_request: Option<&PendingIdeContextRequest>) -> Instant { + let idle_deadline = Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT; + pending_request + .map(|pending| pending.deadline.min(idle_deadline)) + .unwrap_or(idle_deadline) +} + +#[cfg(any(unix, windows))] +fn handle_reader_message( + stream: &mut IdeContextStream, + pending_request: &mut Option, + message: Value, +) -> Result<(), IdeContextError> { + match message.get("type").and_then(Value::as_str) { + Some("response") => { + if pending_request.as_ref().is_some_and(|pending| { + message.get("requestId").and_then(Value::as_str) + == Some(pending.request_id.as_str()) + }) && let Some(pending) = pending_request.take() + { + let _ = pending.response_tx.send(extract_ide_context(message)); + } + Ok(()) + } + Some("broadcast") => Ok(()), + Some("client-discovery-request") => { + if let Some(discovery_request_id) = message.get("requestId").and_then(Value::as_str) { + let response = json!({ + "type": "client-discovery-response", + "requestId": discovery_request_id, + "response": { + "canHandle": false, + }, + }); + stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT); + write_frame(stream, &response).map_err(IdeContextError::Send)?; + } + Ok(()) + } + Some("client-discovery-response") | Some("request") => Ok(()), + Some(other) => Err(IdeContextError::InvalidResponse(format!( + "unexpected IDE context message type: {other}" + ))), + None => Err(IdeContextError::InvalidResponse( + "IDE context message did not include a type".to_string(), + )), + } +} + #[cfg(any(unix, windows))] fn initialize_client( stream: &mut T, @@ -543,16 +735,15 @@ fn initialize_client( } #[cfg(any(unix, windows))] -fn fetch_ide_context_with_client_id( +fn write_ide_context_request( stream: &mut T, + request_id: &str, client_id: &str, workspace_root: &Path, - deadline: Instant, -) -> Result { - let ide_context_request_id = uuid::Uuid::new_v4().to_string(); +) -> std::io::Result<()> { let ide_context_request = json!({ "type": "request", - "requestId": ide_context_request_id.clone(), + "requestId": request_id, "sourceClientId": client_id, "version": 0, "method": "ide-context", @@ -560,9 +751,7 @@ fn fetch_ide_context_with_client_id( "workspaceRoot": workspace_root.to_string_lossy(), }, }); - write_frame(stream, &ide_context_request).map_err(IdeContextError::Send)?; - let ide_context_response = read_response_frame(stream, &ide_context_request_id, deadline)?; - extract_ide_context(ide_context_response) + write_frame(stream, &ide_context_request) } #[cfg(any(unix, windows))] @@ -697,6 +886,14 @@ fn deadline_timeout_io_error() -> std::io::Error { ) } +#[cfg(any(unix, windows))] +fn ipc_reader_closed_io_error() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "IDE context IPC reader is closed", + ) +} + #[cfg(unix)] fn permission_denied_io_error(message: &'static str) -> std::io::Error { std::io::Error::new(std::io::ErrorKind::PermissionDenied, message) @@ -1068,6 +1265,87 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn ide_context_client_answers_discovery_while_idle() { + use std::os::unix::net::UnixListener; + use std::thread; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("codex-ipc.sock"); + let listener = UnixListener::bind(&socket_path).expect("bind socket"); + let (discovery_tx, discovery_rx) = std::sync::mpsc::channel(); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + + let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); + let initialize_request_id = initialize + .get("requestId") + .and_then(Value::as_str) + .expect("initialize request id"); + write_initialize_response(&mut stream, initialize_request_id); + + write_frame( + &mut stream, + &json!({ + "type": "client-discovery-request", + "requestId": "idle-discovery-request", + "request": { + "type": "request", + "method": "ide-context" + } + }), + ) + .expect("write idle client discovery request"); + let discovery_response = + read_frame(&mut stream, test_deadline()).expect("read client discovery response"); + assert_eq!( + discovery_response.get("type").and_then(Value::as_str), + Some("client-discovery-response") + ); + assert_eq!( + discovery_response.get("requestId").and_then(Value::as_str), + Some("idle-discovery-request") + ); + assert_eq!( + discovery_response + .get("response") + .and_then(|response| response.get("canHandle")) + .and_then(Value::as_bool), + Some(false) + ); + discovery_tx + .send(()) + .expect("notify idle discovery response was received"); + + let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); + let ide_context_request_id = ide_context + .get("requestId") + .and_then(Value::as_str) + .expect("ide-context request id"); + write_ide_context_response(&mut stream, ide_context_request_id, "fresh"); + }); + + let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) + .expect("connect IDE context client"); + discovery_rx + .recv_timeout(Duration::from_secs(1)) + .expect("idle client should answer discovery requests"); + let context = client + .fetch_ide_context_for_prompt(Path::new("/repo")) + .expect("fetch IDE context after idle discovery"); + + server.join().expect("server joins"); + assert_eq!( + context + .active_file + .as_ref() + .map(|file| file.active_selection_content.as_str()), + Some("fresh") + ); + } + #[cfg(unix)] #[test] fn ide_context_client_reuses_initialized_connection_for_prompt_requests() { From ba93b1355ad6130858dc514bf979602af5581306 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 22:12:39 -0700 Subject: [PATCH 14/35] codex: preserve IDE IPC framing while idle (#20294) --- codex-rs/tui/src/ide_context/ipc.rs | 151 +++++++++++++++++-- codex-rs/tui/src/ide_context/windows_pipe.rs | 36 +++++ 2 files changed, 173 insertions(+), 14 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index b64d92cb7427..9bfb45ab0343 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -24,6 +24,8 @@ const IDE_CONTEXT_PROBE_TIMEOUT: Duration = Duration::from_secs(6); const IDE_CONTEXT_PROMPT_TIMEOUT: Duration = Duration::from_millis(500); #[cfg(any(unix, windows))] const IDE_CONTEXT_IDLE_READ_TIMEOUT: Duration = Duration::from_millis(50); +#[cfg(any(unix, windows))] +const IDE_CONTEXT_IDLE_FRAME_TIMEOUT: Duration = Duration::from_secs(1); // Prompt rendering applies its own smaller cap to selected text before injection. #[cfg(any(unix, windows))] const MAX_IPC_FRAME_BYTES: usize = 256 * 1024 * 1024; @@ -585,7 +587,16 @@ fn run_ide_context_reader( let _ = pending.response_tx.send(Err(timeout_error())); } - let read_deadline = next_reader_deadline(pending_request.as_ref()); + let read_deadline = match next_reader_deadline(&mut stream, pending_request.as_ref()) { + Ok(Some(read_deadline)) => read_deadline, + Ok(None) => continue, + Err(err) => { + if let Some(pending) = pending_request.take() { + let _ = pending.response_tx.send(Err(err)); + } + break; + } + }; stream.set_deadline(read_deadline); match read_frame(&mut stream, read_deadline) { Ok(message) => { @@ -597,7 +608,6 @@ fn run_ide_context_reader( break; } } - Err(IdeContextError::Read(err)) if err.kind() == std::io::ErrorKind::TimedOut => {} Err(err) => { if let Some(pending) = pending_request.take() { let _ = pending.response_tx.send(Err(err)); @@ -663,11 +673,39 @@ fn drain_ide_context_reader_commands( } #[cfg(any(unix, windows))] -fn next_reader_deadline(pending_request: Option<&PendingIdeContextRequest>) -> Instant { +fn next_reader_deadline( + stream: &mut IdeContextStream, + pending_request: Option<&PendingIdeContextRequest>, +) -> Result, IdeContextError> { + if let Some(pending) = pending_request { + return Ok(Some(pending.deadline)); + } + + wait_for_idle_reader_message(stream) +} + +#[cfg(unix)] +fn wait_for_idle_reader_message( + stream: &mut IdeContextStream, +) -> Result, IdeContextError> { let idle_deadline = Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT; - pending_request - .map(|pending| pending.deadline.min(idle_deadline)) - .unwrap_or(idle_deadline) + stream.set_deadline(idle_deadline); + match stream.wait_for_ready(libc::POLLIN) { + Ok(()) => Ok(Some(Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT)), + Err(err) if err.kind() == std::io::ErrorKind::TimedOut => Ok(None), + Err(err) => Err(IdeContextError::Read(err)), + } +} + +#[cfg(windows)] +fn wait_for_idle_reader_message( + stream: &mut IdeContextStream, +) -> Result, IdeContextError> { + let idle_deadline = Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT; + stream + .wait_until_readable(idle_deadline) + .map(|readable| readable.then(|| Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT)) + .map_err(IdeContextError::Read) } #[cfg(any(unix, windows))] @@ -697,18 +735,12 @@ fn handle_reader_message( "canHandle": false, }, }); - stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT); + stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT); write_frame(stream, &response).map_err(IdeContextError::Send)?; } Ok(()) } - Some("client-discovery-response") | Some("request") => Ok(()), - Some(other) => Err(IdeContextError::InvalidResponse(format!( - "unexpected IDE context message type: {other}" - ))), - None => Err(IdeContextError::InvalidResponse( - "IDE context message did not include a type".to_string(), - )), + Some("client-discovery-response") | Some("request") | Some(_) | None => Ok(()), } } @@ -1011,6 +1043,27 @@ mod tests { .expect("write ide-context response"); } + #[cfg(unix)] + fn write_frame_in_two_chunks( + stream: &mut impl std::io::Write, + message: &Value, + delay: Duration, + ) { + let payload = serde_json::to_vec(message).expect("serialize test IPC message"); + let payload_len = u32::try_from(payload.len()) + .expect("test IPC message length fits u32") + .to_le_bytes(); + stream + .write_all(&payload_len) + .expect("write test IPC frame header"); + stream.flush().expect("flush test IPC frame header"); + std::thread::sleep(delay); + stream + .write_all(&payload) + .expect("write test IPC frame payload"); + stream.flush().expect("flush test IPC frame payload"); + } + #[cfg(any(unix, windows))] #[test] fn retryable_after_recent_toggle_covers_transient_errors() { @@ -1346,6 +1399,76 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn ide_context_idle_reader_preserves_split_frame() { + use std::os::unix::net::UnixListener; + use std::thread; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("codex-ipc.sock"); + let listener = UnixListener::bind(&socket_path).expect("bind socket"); + let (discovery_tx, discovery_rx) = std::sync::mpsc::channel(); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + + let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); + let initialize_request_id = initialize + .get("requestId") + .and_then(Value::as_str) + .expect("initialize request id"); + write_initialize_response(&mut stream, initialize_request_id); + + write_frame_in_two_chunks( + &mut stream, + &json!({ + "type": "client-discovery-request", + "requestId": "split-discovery-request", + "request": { + "type": "request", + "method": "ide-context" + } + }), + IDE_CONTEXT_IDLE_READ_TIMEOUT * 2, + ); + let discovery_response = + read_frame(&mut stream, test_deadline()).expect("read client discovery response"); + assert_eq!( + discovery_response.get("requestId").and_then(Value::as_str), + Some("split-discovery-request") + ); + discovery_tx + .send(()) + .expect("notify split discovery response was received"); + + let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); + let ide_context_request_id = ide_context + .get("requestId") + .and_then(Value::as_str) + .expect("ide-context request id"); + write_ide_context_response(&mut stream, ide_context_request_id, "after split frame"); + }); + + let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) + .expect("connect IDE context client"); + discovery_rx + .recv_timeout(Duration::from_secs(1)) + .expect("idle client should preserve and answer split frames"); + let context = client + .fetch_ide_context_for_prompt(Path::new("/repo")) + .expect("fetch IDE context after split idle frame"); + + server.join().expect("server joins"); + assert_eq!( + context + .active_file + .as_ref() + .map(|file| file.active_selection_content.as_str()), + Some("after split frame") + ); + } + #[cfg(unix)] #[test] fn ide_context_client_reuses_initialized_connection_for_prompt_requests() { diff --git a/codex-rs/tui/src/ide_context/windows_pipe.rs b/codex-rs/tui/src/ide_context/windows_pipe.rs index 8074b65e8045..e345fb298a40 100644 --- a/codex-rs/tui/src/ide_context/windows_pipe.rs +++ b/codex-rs/tui/src/ide_context/windows_pipe.rs @@ -6,6 +6,8 @@ use std::io::Write; use std::os::windows::ffi::OsStrExt; use std::path::PathBuf; use std::ptr; +use std::thread; +use std::time::Duration; use std::time::Instant; use windows_sys::Win32::Foundation::BOOL; @@ -36,6 +38,7 @@ use windows_sys::Win32::System::IO::CancelIoEx; use windows_sys::Win32::System::IO::GetOverlappedResult; use windows_sys::Win32::System::IO::OVERLAPPED; use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId; +use windows_sys::Win32::System::Pipes::PeekNamedPipe; use windows_sys::Win32::System::Threading::CreateEventW; use windows_sys::Win32::System::Threading::GetCurrentProcess; use windows_sys::Win32::System::Threading::OpenProcess; @@ -84,6 +87,39 @@ impl WindowsPipeStream { pub(super) fn set_deadline(&mut self, deadline: Instant) { self.deadline = deadline; } + + pub(super) fn wait_until_readable(&self, deadline: Instant) -> io::Result { + loop { + if self.has_bytes_available()? { + return Ok(true); + } + let now = Instant::now(); + if now >= deadline { + return Ok(false); + } + + thread::sleep((deadline - now).min(Duration::from_millis(5))); + } + } + + fn has_bytes_available(&self) -> io::Result { + let mut bytes_available = 0; + let result = unsafe { + PeekNamedPipe( + self.handle.raw(), + ptr::null_mut(), + 0, + ptr::null_mut(), + &mut bytes_available, + ptr::null_mut(), + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(bytes_available > 0) + } } impl Read for WindowsPipeStream { From 5efbf4c6d00d664a8d164192754793689fc76f02 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 22:14:45 -0700 Subject: [PATCH 15/35] codex: trim IDE context model (#20294) --- codex-rs/tui/src/ide_context.rs | 53 ++++++++++++++++---------- codex-rs/tui/src/ide_context/prompt.rs | 8 ---- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/codex-rs/tui/src/ide_context.rs b/codex-rs/tui/src/ide_context.rs index 33d3da6b78b4..356125f12649 100644 --- a/codex-rs/tui/src/ide_context.rs +++ b/codex-rs/tui/src/ide_context.rs @@ -19,8 +19,6 @@ pub(crate) struct IdeContext { pub(crate) active_file: Option, #[serde(default)] pub(crate) open_tabs: Vec, - #[serde(default)] - pub(crate) process_env: Option, } #[derive(Debug, Clone, Deserialize, PartialEq)] @@ -40,12 +38,6 @@ pub(crate) struct ActiveFile { pub(crate) struct FileDescriptor { pub(crate) label: String, pub(crate) path: String, - #[serde(rename = "fsPath")] - pub(crate) fs_path: String, - #[serde(default)] - pub(crate) start_line: Option, - #[serde(default)] - pub(crate) end_line: Option, } #[derive(Debug, Clone, Deserialize, PartialEq)] @@ -60,11 +52,6 @@ pub(crate) struct Position { pub(crate) character: u32, } -#[derive(Debug, Clone, Deserialize, PartialEq)] -pub(crate) struct IdeProcessEnv { - pub(crate) path: String, -} - #[cfg(test)] mod tests { use super::*; @@ -89,19 +76,43 @@ mod tests { { "label": "main.rs", "path": "src/main.rs", - "fsPath": "/repo/src/main.rs" + "fsPath": "/repo/src/main.rs", + "startLine": 2, + "endLine": 10 } - ] + ], + "processEnv": { + "path": "/usr/bin" + } }); let context: IdeContext = serde_json::from_value(value).expect("deserialize ide context"); assert_eq!( - context - .active_file - .as_ref() - .map(|file| file.descriptor.path.as_str()), - Some("src/lib.rs") + context, + IdeContext { + active_file: Some(ActiveFile { + descriptor: FileDescriptor { + label: "lib.rs".to_string(), + path: "src/lib.rs".to_string(), + }, + selection: Range { + start: Position { + line: 1, + character: 2, + }, + end: Position { + line: 3, + character: 4, + }, + }, + active_selection_content: "selected".to_string(), + selections: Vec::new(), + }), + open_tabs: vec![FileDescriptor { + label: "main.rs".to_string(), + path: "src/main.rs".to_string(), + }], + } ); - assert_eq!(context.open_tabs.len(), 1); } } diff --git a/codex-rs/tui/src/ide_context/prompt.rs b/codex-rs/tui/src/ide_context/prompt.rs index b1f4ab02584b..0eeee1870229 100644 --- a/codex-rs/tui/src/ide_context/prompt.rs +++ b/codex-rs/tui/src/ide_context/prompt.rs @@ -163,9 +163,6 @@ mod tests { FileDescriptor { label: label.to_string(), path: path.to_string(), - fs_path: format!("/repo/{path}"), - start_line: None, - end_line: None, } } @@ -191,7 +188,6 @@ mod tests { descriptor("lib.rs", "src/lib.rs"), descriptor("main.rs", "src/main.rs"), ], - process_env: None, }; assert_eq!( @@ -208,7 +204,6 @@ mod tests { let context = IdeContext { active_file: None, open_tabs: Vec::new(), - process_env: None, }; assert_eq!(render_prompt_context(&context), None); @@ -233,7 +228,6 @@ mod tests { selections: Vec::new(), }), open_tabs: Vec::new(), - process_env: None, }; let text = "Ask $figma".to_string(); let mut items = vec![ @@ -308,7 +302,6 @@ mod tests { selections: vec![first_range, second_range], }), open_tabs: Vec::new(), - process_env: None, }; assert_eq!( @@ -339,7 +332,6 @@ mod tests { selections: Vec::new(), }), open_tabs: Vec::new(), - process_env: None, }; let rendered = render_prompt_context(&context).expect("rendered IDE context"); From aa60e625bc26f7e27bd38dbe1c0a3a5f743d4116 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 22:17:09 -0700 Subject: [PATCH 16/35] codex: remove redundant IDE context tests (#20294) --- codex-rs/tui/src/ide_context/ipc.rs | 145 ---------------------------- codex-rs/tui/src/slash_command.rs | 6 -- 2 files changed, 151 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 9bfb45ab0343..a4b72e9fe8b1 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -1064,70 +1064,6 @@ mod tests { stream.flush().expect("flush test IPC frame payload"); } - #[cfg(any(unix, windows))] - #[test] - fn retryable_after_recent_toggle_covers_transient_errors() { - assert!( - IdeContextError::RequestFailed("no-client-found".to_string()) - .is_retryable_after_recent_toggle() - ); - assert!( - IdeContextError::RequestFailed("client-disconnected".to_string()) - .is_retryable_after_recent_toggle() - ); - assert!( - !IdeContextError::RequestFailed("request-timeout".to_string()) - .is_retryable_after_recent_toggle() - ); - assert!( - IdeContextError::Read(std::io::Error::from(std::io::ErrorKind::WouldBlock)) - .is_retryable_after_recent_toggle() - ); - assert!( - !IdeContextError::Read(std::io::Error::from(std::io::ErrorKind::TimedOut)) - .is_retryable_after_recent_toggle() - ); - assert!( - !IdeContextError::RequestFailed("other-error".to_string()) - .is_retryable_after_recent_toggle() - ); - assert!( - !IdeContextError::InvalidResponse("bad payload".to_string()) - .is_retryable_after_recent_toggle() - ); - assert!(!IdeContextError::ResponseTooLarge.is_retryable_after_recent_toggle()); - } - - #[cfg(any(unix, windows))] - #[test] - fn read_response_frame_respects_expired_deadline() { - let mut stream = std::io::Cursor::new(Vec::new()); - write_frame( - &mut stream, - &json!({ - "type": "broadcast", - "method": "client-status-changed", - "sourceClientId": "vscode-client", - "version": 0, - "params": { - "clientId": "vscode-client", - "clientType": "vscode", - "status": "connected" - } - }), - ) - .expect("write broadcast frame"); - stream.set_position(0); - - let err = read_response_frame(&mut stream, "missing-request", Instant::now()) - .expect_err("expired deadline should fail before reading"); - - assert!(matches!( - err, - IdeContextError::Read(error) if error.kind() == std::io::ErrorKind::TimedOut - )); - } - #[cfg(any(unix, windows))] #[test] fn read_frame_respects_deadline_while_reading_payload() { @@ -1318,87 +1254,6 @@ mod tests { ); } - #[cfg(unix)] - #[test] - fn ide_context_client_answers_discovery_while_idle() { - use std::os::unix::net::UnixListener; - use std::thread; - - let tempdir = tempfile::tempdir().expect("tempdir"); - let socket_path = tempdir.path().join("codex-ipc.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind socket"); - let (discovery_tx, discovery_rx) = std::sync::mpsc::channel(); - - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - - let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); - let initialize_request_id = initialize - .get("requestId") - .and_then(Value::as_str) - .expect("initialize request id"); - write_initialize_response(&mut stream, initialize_request_id); - - write_frame( - &mut stream, - &json!({ - "type": "client-discovery-request", - "requestId": "idle-discovery-request", - "request": { - "type": "request", - "method": "ide-context" - } - }), - ) - .expect("write idle client discovery request"); - let discovery_response = - read_frame(&mut stream, test_deadline()).expect("read client discovery response"); - assert_eq!( - discovery_response.get("type").and_then(Value::as_str), - Some("client-discovery-response") - ); - assert_eq!( - discovery_response.get("requestId").and_then(Value::as_str), - Some("idle-discovery-request") - ); - assert_eq!( - discovery_response - .get("response") - .and_then(|response| response.get("canHandle")) - .and_then(Value::as_bool), - Some(false) - ); - discovery_tx - .send(()) - .expect("notify idle discovery response was received"); - - let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); - let ide_context_request_id = ide_context - .get("requestId") - .and_then(Value::as_str) - .expect("ide-context request id"); - write_ide_context_response(&mut stream, ide_context_request_id, "fresh"); - }); - - let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) - .expect("connect IDE context client"); - discovery_rx - .recv_timeout(Duration::from_secs(1)) - .expect("idle client should answer discovery requests"); - let context = client - .fetch_ide_context_for_prompt(Path::new("/repo")) - .expect("fetch IDE context after idle discovery"); - - server.join().expect("server joins"); - assert_eq!( - context - .active_file - .as_ref() - .map(|file| file.active_selection_content.as_str()), - Some("fresh") - ); - } - #[cfg(unix)] #[test] fn ide_context_idle_reader_preserves_split_frame() { diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 9f29ec4c774d..f77b21295522 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -269,12 +269,6 @@ mod tests { assert!(SlashCommand::Statusline.available_during_task()); } - #[test] - fn ide_command_accepts_state_args() { - assert_eq!(SlashCommand::from_str("ide"), Ok(SlashCommand::Ide)); - assert!(SlashCommand::Ide.supports_inline_args()); - } - #[test] fn auto_review_command_is_autoreview() { assert_eq!(SlashCommand::AutoReview.command(), "autoreview"); From 867005ce1035231c0df572e5a911a2aa9f845ec7 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 22:26:43 -0700 Subject: [PATCH 17/35] codex: match IDE context IPC timeout Addresses #13834 by matching the TUI IDE context request timeout to the desktop IPC client timeout, avoiding false prompt-time skips when the IDE answers after 500ms. --- codex-rs/tui/src/ide_context/ipc.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index a4b72e9fe8b1..242b47b063f9 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -17,11 +17,11 @@ use thiserror::Error; use super::IdeContext; -// The desktop integration can take several seconds to determine whether an IDE can answer an -// initial probe. Keep this long enough that transient local read timeouts do not prevent enabling -// the feature, but use a much shorter budget on the prompt-submit path below. -const IDE_CONTEXT_PROBE_TIMEOUT: Duration = Duration::from_secs(6); -const IDE_CONTEXT_PROMPT_TIMEOUT: Duration = Duration::from_millis(500); +// The desktop IPC client gives requests 5 seconds to complete. Match that prompt-time budget here: +// fetching IDE context includes router discovery and extension event-loop work, so a shorter TUI +// deadline can incorrectly skip context even though the IDE answers normally. +const IDE_CONTEXT_PROMPT_TIMEOUT: Duration = Duration::from_secs(5); +const IDE_CONTEXT_PROBE_TIMEOUT: Duration = IDE_CONTEXT_PROMPT_TIMEOUT; #[cfg(any(unix, windows))] const IDE_CONTEXT_IDLE_READ_TIMEOUT: Duration = Duration::from_millis(50); #[cfg(any(unix, windows))] @@ -112,12 +112,16 @@ impl IdeContextError { "Open this project in VS Code or Cursor with the Codex extension active." .to_string() } + IdeContextError::Read(error) if error.kind() == std::io::ErrorKind::TimedOut => { + "Codex timed out waiting for IDE context. It will keep trying on future messages." + .to_string() + } IdeContextError::Send(_) - | IdeContextError::Read(_) | IdeContextError::InvalidResponse(_) | IdeContextError::RequestFailed(_) => { "Codex will keep trying on future messages.".to_string() } + IdeContextError::Read(_) => "Codex will keep trying on future messages.".to_string(), } } From 605d097c25b2dc407156d6b349c64a069c53d4dc Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 22:31:47 -0700 Subject: [PATCH 18/35] codex: retry stale IDE context clients Addresses #13834 by retrying prompt-time IDE context fetches after stale IPC clients and replacing the generic prompt skip hint with specific failure guidance. --- codex-rs/tui/src/chatwidget/ide_context.rs | 36 +++++++++----- codex-rs/tui/src/ide_context/ipc.rs | 58 ++++++++++++++++++++-- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/ide_context.rs b/codex-rs/tui/src/chatwidget/ide_context.rs index b3ec34cc0441..95bdfc55e1c9 100644 --- a/codex-rs/tui/src/chatwidget/ide_context.rs +++ b/codex-rs/tui/src/chatwidget/ide_context.rs @@ -83,19 +83,31 @@ impl IdeContextState { &mut self, workspace_root: &Path, ) -> Result { - let client = if let Some(client) = self.client.as_mut() { - client - } else { - self.client.insert(IdeContextClient::connect_for_prompt()?) - }; - - let result = client.fetch_ide_context_for_prompt(workspace_root); - if let Err(err) = &result - && err.should_reset_client() - { - self.client = None; + let mut retried_after_reset = false; + loop { + let client = if let Some(client) = self.client.as_mut() { + client + } else { + self.client.insert(IdeContextClient::connect_for_prompt()?) + }; + + let result = client.fetch_ide_context_for_prompt(workspace_root); + match result { + Ok(context) => return Ok(context), + Err(err) => { + let should_retry = + !retried_after_reset && err.should_retry_prompt_fetch_after_reset(); + if err.should_reset_client() || should_retry { + self.client = None; + } + if should_retry { + retried_after_reset = true; + continue; + } + return Err(err); + } + } } - result } } diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 242b47b063f9..c818a0cce248 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -116,12 +116,55 @@ impl IdeContextError { "Codex timed out waiting for IDE context. It will keep trying on future messages." .to_string() } - IdeContextError::Send(_) - | IdeContextError::InvalidResponse(_) - | IdeContextError::RequestFailed(_) => { - "Codex will keep trying on future messages.".to_string() + IdeContextError::RequestFailed(error) if error == "client-disconnected" => { + "The IDE connection changed while Codex was requesting context. Codex will keep trying on future messages." + .to_string() + } + IdeContextError::RequestFailed(error) if error == "request-timeout" => { + "The IDE extension did not answer in time. Codex will keep trying on future messages." + .to_string() + } + IdeContextError::RequestFailed(error) if error == "request-version-mismatch" => { + "The connected IDE extension is not compatible with this IDE context request." + .to_string() + } + IdeContextError::RequestFailed(error) if error == "no-handler-for-request" => { + "The connected IDE client does not support IDE context requests.".to_string() + } + IdeContextError::Send(_) => { + "Codex lost the IDE connection while requesting context. Codex will keep trying on future messages." + .to_string() + } + IdeContextError::InvalidResponse(_) => { + "Codex received an unexpected IDE context response. Codex will keep trying on future messages." + .to_string() + } + IdeContextError::RequestFailed(_) => { + "The IDE extension did not provide context. Codex will keep trying on future messages." + .to_string() } - IdeContextError::Read(_) => "Codex will keep trying on future messages.".to_string(), + IdeContextError::Read(_) => { + "Codex could not read IDE context. Codex will keep trying on future messages." + .to_string() + } + } + } + + #[cfg(any(unix, windows))] + pub(crate) fn should_retry_prompt_fetch_after_reset(&self) -> bool { + match self { + IdeContextError::Send(_) => true, + IdeContextError::Read(error) => matches!( + error.kind(), + std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::UnexpectedEof + ), + IdeContextError::RequestFailed(error) => error == "client-disconnected", + IdeContextError::Connect(_) + | IdeContextError::InvalidResponse(_) + | IdeContextError::ResponseTooLarge => false, } } @@ -156,6 +199,11 @@ impl IdeContextError { pub(crate) fn should_reset_client(&self) -> bool { false } + + #[cfg(not(any(unix, windows)))] + pub(crate) fn should_retry_prompt_fetch_after_reset(&self) -> bool { + false + } } /// Persistent IPC client used while TUI `/ide` mode is enabled. From 1502d5471b0d0cc9f5fbf913a9e73aa9d9bb6d79 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 22:38:48 -0700 Subject: [PATCH 19/35] codex: simplify IDE context IPC fetch Addresses #13834 by removing the prompt-time background reader path and fetching IDE context synchronously over the persistent IPC connection. --- codex-rs/tui/src/ide_context/ipc.rs | 373 ++----------------- codex-rs/tui/src/ide_context/windows_pipe.rs | 36 -- 2 files changed, 28 insertions(+), 381 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index c818a0cce248..036dc39f4220 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -2,10 +2,6 @@ use std::path::Path; use std::path::PathBuf; -use std::sync::mpsc; -use std::sync::mpsc::Receiver; -use std::sync::mpsc::Sender; -use std::thread::JoinHandle; use std::time::Duration; use std::time::Instant; @@ -22,10 +18,6 @@ use super::IdeContext; // deadline can incorrectly skip context even though the IDE answers normally. const IDE_CONTEXT_PROMPT_TIMEOUT: Duration = Duration::from_secs(5); const IDE_CONTEXT_PROBE_TIMEOUT: Duration = IDE_CONTEXT_PROMPT_TIMEOUT; -#[cfg(any(unix, windows))] -const IDE_CONTEXT_IDLE_READ_TIMEOUT: Duration = Duration::from_millis(50); -#[cfg(any(unix, windows))] -const IDE_CONTEXT_IDLE_FRAME_TIMEOUT: Duration = Duration::from_secs(1); // Prompt rendering applies its own smaller cap to selected text before injection. #[cfg(any(unix, windows))] const MAX_IPC_FRAME_BYTES: usize = 256 * 1024 * 1024; @@ -209,11 +201,11 @@ impl IdeContextError { /// Persistent IPC client used while TUI `/ide` mode is enabled. /// /// The initial connection and initialize handshake happen once on `/ide on`, and each user turn -/// asks for a fresh IDE context snapshot over the same route with a short prompt-time deadline. +/// asks for a fresh IDE context snapshot synchronously over the same route. #[cfg(any(unix, windows))] pub(crate) struct IdeContextClient { - commands: Sender, - reader: Option>, + stream: IdeContextStream, + client_id: String, } #[cfg(unix)] @@ -222,23 +214,6 @@ type IdeContextStream = UnixDeadlineStream; #[cfg(windows)] type IdeContextStream = super::windows_pipe::WindowsPipeStream; -#[cfg(any(unix, windows))] -enum IdeContextClientCommand { - FetchIdeContext { - workspace_root: PathBuf, - timeout: Duration, - response_tx: Sender>, - }, - Shutdown, -} - -#[cfg(any(unix, windows))] -struct PendingIdeContextRequest { - request_id: String, - deadline: Instant, - response_tx: Sender>, -} - #[cfg(any(unix, windows))] impl IdeContextClient { pub(crate) fn connect() -> Result { @@ -274,7 +249,7 @@ impl IdeContextClient { ) -> Result { let mut stream = connect_stream(socket_path, deadline)?; let client_id = initialize_client(&mut stream, deadline)?; - Ok(Self::spawn_reader(stream, client_id)) + Ok(Self { stream, client_id }) } fn fetch_ide_context_with_timeout( @@ -282,40 +257,18 @@ impl IdeContextClient { workspace_root: &Path, timeout: Duration, ) -> Result { - let (response_tx, response_rx) = mpsc::channel(); - self.commands - .send(IdeContextClientCommand::FetchIdeContext { - workspace_root: workspace_root.to_path_buf(), - timeout, - response_tx, - }) - .map_err(|_| IdeContextError::Send(ipc_reader_closed_io_error()))?; - - response_rx - .recv_timeout(timeout + IDE_CONTEXT_IDLE_READ_TIMEOUT) - .unwrap_or_else(|_| Err(timeout_error())) - } - - fn spawn_reader(stream: IdeContextStream, client_id: String) -> Self { - let (commands_tx, commands_rx) = mpsc::channel(); - let reader = std::thread::spawn(move || { - run_ide_context_reader(stream, client_id, commands_rx); - }); - - Self { - commands: commands_tx, - reader: Some(reader), - } - } -} - -#[cfg(any(unix, windows))] -impl Drop for IdeContextClient { - fn drop(&mut self) { - let _ = self.commands.send(IdeContextClientCommand::Shutdown); - if let Some(reader) = self.reader.take() { - let _ = reader.join(); - } + let request_id = uuid::Uuid::new_v4().to_string(); + let deadline = Instant::now() + timeout; + self.stream.set_deadline(deadline); + write_ide_context_request( + &mut self.stream, + &request_id, + &self.client_id, + workspace_root, + ) + .map_err(IdeContextError::Send)?; + let response = read_response_frame(&mut self.stream, &request_id, deadline)?; + extract_ide_context(response) } } @@ -614,188 +567,6 @@ fn connect_stream( .map_err(IdeContextError::Connect) } -// This mirrors the desktop app's attached IPC message reader, not a context poller. IDE context is -// requested only by FetchIdeContext commands; idle reads just keep router broadcasts and discovery -// requests from piling up until the next prompt-submit deadline. -#[cfg(any(unix, windows))] -fn run_ide_context_reader( - mut stream: IdeContextStream, - client_id: String, - commands_rx: Receiver, -) { - let mut pending_request: Option = None; - - while let IdeContextReaderCommandState::Continue = drain_ide_context_reader_commands( - &mut stream, - &client_id, - &commands_rx, - &mut pending_request, - ) { - if pending_request - .as_ref() - .is_some_and(|pending| Instant::now() >= pending.deadline) - && let Some(pending) = pending_request.take() - { - let _ = pending.response_tx.send(Err(timeout_error())); - } - - let read_deadline = match next_reader_deadline(&mut stream, pending_request.as_ref()) { - Ok(Some(read_deadline)) => read_deadline, - Ok(None) => continue, - Err(err) => { - if let Some(pending) = pending_request.take() { - let _ = pending.response_tx.send(Err(err)); - } - break; - } - }; - stream.set_deadline(read_deadline); - match read_frame(&mut stream, read_deadline) { - Ok(message) => { - if let Err(err) = handle_reader_message(&mut stream, &mut pending_request, message) - { - if let Some(pending) = pending_request.take() { - let _ = pending.response_tx.send(Err(err)); - } - break; - } - } - Err(err) => { - if let Some(pending) = pending_request.take() { - let _ = pending.response_tx.send(Err(err)); - } - break; - } - } - } -} - -#[cfg(any(unix, windows))] -enum IdeContextReaderCommandState { - Continue, - Shutdown, -} - -#[cfg(any(unix, windows))] -fn drain_ide_context_reader_commands( - stream: &mut IdeContextStream, - client_id: &str, - commands_rx: &Receiver, - pending_request: &mut Option, -) -> IdeContextReaderCommandState { - loop { - match commands_rx.try_recv() { - Ok(IdeContextClientCommand::FetchIdeContext { - workspace_root, - timeout, - response_tx, - }) => { - if pending_request.is_some() { - let _ = response_tx.send(Err(IdeContextError::RequestFailed( - "request-in-flight".to_string(), - ))); - continue; - } - - let request_id = uuid::Uuid::new_v4().to_string(); - let deadline = Instant::now() + timeout; - stream.set_deadline(deadline); - match write_ide_context_request(stream, &request_id, client_id, &workspace_root) { - Ok(()) => { - *pending_request = Some(PendingIdeContextRequest { - request_id, - deadline, - response_tx, - }); - } - Err(err) => { - let _ = response_tx.send(Err(IdeContextError::Send(err))); - return IdeContextReaderCommandState::Shutdown; - } - } - } - Ok(IdeContextClientCommand::Shutdown) | Err(mpsc::TryRecvError::Disconnected) => { - return IdeContextReaderCommandState::Shutdown; - } - Err(mpsc::TryRecvError::Empty) => { - return IdeContextReaderCommandState::Continue; - } - } - } -} - -#[cfg(any(unix, windows))] -fn next_reader_deadline( - stream: &mut IdeContextStream, - pending_request: Option<&PendingIdeContextRequest>, -) -> Result, IdeContextError> { - if let Some(pending) = pending_request { - return Ok(Some(pending.deadline)); - } - - wait_for_idle_reader_message(stream) -} - -#[cfg(unix)] -fn wait_for_idle_reader_message( - stream: &mut IdeContextStream, -) -> Result, IdeContextError> { - let idle_deadline = Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT; - stream.set_deadline(idle_deadline); - match stream.wait_for_ready(libc::POLLIN) { - Ok(()) => Ok(Some(Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT)), - Err(err) if err.kind() == std::io::ErrorKind::TimedOut => Ok(None), - Err(err) => Err(IdeContextError::Read(err)), - } -} - -#[cfg(windows)] -fn wait_for_idle_reader_message( - stream: &mut IdeContextStream, -) -> Result, IdeContextError> { - let idle_deadline = Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT; - stream - .wait_until_readable(idle_deadline) - .map(|readable| readable.then(|| Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT)) - .map_err(IdeContextError::Read) -} - -#[cfg(any(unix, windows))] -fn handle_reader_message( - stream: &mut IdeContextStream, - pending_request: &mut Option, - message: Value, -) -> Result<(), IdeContextError> { - match message.get("type").and_then(Value::as_str) { - Some("response") => { - if pending_request.as_ref().is_some_and(|pending| { - message.get("requestId").and_then(Value::as_str) - == Some(pending.request_id.as_str()) - }) && let Some(pending) = pending_request.take() - { - let _ = pending.response_tx.send(extract_ide_context(message)); - } - Ok(()) - } - Some("broadcast") => Ok(()), - Some("client-discovery-request") => { - if let Some(discovery_request_id) = message.get("requestId").and_then(Value::as_str) { - let response = json!({ - "type": "client-discovery-response", - "requestId": discovery_request_id, - "response": { - "canHandle": false, - }, - }); - stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT); - write_frame(stream, &response).map_err(IdeContextError::Send)?; - } - Ok(()) - } - Some("client-discovery-response") | Some("request") | Some(_) | None => Ok(()), - } -} - #[cfg(any(unix, windows))] fn initialize_client( stream: &mut T, @@ -933,7 +704,18 @@ fn read_response_frame( write_frame(stream, &response).map_err(IdeContextError::Send)?; } } - Some("client-discovery-response") | Some("request") => {} + Some("request") => { + if let Some(inbound_request_id) = message.get("requestId").and_then(Value::as_str) { + let response = json!({ + "type": "response", + "requestId": inbound_request_id, + "resultType": "error", + "error": "no-handler-for-request", + }); + write_frame(stream, &response).map_err(IdeContextError::Send)?; + } + } + Some("client-discovery-response") => {} Some(other) => { return Err(IdeContextError::InvalidResponse(format!( "unexpected IDE context message type: {other}" @@ -970,14 +752,6 @@ fn deadline_timeout_io_error() -> std::io::Error { ) } -#[cfg(any(unix, windows))] -fn ipc_reader_closed_io_error() -> std::io::Error { - std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "IDE context IPC reader is closed", - ) -} - #[cfg(unix)] fn permission_denied_io_error(message: &'static str) -> std::io::Error { std::io::Error::new(std::io::ErrorKind::PermissionDenied, message) @@ -1095,27 +869,6 @@ mod tests { .expect("write ide-context response"); } - #[cfg(unix)] - fn write_frame_in_two_chunks( - stream: &mut impl std::io::Write, - message: &Value, - delay: Duration, - ) { - let payload = serde_json::to_vec(message).expect("serialize test IPC message"); - let payload_len = u32::try_from(payload.len()) - .expect("test IPC message length fits u32") - .to_le_bytes(); - stream - .write_all(&payload_len) - .expect("write test IPC frame header"); - stream.flush().expect("flush test IPC frame header"); - std::thread::sleep(delay); - stream - .write_all(&payload) - .expect("write test IPC frame payload"); - stream.flush().expect("flush test IPC frame payload"); - } - #[cfg(any(unix, windows))] #[test] fn read_frame_respects_deadline_while_reading_payload() { @@ -1306,76 +1059,6 @@ mod tests { ); } - #[cfg(unix)] - #[test] - fn ide_context_idle_reader_preserves_split_frame() { - use std::os::unix::net::UnixListener; - use std::thread; - - let tempdir = tempfile::tempdir().expect("tempdir"); - let socket_path = tempdir.path().join("codex-ipc.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind socket"); - let (discovery_tx, discovery_rx) = std::sync::mpsc::channel(); - - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - - let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); - let initialize_request_id = initialize - .get("requestId") - .and_then(Value::as_str) - .expect("initialize request id"); - write_initialize_response(&mut stream, initialize_request_id); - - write_frame_in_two_chunks( - &mut stream, - &json!({ - "type": "client-discovery-request", - "requestId": "split-discovery-request", - "request": { - "type": "request", - "method": "ide-context" - } - }), - IDE_CONTEXT_IDLE_READ_TIMEOUT * 2, - ); - let discovery_response = - read_frame(&mut stream, test_deadline()).expect("read client discovery response"); - assert_eq!( - discovery_response.get("requestId").and_then(Value::as_str), - Some("split-discovery-request") - ); - discovery_tx - .send(()) - .expect("notify split discovery response was received"); - - let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); - let ide_context_request_id = ide_context - .get("requestId") - .and_then(Value::as_str) - .expect("ide-context request id"); - write_ide_context_response(&mut stream, ide_context_request_id, "after split frame"); - }); - - let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) - .expect("connect IDE context client"); - discovery_rx - .recv_timeout(Duration::from_secs(1)) - .expect("idle client should preserve and answer split frames"); - let context = client - .fetch_ide_context_for_prompt(Path::new("/repo")) - .expect("fetch IDE context after split idle frame"); - - server.join().expect("server joins"); - assert_eq!( - context - .active_file - .as_ref() - .map(|file| file.active_selection_content.as_str()), - Some("after split frame") - ); - } - #[cfg(unix)] #[test] fn ide_context_client_reuses_initialized_connection_for_prompt_requests() { diff --git a/codex-rs/tui/src/ide_context/windows_pipe.rs b/codex-rs/tui/src/ide_context/windows_pipe.rs index e345fb298a40..8074b65e8045 100644 --- a/codex-rs/tui/src/ide_context/windows_pipe.rs +++ b/codex-rs/tui/src/ide_context/windows_pipe.rs @@ -6,8 +6,6 @@ use std::io::Write; use std::os::windows::ffi::OsStrExt; use std::path::PathBuf; use std::ptr; -use std::thread; -use std::time::Duration; use std::time::Instant; use windows_sys::Win32::Foundation::BOOL; @@ -38,7 +36,6 @@ use windows_sys::Win32::System::IO::CancelIoEx; use windows_sys::Win32::System::IO::GetOverlappedResult; use windows_sys::Win32::System::IO::OVERLAPPED; use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId; -use windows_sys::Win32::System::Pipes::PeekNamedPipe; use windows_sys::Win32::System::Threading::CreateEventW; use windows_sys::Win32::System::Threading::GetCurrentProcess; use windows_sys::Win32::System::Threading::OpenProcess; @@ -87,39 +84,6 @@ impl WindowsPipeStream { pub(super) fn set_deadline(&mut self, deadline: Instant) { self.deadline = deadline; } - - pub(super) fn wait_until_readable(&self, deadline: Instant) -> io::Result { - loop { - if self.has_bytes_available()? { - return Ok(true); - } - let now = Instant::now(); - if now >= deadline { - return Ok(false); - } - - thread::sleep((deadline - now).min(Duration::from_millis(5))); - } - } - - fn has_bytes_available(&self) -> io::Result { - let mut bytes_available = 0; - let result = unsafe { - PeekNamedPipe( - self.handle.raw(), - ptr::null_mut(), - 0, - ptr::null_mut(), - &mut bytes_available, - ptr::null_mut(), - ) - }; - if result == 0 { - return Err(io::Error::last_os_error()); - } - - Ok(bytes_available > 0) - } } impl Read for WindowsPipeStream { From 221f26d60d245c1bb86b2596e46fcd682783c104 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 23:02:28 -0700 Subject: [PATCH 20/35] codex: keep IDE IPC reader attached --- codex-rs/tui/src/ide_context/ipc.rs | 537 ++++++++++++++++++- codex-rs/tui/src/ide_context/windows_pipe.rs | 36 ++ 2 files changed, 545 insertions(+), 28 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 036dc39f4220..e76a6f14ab3d 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -2,6 +2,10 @@ use std::path::Path; use std::path::PathBuf; +use std::sync::mpsc; +use std::sync::mpsc::Receiver; +use std::sync::mpsc::Sender; +use std::thread::JoinHandle; use std::time::Duration; use std::time::Instant; @@ -18,6 +22,10 @@ use super::IdeContext; // deadline can incorrectly skip context even though the IDE answers normally. const IDE_CONTEXT_PROMPT_TIMEOUT: Duration = Duration::from_secs(5); const IDE_CONTEXT_PROBE_TIMEOUT: Duration = IDE_CONTEXT_PROMPT_TIMEOUT; +#[cfg(any(unix, windows))] +const IDE_CONTEXT_IDLE_READ_TIMEOUT: Duration = Duration::from_millis(50); +#[cfg(any(unix, windows))] +const IDE_CONTEXT_IDLE_FRAME_TIMEOUT: Duration = Duration::from_secs(1); // Prompt rendering applies its own smaller cap to selected text before injection. #[cfg(any(unix, windows))] const MAX_IPC_FRAME_BYTES: usize = 256 * 1024 * 1024; @@ -201,11 +209,11 @@ impl IdeContextError { /// Persistent IPC client used while TUI `/ide` mode is enabled. /// /// The initial connection and initialize handshake happen once on `/ide on`, and each user turn -/// asks for a fresh IDE context snapshot synchronously over the same route. +/// asks for a fresh IDE context snapshot over the same route with a short prompt-time deadline. #[cfg(any(unix, windows))] pub(crate) struct IdeContextClient { - stream: IdeContextStream, - client_id: String, + commands: Sender, + reader: Option>, } #[cfg(unix)] @@ -214,6 +222,23 @@ type IdeContextStream = UnixDeadlineStream; #[cfg(windows)] type IdeContextStream = super::windows_pipe::WindowsPipeStream; +#[cfg(any(unix, windows))] +enum IdeContextClientCommand { + FetchIdeContext { + workspace_root: PathBuf, + timeout: Duration, + response_tx: Sender>, + }, + Shutdown, +} + +#[cfg(any(unix, windows))] +struct PendingIdeContextRequest { + request_id: String, + deadline: Instant, + response_tx: Sender>, +} + #[cfg(any(unix, windows))] impl IdeContextClient { pub(crate) fn connect() -> Result { @@ -249,7 +274,7 @@ impl IdeContextClient { ) -> Result { let mut stream = connect_stream(socket_path, deadline)?; let client_id = initialize_client(&mut stream, deadline)?; - Ok(Self { stream, client_id }) + Ok(Self::spawn_reader(stream, client_id)) } fn fetch_ide_context_with_timeout( @@ -257,18 +282,40 @@ impl IdeContextClient { workspace_root: &Path, timeout: Duration, ) -> Result { - let request_id = uuid::Uuid::new_v4().to_string(); - let deadline = Instant::now() + timeout; - self.stream.set_deadline(deadline); - write_ide_context_request( - &mut self.stream, - &request_id, - &self.client_id, - workspace_root, - ) - .map_err(IdeContextError::Send)?; - let response = read_response_frame(&mut self.stream, &request_id, deadline)?; - extract_ide_context(response) + let (response_tx, response_rx) = mpsc::channel(); + self.commands + .send(IdeContextClientCommand::FetchIdeContext { + workspace_root: workspace_root.to_path_buf(), + timeout, + response_tx, + }) + .map_err(|_| IdeContextError::Send(ipc_reader_closed_io_error()))?; + + response_rx + .recv_timeout(timeout + IDE_CONTEXT_IDLE_READ_TIMEOUT) + .unwrap_or_else(|_| Err(timeout_error())) + } + + fn spawn_reader(stream: IdeContextStream, client_id: String) -> Self { + let (commands_tx, commands_rx) = mpsc::channel(); + let reader = std::thread::spawn(move || { + run_ide_context_reader(stream, client_id, commands_rx); + }); + + Self { + commands: commands_tx, + reader: Some(reader), + } + } +} + +#[cfg(any(unix, windows))] +impl Drop for IdeContextClient { + fn drop(&mut self) { + let _ = self.commands.send(IdeContextClientCommand::Shutdown); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } } } @@ -405,6 +452,36 @@ impl UnixDeadlineStream { } } } + + fn is_readable_now(&self) -> std::io::Result { + use std::os::fd::AsRawFd; + + loop { + let mut poll_fd = libc::pollfd { + fd: self.stream.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + let result = unsafe { libc::poll(&mut poll_fd, 1, 0) }; + if result == 0 { + return Ok(false); + } + if result < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error); + } + if poll_fd.revents & libc::POLLNVAL != 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid IDE context Unix socket", + )); + } + return Ok(poll_fd.revents & (libc::POLLIN | libc::POLLERR | libc::POLLHUP) != 0); + } + } } #[cfg(unix)] @@ -567,6 +644,253 @@ fn connect_stream( .map_err(IdeContextError::Connect) } +// This mirrors the desktop app's attached IPC message reader, not a context poller. IDE context is +// requested only by FetchIdeContext commands; idle reads just keep router broadcasts and discovery +// requests from piling up until the next prompt-submit deadline. +#[cfg(any(unix, windows))] +fn run_ide_context_reader( + mut stream: IdeContextStream, + client_id: String, + commands_rx: Receiver, +) { + let mut pending_request: Option = None; + + loop { + if pending_request.is_none() { + match read_idle_reader_message_if_available(&mut stream, &mut pending_request) { + Ok(true) => continue, + Ok(false) => {} + Err(err) => { + if let Some(pending) = pending_request.take() { + let _ = pending.response_tx.send(Err(err)); + } + break; + } + } + } + + if matches!( + drain_ide_context_reader_commands( + &mut stream, + &client_id, + &commands_rx, + &mut pending_request, + ), + IdeContextReaderCommandState::Shutdown + ) { + break; + } + + if pending_request + .as_ref() + .is_some_and(|pending| Instant::now() >= pending.deadline) + && let Some(pending) = pending_request.take() + { + let _ = pending.response_tx.send(Err(timeout_error())); + } + + let read_deadline = match next_reader_deadline(&mut stream, pending_request.as_ref()) { + Ok(Some(read_deadline)) => read_deadline, + Ok(None) => continue, + Err(err) => { + if let Some(pending) = pending_request.take() { + let _ = pending.response_tx.send(Err(err)); + } + break; + } + }; + stream.set_deadline(read_deadline); + match read_frame(&mut stream, read_deadline) { + Ok(message) => { + if let Err(err) = handle_reader_message(&mut stream, &mut pending_request, message) + { + if let Some(pending) = pending_request.take() { + let _ = pending.response_tx.send(Err(err)); + } + break; + } + } + Err(err) => { + if let Some(pending) = pending_request.take() { + let _ = pending.response_tx.send(Err(err)); + } + break; + } + } + } +} + +#[cfg(any(unix, windows))] +enum IdeContextReaderCommandState { + Continue, + Shutdown, +} + +#[cfg(any(unix, windows))] +fn drain_ide_context_reader_commands( + stream: &mut IdeContextStream, + client_id: &str, + commands_rx: &Receiver, + pending_request: &mut Option, +) -> IdeContextReaderCommandState { + loop { + match commands_rx.try_recv() { + Ok(IdeContextClientCommand::FetchIdeContext { + workspace_root, + timeout, + response_tx, + }) => { + if pending_request.is_some() { + let _ = response_tx.send(Err(IdeContextError::RequestFailed( + "request-in-flight".to_string(), + ))); + continue; + } + + let request_id = uuid::Uuid::new_v4().to_string(); + let deadline = Instant::now() + timeout; + stream.set_deadline(deadline); + match write_ide_context_request(stream, &request_id, client_id, &workspace_root) { + Ok(()) => { + *pending_request = Some(PendingIdeContextRequest { + request_id, + deadline, + response_tx, + }); + } + Err(err) => { + let _ = response_tx.send(Err(IdeContextError::Send(err))); + return IdeContextReaderCommandState::Shutdown; + } + } + } + Ok(IdeContextClientCommand::Shutdown) | Err(mpsc::TryRecvError::Disconnected) => { + return IdeContextReaderCommandState::Shutdown; + } + Err(mpsc::TryRecvError::Empty) => { + return IdeContextReaderCommandState::Continue; + } + } + } +} + +#[cfg(any(unix, windows))] +fn next_reader_deadline( + stream: &mut IdeContextStream, + pending_request: Option<&PendingIdeContextRequest>, +) -> Result, IdeContextError> { + if let Some(pending) = pending_request { + return Ok(Some(pending.deadline)); + } + + wait_for_idle_reader_message(stream) +} + +#[cfg(unix)] +fn wait_for_idle_reader_message( + stream: &mut IdeContextStream, +) -> Result, IdeContextError> { + let idle_deadline = Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT; + stream.set_deadline(idle_deadline); + match stream.wait_for_ready(libc::POLLIN) { + Ok(()) => Ok(Some(Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT)), + Err(err) if err.kind() == std::io::ErrorKind::TimedOut => Ok(None), + Err(err) => Err(IdeContextError::Read(err)), + } +} + +#[cfg(windows)] +fn wait_for_idle_reader_message( + stream: &mut IdeContextStream, +) -> Result, IdeContextError> { + let idle_deadline = Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT; + stream + .wait_until_readable(idle_deadline) + .map(|readable| readable.then(|| Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT)) + .map_err(IdeContextError::Read) +} + +#[cfg(any(unix, windows))] +fn read_idle_reader_message_if_available( + stream: &mut IdeContextStream, + pending_request: &mut Option, +) -> Result { + if !is_idle_reader_message_available(stream)? { + return Ok(false); + } + + let deadline = Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT; + stream.set_deadline(deadline); + let message = read_frame(stream, deadline)?; + handle_reader_message(stream, pending_request, message)?; + Ok(true) +} + +#[cfg(unix)] +fn is_idle_reader_message_available( + stream: &mut IdeContextStream, +) -> Result { + stream.is_readable_now().map_err(IdeContextError::Read) +} + +#[cfg(windows)] +fn is_idle_reader_message_available( + stream: &mut IdeContextStream, +) -> Result { + stream + .wait_until_readable(Instant::now()) + .map_err(IdeContextError::Read) +} + +#[cfg(any(unix, windows))] +fn handle_reader_message( + stream: &mut IdeContextStream, + pending_request: &mut Option, + message: Value, +) -> Result<(), IdeContextError> { + match message.get("type").and_then(Value::as_str) { + Some("response") => { + if pending_request.as_ref().is_some_and(|pending| { + message.get("requestId").and_then(Value::as_str) + == Some(pending.request_id.as_str()) + }) && let Some(pending) = pending_request.take() + { + let _ = pending.response_tx.send(extract_ide_context(message)); + } + Ok(()) + } + Some("broadcast") => Ok(()), + Some("client-discovery-request") => { + if let Some(discovery_request_id) = message.get("requestId").and_then(Value::as_str) { + let response = json!({ + "type": "client-discovery-response", + "requestId": discovery_request_id, + "response": { + "canHandle": false, + }, + }); + stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT); + write_frame(stream, &response).map_err(IdeContextError::Send)?; + } + Ok(()) + } + Some("request") => { + if let Some(inbound_request_id) = message.get("requestId").and_then(Value::as_str) { + let response = json!({ + "type": "response", + "requestId": inbound_request_id, + "resultType": "error", + "error": "no-handler-for-request", + }); + stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT); + write_frame(stream, &response).map_err(IdeContextError::Send)?; + } + Ok(()) + } + Some("client-discovery-response") | Some(_) | None => Ok(()), + } +} + #[cfg(any(unix, windows))] fn initialize_client( stream: &mut T, @@ -704,18 +1028,7 @@ fn read_response_frame( write_frame(stream, &response).map_err(IdeContextError::Send)?; } } - Some("request") => { - if let Some(inbound_request_id) = message.get("requestId").and_then(Value::as_str) { - let response = json!({ - "type": "response", - "requestId": inbound_request_id, - "resultType": "error", - "error": "no-handler-for-request", - }); - write_frame(stream, &response).map_err(IdeContextError::Send)?; - } - } - Some("client-discovery-response") => {} + Some("client-discovery-response") | Some("request") => {} Some(other) => { return Err(IdeContextError::InvalidResponse(format!( "unexpected IDE context message type: {other}" @@ -752,6 +1065,14 @@ fn deadline_timeout_io_error() -> std::io::Error { ) } +#[cfg(any(unix, windows))] +fn ipc_reader_closed_io_error() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "IDE context IPC reader is closed", + ) +} + #[cfg(unix)] fn permission_denied_io_error(message: &'static str) -> std::io::Error { std::io::Error::new(std::io::ErrorKind::PermissionDenied, message) @@ -869,6 +1190,27 @@ mod tests { .expect("write ide-context response"); } + #[cfg(unix)] + fn write_frame_in_two_chunks( + stream: &mut impl std::io::Write, + message: &Value, + delay: Duration, + ) { + let payload = serde_json::to_vec(message).expect("serialize test IPC message"); + let payload_len = u32::try_from(payload.len()) + .expect("test IPC message length fits u32") + .to_le_bytes(); + stream + .write_all(&payload_len) + .expect("write test IPC frame header"); + stream.flush().expect("flush test IPC frame header"); + std::thread::sleep(delay); + stream + .write_all(&payload) + .expect("write test IPC frame payload"); + stream.flush().expect("flush test IPC frame payload"); + } + #[cfg(any(unix, windows))] #[test] fn read_frame_respects_deadline_while_reading_payload() { @@ -1059,6 +1401,76 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn ide_context_idle_reader_preserves_split_frame() { + use std::os::unix::net::UnixListener; + use std::thread; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("codex-ipc.sock"); + let listener = UnixListener::bind(&socket_path).expect("bind socket"); + let (discovery_tx, discovery_rx) = std::sync::mpsc::channel(); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + + let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); + let initialize_request_id = initialize + .get("requestId") + .and_then(Value::as_str) + .expect("initialize request id"); + write_initialize_response(&mut stream, initialize_request_id); + + write_frame_in_two_chunks( + &mut stream, + &json!({ + "type": "client-discovery-request", + "requestId": "split-discovery-request", + "request": { + "type": "request", + "method": "ide-context" + } + }), + IDE_CONTEXT_IDLE_READ_TIMEOUT * 2, + ); + let discovery_response = + read_frame(&mut stream, test_deadline()).expect("read client discovery response"); + assert_eq!( + discovery_response.get("requestId").and_then(Value::as_str), + Some("split-discovery-request") + ); + discovery_tx + .send(()) + .expect("notify split discovery response was received"); + + let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); + let ide_context_request_id = ide_context + .get("requestId") + .and_then(Value::as_str) + .expect("ide-context request id"); + write_ide_context_response(&mut stream, ide_context_request_id, "after split frame"); + }); + + let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) + .expect("connect IDE context client"); + discovery_rx + .recv_timeout(Duration::from_secs(1)) + .expect("idle client should preserve and answer split frames"); + let context = client + .fetch_ide_context_for_prompt(Path::new("/repo")) + .expect("fetch IDE context after split idle frame"); + + server.join().expect("server joins"); + assert_eq!( + context + .active_file + .as_ref() + .map(|file| file.active_selection_content.as_str()), + Some("after split frame") + ); + } + #[cfg(unix)] #[test] fn ide_context_client_reuses_initialized_connection_for_prompt_requests() { @@ -1133,4 +1545,73 @@ mod tests { [Some("first"), Some("second")] ); } + + #[cfg(unix)] + #[test] + fn ide_context_client_answers_idle_inbound_requests() { + use std::os::unix::net::UnixListener; + use std::thread; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("codex-ipc.sock"); + let listener = UnixListener::bind(&socket_path).expect("bind socket"); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + + let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); + let initialize_request_id = initialize + .get("requestId") + .and_then(Value::as_str) + .expect("initialize request id"); + write_initialize_response(&mut stream, initialize_request_id); + + write_frame( + &mut stream, + &json!({ + "type": "request", + "requestId": "idle-request", + "sourceClientId": "vscode-client", + "version": 0, + "method": "unknown-method", + "params": {} + }), + ) + .expect("write idle request"); + + let idle_response = + read_frame(&mut stream, test_deadline()).expect("read idle request response"); + assert_eq!( + idle_response, + json!({ + "type": "response", + "requestId": "idle-request", + "resultType": "error", + "error": "no-handler-for-request" + }) + ); + + let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); + let ide_context_request_id = ide_context + .get("requestId") + .and_then(Value::as_str) + .expect("ide-context request id"); + write_ide_context_response(&mut stream, ide_context_request_id, "selected"); + }); + + let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) + .expect("connect IDE context client"); + let context = client + .fetch_ide_context_for_prompt(Path::new("/repo")) + .expect("fetch IDE context after idle request"); + + server.join().expect("server joins"); + assert_eq!( + context + .active_file + .as_ref() + .map(|file| file.active_selection_content.as_str()), + Some("selected") + ); + } } diff --git a/codex-rs/tui/src/ide_context/windows_pipe.rs b/codex-rs/tui/src/ide_context/windows_pipe.rs index 8074b65e8045..e345fb298a40 100644 --- a/codex-rs/tui/src/ide_context/windows_pipe.rs +++ b/codex-rs/tui/src/ide_context/windows_pipe.rs @@ -6,6 +6,8 @@ use std::io::Write; use std::os::windows::ffi::OsStrExt; use std::path::PathBuf; use std::ptr; +use std::thread; +use std::time::Duration; use std::time::Instant; use windows_sys::Win32::Foundation::BOOL; @@ -36,6 +38,7 @@ use windows_sys::Win32::System::IO::CancelIoEx; use windows_sys::Win32::System::IO::GetOverlappedResult; use windows_sys::Win32::System::IO::OVERLAPPED; use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId; +use windows_sys::Win32::System::Pipes::PeekNamedPipe; use windows_sys::Win32::System::Threading::CreateEventW; use windows_sys::Win32::System::Threading::GetCurrentProcess; use windows_sys::Win32::System::Threading::OpenProcess; @@ -84,6 +87,39 @@ impl WindowsPipeStream { pub(super) fn set_deadline(&mut self, deadline: Instant) { self.deadline = deadline; } + + pub(super) fn wait_until_readable(&self, deadline: Instant) -> io::Result { + loop { + if self.has_bytes_available()? { + return Ok(true); + } + let now = Instant::now(); + if now >= deadline { + return Ok(false); + } + + thread::sleep((deadline - now).min(Duration::from_millis(5))); + } + } + + fn has_bytes_available(&self) -> io::Result { + let mut bytes_available = 0; + let result = unsafe { + PeekNamedPipe( + self.handle.raw(), + ptr::null_mut(), + 0, + ptr::null_mut(), + &mut bytes_available, + ptr::null_mut(), + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(bytes_available > 0) + } } impl Read for WindowsPipeStream { From f1c03340f110611de4cf07ce5416e8d36f88b12b Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 23:16:04 -0700 Subject: [PATCH 21/35] codex: buffer partial IDE IPC frames --- codex-rs/tui/src/ide_context/ipc.rs | 357 ++++++++++++------- codex-rs/tui/src/ide_context/windows_pipe.rs | 13 + 2 files changed, 251 insertions(+), 119 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index e76a6f14ab3d..7dff0071336a 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -25,7 +25,7 @@ const IDE_CONTEXT_PROBE_TIMEOUT: Duration = IDE_CONTEXT_PROMPT_TIMEOUT; #[cfg(any(unix, windows))] const IDE_CONTEXT_IDLE_READ_TIMEOUT: Duration = Duration::from_millis(50); #[cfg(any(unix, windows))] -const IDE_CONTEXT_IDLE_FRAME_TIMEOUT: Duration = Duration::from_secs(1); +const IDE_CONTEXT_IDLE_WRITE_TIMEOUT: Duration = Duration::from_secs(1); // Prompt rendering applies its own smaller cap to selected text before injection. #[cfg(any(unix, windows))] const MAX_IPC_FRAME_BYTES: usize = 256 * 1024 * 1024; @@ -239,6 +239,97 @@ struct PendingIdeContextRequest { response_tx: Sender>, } +#[cfg(any(unix, windows))] +#[derive(Default)] +struct IpcFrameBuffer { + buffer: Vec, + expected_len: Option, +} + +#[cfg(any(unix, windows))] +impl IpcFrameBuffer { + fn read_available_messages( + &mut self, + stream: &mut IdeContextStream, + ) -> Result, IdeContextError> { + let mut chunk = [0_u8; 8192]; + loop { + match stream + .try_read_available(&mut chunk) + .map_err(IdeContextError::Read)? + { + Some(0) => { + return Err(IdeContextError::Read(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "IDE context IPC stream closed", + ))); + } + Some(bytes_read) => self.buffer.extend_from_slice(&chunk[..bytes_read]), + None => break, + } + } + + let mut messages = Vec::new(); + while let Some(message) = self.pop_complete_message()? { + messages.push(message); + } + Ok(messages) + } + + fn read_next_message( + &mut self, + stream: &mut IdeContextStream, + deadline: Instant, + ) -> Result { + loop { + if let Some(message) = self.pop_complete_message()? { + return Ok(message); + } + + ensure_deadline_not_expired(deadline)?; + stream.set_deadline(deadline); + let mut chunk = [0_u8; 8192]; + match std::io::Read::read(stream, &mut chunk) { + Ok(0) => { + return Err(IdeContextError::Read(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "IDE context IPC stream closed", + ))); + } + Ok(bytes_read) => self.buffer.extend_from_slice(&chunk[..bytes_read]), + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => return Err(IdeContextError::Read(error)), + } + } + } + + fn pop_complete_message(&mut self) -> Result, IdeContextError> { + if self.expected_len.is_none() && self.buffer.len() >= 4 { + let mut len_bytes = [0_u8; 4]; + len_bytes.copy_from_slice(&self.buffer[..4]); + self.buffer.drain(..4); + let len = u32::from_le_bytes(len_bytes) as usize; + if len > MAX_IPC_FRAME_BYTES { + return Err(IdeContextError::ResponseTooLarge); + } + self.expected_len = Some(len); + } + + let Some(expected_len) = self.expected_len else { + return Ok(None); + }; + if self.buffer.len() < expected_len { + return Ok(None); + } + + let payload = self.buffer.drain(..expected_len).collect::>(); + self.expected_len = None; + serde_json::from_slice(&payload) + .map(Some) + .map_err(|err| IdeContextError::InvalidResponse(format!("invalid JSON payload: {err}"))) + } +} + #[cfg(any(unix, windows))] impl IdeContextClient { pub(crate) fn connect() -> Result { @@ -291,9 +382,19 @@ impl IdeContextClient { }) .map_err(|_| IdeContextError::Send(ipc_reader_closed_io_error()))?; - response_rx - .recv_timeout(timeout + IDE_CONTEXT_IDLE_READ_TIMEOUT) - .unwrap_or_else(|_| Err(timeout_error())) + match response_rx.recv_timeout(timeout + IDE_CONTEXT_IDLE_READ_TIMEOUT) { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => { + tracing::warn!("IDE context fetch timed out waiting for IPC reader response"); + Err(timeout_error()) + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + tracing::warn!( + "IDE context fetch response channel closed before IPC reader responded" + ); + Err(timeout_error()) + } + } } fn spawn_reader(stream: IdeContextStream, client_id: String) -> Self { @@ -453,33 +554,14 @@ impl UnixDeadlineStream { } } - fn is_readable_now(&self) -> std::io::Result { - use std::os::fd::AsRawFd; - + fn try_read_available(&mut self, buf: &mut [u8]) -> std::io::Result> { loop { - let mut poll_fd = libc::pollfd { - fd: self.stream.as_raw_fd(), - events: libc::POLLIN, - revents: 0, - }; - let result = unsafe { libc::poll(&mut poll_fd, 1, 0) }; - if result == 0 { - return Ok(false); - } - if result < 0 { - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::Interrupted { - continue; - } - return Err(error); - } - if poll_fd.revents & libc::POLLNVAL != 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "invalid IDE context Unix socket", - )); + match std::io::Read::read(&mut self.stream, buf) { + Ok(bytes_read) => return Ok(Some(bytes_read)), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(None), + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), } - return Ok(poll_fd.revents & (libc::POLLIN | libc::POLLERR | libc::POLLHUP) != 0); } } } @@ -654,19 +736,14 @@ fn run_ide_context_reader( commands_rx: Receiver, ) { let mut pending_request: Option = None; + let mut frame_buffer = IpcFrameBuffer::default(); loop { - if pending_request.is_none() { - match read_idle_reader_message_if_available(&mut stream, &mut pending_request) { - Ok(true) => continue, - Ok(false) => {} - Err(err) => { - if let Some(pending) = pending_request.take() { - let _ = pending.response_tx.send(Err(err)); - } - break; - } - } + if let Err(err) = + drain_available_reader_messages(&mut stream, &mut frame_buffer, &mut pending_request) + { + fail_pending_request(pending_request.take(), err); + break; } if matches!( @@ -681,39 +758,29 @@ fn run_ide_context_reader( break; } - if pending_request - .as_ref() - .is_some_and(|pending| Instant::now() >= pending.deadline) - && let Some(pending) = pending_request.take() - { - let _ = pending.response_tx.send(Err(timeout_error())); - } - - let read_deadline = match next_reader_deadline(&mut stream, pending_request.as_ref()) { - Ok(Some(read_deadline)) => read_deadline, - Ok(None) => continue, - Err(err) => { - if let Some(pending) = pending_request.take() { - let _ = pending.response_tx.send(Err(err)); - } + let Some(pending) = pending_request.as_ref() else { + if let Err(err) = wait_for_idle_reader_message(&mut stream) { + fail_pending_request(pending_request.take(), err); break; } + continue; }; - stream.set_deadline(read_deadline); - match read_frame(&mut stream, read_deadline) { + + if Instant::now() >= pending.deadline { + fail_pending_request(pending_request.take(), timeout_error()); + continue; + } + + match frame_buffer.read_next_message(&mut stream, pending.deadline) { Ok(message) => { if let Err(err) = handle_reader_message(&mut stream, &mut pending_request, message) { - if let Some(pending) = pending_request.take() { - let _ = pending.response_tx.send(Err(err)); - } + fail_pending_request(pending_request.take(), err); break; } } Err(err) => { - if let Some(pending) = pending_request.take() { - let _ = pending.response_tx.send(Err(err)); - } + fail_pending_request(pending_request.take(), err); break; } } @@ -759,6 +826,7 @@ fn drain_ide_context_reader_commands( }); } Err(err) => { + tracing::warn!(error = %err, "IDE context IPC reader failed to write ide-context request"); let _ = response_tx.send(Err(IdeContextError::Send(err))); return IdeContextReaderCommandState::Shutdown; } @@ -774,72 +842,46 @@ fn drain_ide_context_reader_commands( } } -#[cfg(any(unix, windows))] -fn next_reader_deadline( - stream: &mut IdeContextStream, - pending_request: Option<&PendingIdeContextRequest>, -) -> Result, IdeContextError> { - if let Some(pending) = pending_request { - return Ok(Some(pending.deadline)); - } - - wait_for_idle_reader_message(stream) -} - #[cfg(unix)] -fn wait_for_idle_reader_message( - stream: &mut IdeContextStream, -) -> Result, IdeContextError> { +fn wait_for_idle_reader_message(stream: &mut IdeContextStream) -> Result<(), IdeContextError> { let idle_deadline = Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT; stream.set_deadline(idle_deadline); match stream.wait_for_ready(libc::POLLIN) { - Ok(()) => Ok(Some(Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT)), - Err(err) if err.kind() == std::io::ErrorKind::TimedOut => Ok(None), + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::TimedOut => Ok(()), Err(err) => Err(IdeContextError::Read(err)), } } #[cfg(windows)] -fn wait_for_idle_reader_message( - stream: &mut IdeContextStream, -) -> Result, IdeContextError> { +fn wait_for_idle_reader_message(stream: &mut IdeContextStream) -> Result<(), IdeContextError> { let idle_deadline = Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT; stream .wait_until_readable(idle_deadline) - .map(|readable| readable.then(|| Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT)) + .map(|_| ()) .map_err(IdeContextError::Read) } #[cfg(any(unix, windows))] -fn read_idle_reader_message_if_available( +fn drain_available_reader_messages( stream: &mut IdeContextStream, + frame_buffer: &mut IpcFrameBuffer, pending_request: &mut Option, -) -> Result { - if !is_idle_reader_message_available(stream)? { - return Ok(false); +) -> Result<(), IdeContextError> { + let messages = frame_buffer.read_available_messages(stream)?; + for message in messages { + handle_reader_message(stream, pending_request, message)?; } - - let deadline = Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT; - stream.set_deadline(deadline); - let message = read_frame(stream, deadline)?; - handle_reader_message(stream, pending_request, message)?; - Ok(true) -} - -#[cfg(unix)] -fn is_idle_reader_message_available( - stream: &mut IdeContextStream, -) -> Result { - stream.is_readable_now().map_err(IdeContextError::Read) + Ok(()) } -#[cfg(windows)] -fn is_idle_reader_message_available( - stream: &mut IdeContextStream, -) -> Result { - stream - .wait_until_readable(Instant::now()) - .map_err(IdeContextError::Read) +#[cfg(any(unix, windows))] +fn fail_pending_request(pending_request: Option, err: IdeContextError) { + if let Some(pending) = pending_request { + let _ = pending.response_tx.send(Err(err)); + } else { + tracing::warn!(error = %err, "IDE context IPC reader exiting without a pending request"); + } } #[cfg(any(unix, windows))] @@ -850,11 +892,11 @@ fn handle_reader_message( ) -> Result<(), IdeContextError> { match message.get("type").and_then(Value::as_str) { Some("response") => { - if pending_request.as_ref().is_some_and(|pending| { + let matches_pending = pending_request.as_ref().is_some_and(|pending| { message.get("requestId").and_then(Value::as_str) == Some(pending.request_id.as_str()) - }) && let Some(pending) = pending_request.take() - { + }); + if matches_pending && let Some(pending) = pending_request.take() { let _ = pending.response_tx.send(extract_ide_context(message)); } Ok(()) @@ -869,7 +911,7 @@ fn handle_reader_message( "canHandle": false, }, }); - stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT); + stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_WRITE_TIMEOUT); write_frame(stream, &response).map_err(IdeContextError::Send)?; } Ok(()) @@ -882,12 +924,12 @@ fn handle_reader_message( "resultType": "error", "error": "no-handler-for-request", }); - stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_FRAME_TIMEOUT); + stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_WRITE_TIMEOUT); write_frame(stream, &response).map_err(IdeContextError::Send)?; } Ok(()) } - Some("client-discovery-response") | Some(_) | None => Ok(()), + Some(_) | None => Ok(()), } } @@ -1196,6 +1238,16 @@ mod tests { message: &Value, delay: Duration, ) { + let payload = write_frame_header(stream, message); + std::thread::sleep(delay); + stream + .write_all(&payload) + .expect("write test IPC frame payload"); + stream.flush().expect("flush test IPC frame payload"); + } + + #[cfg(unix)] + fn write_frame_header(stream: &mut impl std::io::Write, message: &Value) -> Vec { let payload = serde_json::to_vec(message).expect("serialize test IPC message"); let payload_len = u32::try_from(payload.len()) .expect("test IPC message length fits u32") @@ -1204,11 +1256,7 @@ mod tests { .write_all(&payload_len) .expect("write test IPC frame header"); stream.flush().expect("flush test IPC frame header"); - std::thread::sleep(delay); - stream - .write_all(&payload) - .expect("write test IPC frame payload"); - stream.flush().expect("flush test IPC frame payload"); + payload } #[cfg(any(unix, windows))] @@ -1471,6 +1519,77 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn ide_context_client_sends_request_while_idle_frame_is_incomplete() { + use std::io::Write; + use std::os::unix::net::UnixListener; + use std::thread; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("codex-ipc.sock"); + let listener = UnixListener::bind(&socket_path).expect("bind socket"); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + + let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); + let initialize_request_id = initialize + .get("requestId") + .and_then(Value::as_str) + .expect("initialize request id"); + write_initialize_response(&mut stream, initialize_request_id); + + let payload = write_frame_header( + &mut stream, + &json!({ + "type": "broadcast", + "method": "client-status-changed", + "sourceClientId": "vscode-client", + "version": 0, + "params": { + "clientId": "vscode-client", + "clientType": "vscode", + "status": "connected" + } + }), + ); + + let ide_context = read_frame(&mut stream, test_deadline()) + .expect("client should write ide-context request before idle frame completes"); + let ide_context_request_id = ide_context + .get("requestId") + .and_then(Value::as_str) + .expect("ide-context request id"); + + stream + .write_all(&payload) + .expect("write delayed idle frame payload"); + stream.flush().expect("flush delayed idle frame payload"); + write_ide_context_response( + &mut stream, + ide_context_request_id, + "after incomplete idle frame", + ); + }); + + let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) + .expect("connect IDE context client"); + std::thread::sleep(IDE_CONTEXT_IDLE_READ_TIMEOUT * 2); + let context = client + .fetch_ide_context_for_prompt(Path::new("/repo")) + .expect("fetch IDE context while idle frame is incomplete"); + + server.join().expect("server joins"); + assert_eq!( + context + .active_file + .as_ref() + .map(|file| file.active_selection_content.as_str()), + Some("after incomplete idle frame") + ); + } + #[cfg(unix)] #[test] fn ide_context_client_reuses_initialized_connection_for_prompt_requests() { diff --git a/codex-rs/tui/src/ide_context/windows_pipe.rs b/codex-rs/tui/src/ide_context/windows_pipe.rs index e345fb298a40..e88ca0039135 100644 --- a/codex-rs/tui/src/ide_context/windows_pipe.rs +++ b/codex-rs/tui/src/ide_context/windows_pipe.rs @@ -120,6 +120,19 @@ impl WindowsPipeStream { Ok(bytes_available > 0) } + + pub(super) fn try_read_available(&mut self, buf: &mut [u8]) -> io::Result> { + if !self.has_bytes_available()? { + return Ok(None); + } + + self.deadline = Instant::now() + Duration::from_millis(1); + match self.read(buf) { + Ok(bytes_read) => Ok(Some(bytes_read)), + Err(err) if err.kind() == io::ErrorKind::TimedOut => Ok(None), + Err(err) => Err(err), + } + } } impl Read for WindowsPipeStream { From e00ef810edeee1dfe51d750d7df876d87eb95e4f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 29 Apr 2026 23:55:29 -0700 Subject: [PATCH 22/35] codex: simplify TUI IDE context support (#13834) --- codex-rs/tui/src/chatwidget/ide_context.rs | 10 +- codex-rs/tui/src/ide_context/ipc.rs | 186 +-------------------- 2 files changed, 8 insertions(+), 188 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/ide_context.rs b/codex-rs/tui/src/chatwidget/ide_context.rs index 95bdfc55e1c9..d7d40a61ad10 100644 --- a/codex-rs/tui/src/chatwidget/ide_context.rs +++ b/codex-rs/tui/src/chatwidget/ide_context.rs @@ -47,11 +47,7 @@ impl IdeContextState { } fn status_indicator(&self) -> Option { - if !self.enabled { - return None; - } - - Some(IdeContextStatusIndicator::Active) + self.enabled.then_some(IdeContextStatusIndicator::Active) } fn should_retry_recent_toggle(&self) -> bool { @@ -88,10 +84,10 @@ impl IdeContextState { let client = if let Some(client) = self.client.as_mut() { client } else { - self.client.insert(IdeContextClient::connect_for_prompt()?) + self.client.insert(IdeContextClient::connect()?) }; - let result = client.fetch_ide_context_for_prompt(workspace_root); + let result = client.fetch_ide_context(workspace_root); match result { Ok(context) => return Ok(context), Err(err) => { diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 7dff0071336a..2953a9305dc4 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -20,8 +20,7 @@ use super::IdeContext; // The desktop IPC client gives requests 5 seconds to complete. Match that prompt-time budget here: // fetching IDE context includes router discovery and extension event-loop work, so a shorter TUI // deadline can incorrectly skip context even though the IDE answers normally. -const IDE_CONTEXT_PROMPT_TIMEOUT: Duration = Duration::from_secs(5); -const IDE_CONTEXT_PROBE_TIMEOUT: Duration = IDE_CONTEXT_PROMPT_TIMEOUT; +const IDE_CONTEXT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(any(unix, windows))] const IDE_CONTEXT_IDLE_READ_TIMEOUT: Duration = Duration::from_millis(50); #[cfg(any(unix, windows))] @@ -333,25 +332,14 @@ impl IpcFrameBuffer { #[cfg(any(unix, windows))] impl IdeContextClient { pub(crate) fn connect() -> Result { - Self::connect_to_socket(default_ipc_socket_path(), IDE_CONTEXT_PROBE_TIMEOUT) - } - - pub(crate) fn connect_for_prompt() -> Result { - Self::connect_to_socket(default_ipc_socket_path(), IDE_CONTEXT_PROMPT_TIMEOUT) + Self::connect_to_socket(default_ipc_socket_path(), IDE_CONTEXT_REQUEST_TIMEOUT) } pub(crate) fn fetch_ide_context( &mut self, workspace_root: &Path, ) -> Result { - self.fetch_ide_context_with_timeout(workspace_root, IDE_CONTEXT_PROBE_TIMEOUT) - } - - pub(crate) fn fetch_ide_context_for_prompt( - &mut self, - workspace_root: &Path, - ) -> Result { - self.fetch_ide_context_with_timeout(workspace_root, IDE_CONTEXT_PROMPT_TIMEOUT) + self.fetch_ide_context_with_timeout(workspace_root, IDE_CONTEXT_REQUEST_TIMEOUT) } fn connect_to_socket(socket_path: PathBuf, timeout: Duration) -> Result { @@ -429,23 +417,12 @@ impl IdeContextClient { Err(IdeContextError::UnsupportedPlatform) } - pub(crate) fn connect_for_prompt() -> Result { - Err(IdeContextError::UnsupportedPlatform) - } - pub(crate) fn fetch_ide_context( &mut self, _workspace_root: &Path, ) -> Result { Err(IdeContextError::UnsupportedPlatform) } - - pub(crate) fn fetch_ide_context_for_prompt( - &mut self, - _workspace_root: &Path, - ) -> Result { - Err(IdeContextError::UnsupportedPlatform) - } } #[cfg(unix)] @@ -1232,20 +1209,6 @@ mod tests { .expect("write ide-context response"); } - #[cfg(unix)] - fn write_frame_in_two_chunks( - stream: &mut impl std::io::Write, - message: &Value, - delay: Duration, - ) { - let payload = write_frame_header(stream, message); - std::thread::sleep(delay); - stream - .write_all(&payload) - .expect("write test IPC frame payload"); - stream.flush().expect("flush test IPC frame payload"); - } - #[cfg(unix)] fn write_frame_header(stream: &mut impl std::io::Write, message: &Value) -> Vec { let payload = serde_json::to_vec(message).expect("serialize test IPC message"); @@ -1449,76 +1412,6 @@ mod tests { ); } - #[cfg(unix)] - #[test] - fn ide_context_idle_reader_preserves_split_frame() { - use std::os::unix::net::UnixListener; - use std::thread; - - let tempdir = tempfile::tempdir().expect("tempdir"); - let socket_path = tempdir.path().join("codex-ipc.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind socket"); - let (discovery_tx, discovery_rx) = std::sync::mpsc::channel(); - - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - - let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); - let initialize_request_id = initialize - .get("requestId") - .and_then(Value::as_str) - .expect("initialize request id"); - write_initialize_response(&mut stream, initialize_request_id); - - write_frame_in_two_chunks( - &mut stream, - &json!({ - "type": "client-discovery-request", - "requestId": "split-discovery-request", - "request": { - "type": "request", - "method": "ide-context" - } - }), - IDE_CONTEXT_IDLE_READ_TIMEOUT * 2, - ); - let discovery_response = - read_frame(&mut stream, test_deadline()).expect("read client discovery response"); - assert_eq!( - discovery_response.get("requestId").and_then(Value::as_str), - Some("split-discovery-request") - ); - discovery_tx - .send(()) - .expect("notify split discovery response was received"); - - let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); - let ide_context_request_id = ide_context - .get("requestId") - .and_then(Value::as_str) - .expect("ide-context request id"); - write_ide_context_response(&mut stream, ide_context_request_id, "after split frame"); - }); - - let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) - .expect("connect IDE context client"); - discovery_rx - .recv_timeout(Duration::from_secs(1)) - .expect("idle client should preserve and answer split frames"); - let context = client - .fetch_ide_context_for_prompt(Path::new("/repo")) - .expect("fetch IDE context after split idle frame"); - - server.join().expect("server joins"); - assert_eq!( - context - .active_file - .as_ref() - .map(|file| file.active_selection_content.as_str()), - Some("after split frame") - ); - } - #[cfg(unix)] #[test] fn ide_context_client_sends_request_while_idle_frame_is_incomplete() { @@ -1577,7 +1470,7 @@ mod tests { .expect("connect IDE context client"); std::thread::sleep(IDE_CONTEXT_IDLE_READ_TIMEOUT * 2); let context = client - .fetch_ide_context_for_prompt(Path::new("/repo")) + .fetch_ide_context(Path::new("/repo")) .expect("fetch IDE context while idle frame is incomplete"); server.join().expect("server joins"); @@ -1646,7 +1539,7 @@ mod tests { .fetch_ide_context(Path::new("/repo")) .expect("fetch first IDE context"); let second = client - .fetch_ide_context_for_prompt(Path::new("/repo")) + .fetch_ide_context(Path::new("/repo")) .expect("fetch second IDE context"); server.join().expect("server joins"); @@ -1664,73 +1557,4 @@ mod tests { [Some("first"), Some("second")] ); } - - #[cfg(unix)] - #[test] - fn ide_context_client_answers_idle_inbound_requests() { - use std::os::unix::net::UnixListener; - use std::thread; - - let tempdir = tempfile::tempdir().expect("tempdir"); - let socket_path = tempdir.path().join("codex-ipc.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind socket"); - - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - - let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); - let initialize_request_id = initialize - .get("requestId") - .and_then(Value::as_str) - .expect("initialize request id"); - write_initialize_response(&mut stream, initialize_request_id); - - write_frame( - &mut stream, - &json!({ - "type": "request", - "requestId": "idle-request", - "sourceClientId": "vscode-client", - "version": 0, - "method": "unknown-method", - "params": {} - }), - ) - .expect("write idle request"); - - let idle_response = - read_frame(&mut stream, test_deadline()).expect("read idle request response"); - assert_eq!( - idle_response, - json!({ - "type": "response", - "requestId": "idle-request", - "resultType": "error", - "error": "no-handler-for-request" - }) - ); - - let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); - let ide_context_request_id = ide_context - .get("requestId") - .and_then(Value::as_str) - .expect("ide-context request id"); - write_ide_context_response(&mut stream, ide_context_request_id, "selected"); - }); - - let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) - .expect("connect IDE context client"); - let context = client - .fetch_ide_context_for_prompt(Path::new("/repo")) - .expect("fetch IDE context after idle request"); - - server.join().expect("server joins"); - assert_eq!( - context - .active_file - .as_ref() - .map(|file| file.active_selection_content.as_str()), - Some("selected") - ); - } } From 7693e735428c366e96eb8a8ff26c42d3cea3cd66 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 00:51:00 -0700 Subject: [PATCH 23/35] Simplify TUI IDE context IPC client Use a request-scoped IPC socket for each IDE context fetch so the TUI does not register for router broadcasts. This keeps /ide prompt injection responsive while still using the existing ide-context request route. Addresses openai/codex#13834. --- codex-rs/tui/src/ide_context/ipc.rs | 776 ++++--------------- codex-rs/tui/src/ide_context/windows_pipe.rs | 49 -- 2 files changed, 171 insertions(+), 654 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 2953a9305dc4..56e036689d72 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -2,10 +2,6 @@ use std::path::Path; use std::path::PathBuf; -use std::sync::mpsc; -use std::sync::mpsc::Receiver; -use std::sync::mpsc::Sender; -use std::thread::JoinHandle; use std::time::Duration; use std::time::Instant; @@ -22,12 +18,15 @@ use super::IdeContext; // deadline can incorrectly skip context even though the IDE answers normally. const IDE_CONTEXT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(any(unix, windows))] -const IDE_CONTEXT_IDLE_READ_TIMEOUT: Duration = Duration::from_millis(50); -#[cfg(any(unix, windows))] -const IDE_CONTEXT_IDLE_WRITE_TIMEOUT: Duration = Duration::from_secs(1); -// Prompt rendering applies its own smaller cap to selected text before injection. +const IPC_READ_CHUNK_BYTES: usize = 64 * 1024; #[cfg(any(unix, windows))] const MAX_IPC_FRAME_BYTES: usize = 256 * 1024 * 1024; +#[cfg(any(unix, windows))] +const BROADCAST_TYPE_JSON: &[u8] = br#""type":"broadcast""#; +#[cfg(any(unix, windows))] +const BROADCAST_PREFIX_SCAN_BYTES: usize = 256; +#[cfg(any(unix, windows))] +const TUI_SOURCE_CLIENT_ID: &str = "codex-tui"; #[derive(Debug, Error)] pub(crate) enum IdeContextError { @@ -170,11 +169,15 @@ impl IdeContextError { #[cfg(any(unix, windows))] pub(crate) fn should_reset_client(&self) -> bool { match self { - IdeContextError::Connect(_) - | IdeContextError::Send(_) - | IdeContextError::Read(_) - | IdeContextError::InvalidResponse(_) - | IdeContextError::ResponseTooLarge => true, + IdeContextError::Connect(_) | IdeContextError::Send(_) => true, + IdeContextError::Read(error) => matches!( + error.kind(), + std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::UnexpectedEof + ), + IdeContextError::InvalidResponse(_) | IdeContextError::ResponseTooLarge => true, IdeContextError::RequestFailed(_) => false, } } @@ -205,15 +208,12 @@ impl IdeContextError { } } -/// Persistent IPC client used while TUI `/ide` mode is enabled. +/// IPC request handle used while TUI `/ide` mode is enabled. /// -/// The initial connection and initialize handshake happen once on `/ide on`, and each user turn -/// asks for a fresh IDE context snapshot over the same route with a short prompt-time deadline. +/// Each fetch uses a short-lived socket without sending `initialize`. That keeps the TUI out of +/// the router's broadcast fanout while still using the same `ide-context` request route. #[cfg(any(unix, windows))] -pub(crate) struct IdeContextClient { - commands: Sender, - reader: Option>, -} +pub(crate) struct IdeContextClient; #[cfg(unix)] type IdeContextStream = UnixDeadlineStream; @@ -221,73 +221,38 @@ type IdeContextStream = UnixDeadlineStream; #[cfg(windows)] type IdeContextStream = super::windows_pipe::WindowsPipeStream; -#[cfg(any(unix, windows))] -enum IdeContextClientCommand { - FetchIdeContext { - workspace_root: PathBuf, - timeout: Duration, - response_tx: Sender>, - }, - Shutdown, -} - -#[cfg(any(unix, windows))] -struct PendingIdeContextRequest { - request_id: String, - deadline: Instant, - response_tx: Sender>, -} - #[cfg(any(unix, windows))] #[derive(Default)] struct IpcFrameBuffer { buffer: Vec, expected_len: Option, + discard_remaining: usize, } #[cfg(any(unix, windows))] -impl IpcFrameBuffer { - fn read_available_messages( - &mut self, - stream: &mut IdeContextStream, - ) -> Result, IdeContextError> { - let mut chunk = [0_u8; 8192]; - loop { - match stream - .try_read_available(&mut chunk) - .map_err(IdeContextError::Read)? - { - Some(0) => { - return Err(IdeContextError::Read(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "IDE context IPC stream closed", - ))); - } - Some(bytes_read) => self.buffer.extend_from_slice(&chunk[..bytes_read]), - None => break, - } - } - - let mut messages = Vec::new(); - while let Some(message) = self.pop_complete_message()? { - messages.push(message); - } - Ok(messages) - } +enum IpcFrame { + Message(Value), + IgnoredBroadcast, +} +#[cfg(any(unix, windows))] +impl IpcFrameBuffer { fn read_next_message( &mut self, stream: &mut IdeContextStream, deadline: Instant, ) -> Result { loop { - if let Some(message) = self.pop_complete_message()? { - return Ok(message); + while let Some(frame) = self.pop_complete_frame()? { + match frame { + IpcFrame::Message(message) => return Ok(message), + IpcFrame::IgnoredBroadcast => {} + } } ensure_deadline_not_expired(deadline)?; stream.set_deadline(deadline); - let mut chunk = [0_u8; 8192]; + let mut chunk = [0_u8; IPC_READ_CHUNK_BYTES]; match std::io::Read::read(stream, &mut chunk) { Ok(0) => { return Err(IdeContextError::Read(std::io::Error::new( @@ -295,116 +260,115 @@ impl IpcFrameBuffer { "IDE context IPC stream closed", ))); } - Ok(bytes_read) => self.buffer.extend_from_slice(&chunk[..bytes_read]), + Ok(bytes_read) => self.push_bytes(&chunk[..bytes_read]), Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} Err(error) => return Err(IdeContextError::Read(error)), } } } - fn pop_complete_message(&mut self) -> Result, IdeContextError> { - if self.expected_len.is_none() && self.buffer.len() >= 4 { - let mut len_bytes = [0_u8; 4]; - len_bytes.copy_from_slice(&self.buffer[..4]); - self.buffer.drain(..4); - let len = u32::from_le_bytes(len_bytes) as usize; - if len > MAX_IPC_FRAME_BYTES { - return Err(IdeContextError::ResponseTooLarge); + fn push_bytes(&mut self, mut bytes: &[u8]) { + while !bytes.is_empty() { + if self.discard_remaining > 0 { + let bytes_to_discard = bytes.len().min(self.discard_remaining); + self.discard_remaining -= bytes_to_discard; + bytes = &bytes[bytes_to_discard..]; + continue; } - self.expected_len = Some(len); + + self.buffer.extend_from_slice(bytes); + self.maybe_start_discarding_broadcast(); + break; + } + } + + fn maybe_start_discarding_broadcast(&mut self) { + if self.expected_len.is_none() && self.buffer.len() >= 4 { + self.read_frame_len(); + } + + let Some(expected_len) = self.expected_len else { + return; + }; + if expected_len > MAX_IPC_FRAME_BYTES { + return; + } + if self.buffer.len() >= expected_len { + return; + } + + let prefix_len = self.buffer.len().min(BROADCAST_PREFIX_SCAN_BYTES); + if prefix_len < BROADCAST_TYPE_JSON.len() { + return; + } + + if frame_payload_is_broadcast(&self.buffer[..prefix_len]) { + self.discard_remaining = expected_len - self.buffer.len(); + self.buffer.clear(); + self.expected_len = None; + } + } + + fn pop_complete_frame(&mut self) -> Result, IdeContextError> { + if self.expected_len.is_none() && self.buffer.len() >= 4 { + self.read_frame_len(); } let Some(expected_len) = self.expected_len else { return Ok(None); }; + if expected_len > MAX_IPC_FRAME_BYTES { + return Err(IdeContextError::ResponseTooLarge); + } if self.buffer.len() < expected_len { return Ok(None); } + if frame_payload_is_broadcast(&self.buffer[..expected_len]) { + self.buffer.drain(..expected_len); + self.expected_len = None; + return Ok(Some(IpcFrame::IgnoredBroadcast)); + } + let payload = self.buffer.drain(..expected_len).collect::>(); self.expected_len = None; serde_json::from_slice(&payload) + .map(IpcFrame::Message) .map(Some) .map_err(|err| IdeContextError::InvalidResponse(format!("invalid JSON payload: {err}"))) } + + fn read_frame_len(&mut self) { + let mut len_bytes = [0_u8; 4]; + len_bytes.copy_from_slice(&self.buffer[..4]); + self.buffer.drain(..4); + self.expected_len = Some(u32::from_le_bytes(len_bytes) as usize); + } +} + +#[cfg(any(unix, windows))] +fn frame_payload_is_broadcast(payload: &[u8]) -> bool { + let prefix = &payload[..payload.len().min(BROADCAST_PREFIX_SCAN_BYTES)]; + prefix + .windows(BROADCAST_TYPE_JSON.len()) + .any(|window| window == BROADCAST_TYPE_JSON) } #[cfg(any(unix, windows))] impl IdeContextClient { pub(crate) fn connect() -> Result { - Self::connect_to_socket(default_ipc_socket_path(), IDE_CONTEXT_REQUEST_TIMEOUT) + Ok(Self) } pub(crate) fn fetch_ide_context( &mut self, workspace_root: &Path, ) -> Result { - self.fetch_ide_context_with_timeout(workspace_root, IDE_CONTEXT_REQUEST_TIMEOUT) - } - - fn connect_to_socket(socket_path: PathBuf, timeout: Duration) -> Result { - let deadline = Instant::now() + timeout; - Self::connect_to_socket_before_deadline(socket_path, deadline) - } - - fn connect_to_socket_before_deadline( - socket_path: PathBuf, - deadline: Instant, - ) -> Result { - let mut stream = connect_stream(socket_path, deadline)?; - let client_id = initialize_client(&mut stream, deadline)?; - Ok(Self::spawn_reader(stream, client_id)) - } - - fn fetch_ide_context_with_timeout( - &mut self, - workspace_root: &Path, - timeout: Duration, - ) -> Result { - let (response_tx, response_rx) = mpsc::channel(); - self.commands - .send(IdeContextClientCommand::FetchIdeContext { - workspace_root: workspace_root.to_path_buf(), - timeout, - response_tx, - }) - .map_err(|_| IdeContextError::Send(ipc_reader_closed_io_error()))?; - - match response_rx.recv_timeout(timeout + IDE_CONTEXT_IDLE_READ_TIMEOUT) { - Ok(result) => result, - Err(mpsc::RecvTimeoutError::Timeout) => { - tracing::warn!("IDE context fetch timed out waiting for IPC reader response"); - Err(timeout_error()) - } - Err(mpsc::RecvTimeoutError::Disconnected) => { - tracing::warn!( - "IDE context fetch response channel closed before IPC reader responded" - ); - Err(timeout_error()) - } - } - } - - fn spawn_reader(stream: IdeContextStream, client_id: String) -> Self { - let (commands_tx, commands_rx) = mpsc::channel(); - let reader = std::thread::spawn(move || { - run_ide_context_reader(stream, client_id, commands_rx); - }); - - Self { - commands: commands_tx, - reader: Some(reader), - } - } -} - -#[cfg(any(unix, windows))] -impl Drop for IdeContextClient { - fn drop(&mut self) { - let _ = self.commands.send(IdeContextClientCommand::Shutdown); - if let Some(reader) = self.reader.take() { - let _ = reader.join(); - } + fetch_ide_context_from_socket( + default_ipc_socket_path(), + workspace_root, + IDE_CONTEXT_REQUEST_TIMEOUT, + ) } } @@ -443,14 +407,15 @@ fn default_ipc_socket_path() -> PathBuf { PathBuf::new() } -#[cfg(all(test, unix))] +#[cfg(any(unix, windows))] fn fetch_ide_context_from_socket( socket_path: PathBuf, workspace_root: &Path, timeout: Duration, ) -> Result { - let mut client = IdeContextClient::connect_to_socket(socket_path, timeout)?; - client.fetch_ide_context_with_timeout(workspace_root, timeout) + let deadline = Instant::now() + timeout; + let mut stream = connect_stream(socket_path, deadline)?; + fetch_ide_context_from_stream(&mut stream, workspace_root, deadline) } #[cfg(unix)] @@ -530,17 +495,6 @@ impl UnixDeadlineStream { } } } - - fn try_read_available(&mut self, buf: &mut [u8]) -> std::io::Result> { - loop { - match std::io::Read::read(&mut self.stream, buf) { - Ok(bytes_read) => return Ok(Some(bytes_read)), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(None), - Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} - Err(error) => return Err(error), - } - } - } } #[cfg(unix)] @@ -703,246 +657,46 @@ fn connect_stream( .map_err(IdeContextError::Connect) } -// This mirrors the desktop app's attached IPC message reader, not a context poller. IDE context is -// requested only by FetchIdeContext commands; idle reads just keep router broadcasts and discovery -// requests from piling up until the next prompt-submit deadline. #[cfg(any(unix, windows))] -fn run_ide_context_reader( - mut stream: IdeContextStream, - client_id: String, - commands_rx: Receiver, -) { - let mut pending_request: Option = None; - let mut frame_buffer = IpcFrameBuffer::default(); - - loop { - if let Err(err) = - drain_available_reader_messages(&mut stream, &mut frame_buffer, &mut pending_request) - { - fail_pending_request(pending_request.take(), err); - break; - } - - if matches!( - drain_ide_context_reader_commands( - &mut stream, - &client_id, - &commands_rx, - &mut pending_request, - ), - IdeContextReaderCommandState::Shutdown - ) { - break; - } - - let Some(pending) = pending_request.as_ref() else { - if let Err(err) = wait_for_idle_reader_message(&mut stream) { - fail_pending_request(pending_request.take(), err); - break; - } - continue; - }; - - if Instant::now() >= pending.deadline { - fail_pending_request(pending_request.take(), timeout_error()); - continue; - } - - match frame_buffer.read_next_message(&mut stream, pending.deadline) { - Ok(message) => { - if let Err(err) = handle_reader_message(&mut stream, &mut pending_request, message) - { - fail_pending_request(pending_request.take(), err); - break; - } - } - Err(err) => { - fail_pending_request(pending_request.take(), err); - break; - } - } - } -} - -#[cfg(any(unix, windows))] -enum IdeContextReaderCommandState { - Continue, - Shutdown, -} - -#[cfg(any(unix, windows))] -fn drain_ide_context_reader_commands( - stream: &mut IdeContextStream, - client_id: &str, - commands_rx: &Receiver, - pending_request: &mut Option, -) -> IdeContextReaderCommandState { - loop { - match commands_rx.try_recv() { - Ok(IdeContextClientCommand::FetchIdeContext { - workspace_root, - timeout, - response_tx, - }) => { - if pending_request.is_some() { - let _ = response_tx.send(Err(IdeContextError::RequestFailed( - "request-in-flight".to_string(), - ))); - continue; - } - - let request_id = uuid::Uuid::new_v4().to_string(); - let deadline = Instant::now() + timeout; - stream.set_deadline(deadline); - match write_ide_context_request(stream, &request_id, client_id, &workspace_root) { - Ok(()) => { - *pending_request = Some(PendingIdeContextRequest { - request_id, - deadline, - response_tx, - }); - } - Err(err) => { - tracing::warn!(error = %err, "IDE context IPC reader failed to write ide-context request"); - let _ = response_tx.send(Err(IdeContextError::Send(err))); - return IdeContextReaderCommandState::Shutdown; - } - } - } - Ok(IdeContextClientCommand::Shutdown) | Err(mpsc::TryRecvError::Disconnected) => { - return IdeContextReaderCommandState::Shutdown; - } - Err(mpsc::TryRecvError::Empty) => { - return IdeContextReaderCommandState::Continue; - } - } - } -} - -#[cfg(unix)] -fn wait_for_idle_reader_message(stream: &mut IdeContextStream) -> Result<(), IdeContextError> { - let idle_deadline = Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT; - stream.set_deadline(idle_deadline); - match stream.wait_for_ready(libc::POLLIN) { - Ok(()) => Ok(()), - Err(err) if err.kind() == std::io::ErrorKind::TimedOut => Ok(()), - Err(err) => Err(IdeContextError::Read(err)), - } -} - -#[cfg(windows)] -fn wait_for_idle_reader_message(stream: &mut IdeContextStream) -> Result<(), IdeContextError> { - let idle_deadline = Instant::now() + IDE_CONTEXT_IDLE_READ_TIMEOUT; - stream - .wait_until_readable(idle_deadline) - .map(|_| ()) - .map_err(IdeContextError::Read) -} - -#[cfg(any(unix, windows))] -fn drain_available_reader_messages( - stream: &mut IdeContextStream, - frame_buffer: &mut IpcFrameBuffer, - pending_request: &mut Option, +fn answer_unsupported_request( + stream: &mut T, + message: &Value, ) -> Result<(), IdeContextError> { - let messages = frame_buffer.read_available_messages(stream)?; - for message in messages { - handle_reader_message(stream, pending_request, message)?; + if let Some(inbound_request_id) = message.get("requestId").and_then(Value::as_str) { + let response = json!({ + "type": "response", + "requestId": inbound_request_id, + "resultType": "error", + "error": "no-handler-for-request", + }); + write_frame(stream, &response).map_err(IdeContextError::Send)?; } Ok(()) } #[cfg(any(unix, windows))] -fn fail_pending_request(pending_request: Option, err: IdeContextError) { - if let Some(pending) = pending_request { - let _ = pending.response_tx.send(Err(err)); - } else { - tracing::warn!(error = %err, "IDE context IPC reader exiting without a pending request"); - } -} - -#[cfg(any(unix, windows))] -fn handle_reader_message( +fn fetch_ide_context_from_stream( stream: &mut IdeContextStream, - pending_request: &mut Option, - message: Value, -) -> Result<(), IdeContextError> { - match message.get("type").and_then(Value::as_str) { - Some("response") => { - let matches_pending = pending_request.as_ref().is_some_and(|pending| { - message.get("requestId").and_then(Value::as_str) - == Some(pending.request_id.as_str()) - }); - if matches_pending && let Some(pending) = pending_request.take() { - let _ = pending.response_tx.send(extract_ide_context(message)); - } - Ok(()) - } - Some("broadcast") => Ok(()), - Some("client-discovery-request") => { - if let Some(discovery_request_id) = message.get("requestId").and_then(Value::as_str) { - let response = json!({ - "type": "client-discovery-response", - "requestId": discovery_request_id, - "response": { - "canHandle": false, - }, - }); - stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_WRITE_TIMEOUT); - write_frame(stream, &response).map_err(IdeContextError::Send)?; - } - Ok(()) - } - Some("request") => { - if let Some(inbound_request_id) = message.get("requestId").and_then(Value::as_str) { - let response = json!({ - "type": "response", - "requestId": inbound_request_id, - "resultType": "error", - "error": "no-handler-for-request", - }); - stream.set_deadline(Instant::now() + IDE_CONTEXT_IDLE_WRITE_TIMEOUT); - write_frame(stream, &response).map_err(IdeContextError::Send)?; - } - Ok(()) - } - Some(_) | None => Ok(()), - } -} - -#[cfg(any(unix, windows))] -fn initialize_client( - stream: &mut T, + workspace_root: &Path, deadline: Instant, -) -> Result { - let initialize_request_id = uuid::Uuid::new_v4().to_string(); - let initialize_request = json!({ - "type": "request", - "requestId": initialize_request_id.clone(), - "sourceClientId": "initializing-client", - "version": 0, - "method": "initialize", - "params": { - // Match the desktop client type so the current IDE extension can handle us unchanged. - "clientType": "desktop", - }, - }); - write_frame(stream, &initialize_request).map_err(IdeContextError::Send)?; - let initialize_response = read_response_frame(stream, &initialize_request_id, deadline)?; - extract_client_id(&initialize_response) +) -> Result { + let request_id = uuid::Uuid::new_v4().to_string(); + write_ide_context_request(stream, &request_id, workspace_root) + .map_err(IdeContextError::Send)?; + let response = read_response_frame(stream, &request_id, deadline)?; + extract_ide_context(response) } #[cfg(any(unix, windows))] fn write_ide_context_request( stream: &mut T, request_id: &str, - client_id: &str, workspace_root: &Path, ) -> std::io::Result<()> { let ide_context_request = json!({ "type": "request", "requestId": request_id, - "sourceClientId": client_id, + "sourceClientId": TUI_SOURCE_CLIENT_ID, "version": 0, "method": "ide-context", "params": { @@ -971,7 +725,7 @@ fn write_frame(stream: &mut T, message: &Value) -> s stream.flush() } -#[cfg(any(unix, windows))] +#[cfg(all(test, any(unix, windows)))] fn read_frame( stream: &mut T, deadline: Instant, @@ -989,7 +743,7 @@ fn read_frame( .map_err(|err| IdeContextError::InvalidResponse(format!("invalid JSON payload: {err}"))) } -#[cfg(any(unix, windows))] +#[cfg(all(test, any(unix, windows)))] fn read_exact_before_deadline( stream: &mut T, buf: &mut [u8], @@ -1019,14 +773,15 @@ fn read_exact_before_deadline( } #[cfg(any(unix, windows))] -fn read_response_frame( - stream: &mut T, +fn read_response_frame( + stream: &mut IdeContextStream, request_id: &str, deadline: Instant, ) -> Result { + let mut frame_buffer = IpcFrameBuffer::default(); loop { ensure_deadline_not_expired(deadline)?; - let message = read_frame(stream, deadline)?; + let message = frame_buffer.read_next_message(stream, deadline)?; match message.get("type").and_then(Value::as_str) { Some("response") => { if message.get("requestId").and_then(Value::as_str) == Some(request_id) { @@ -1047,7 +802,10 @@ fn read_response_frame( write_frame(stream, &response).map_err(IdeContextError::Send)?; } } - Some("client-discovery-response") | Some("request") => {} + Some("client-discovery-response") => {} + Some("request") => { + answer_unsupported_request(stream, &message)?; + } Some(other) => { return Err(IdeContextError::InvalidResponse(format!( "unexpected IDE context message type: {other}" @@ -1084,34 +842,11 @@ fn deadline_timeout_io_error() -> std::io::Error { ) } -#[cfg(any(unix, windows))] -fn ipc_reader_closed_io_error() -> std::io::Error { - std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "IDE context IPC reader is closed", - ) -} - #[cfg(unix)] fn permission_denied_io_error(message: &'static str) -> std::io::Error { std::io::Error::new(std::io::ErrorKind::PermissionDenied, message) } -#[cfg(any(unix, windows))] -fn extract_client_id(response: &Value) -> Result { - ensure_success_response(response)?; - response - .get("result") - .and_then(|result| result.get("clientId")) - .and_then(Value::as_str) - .map(str::to_string) - .ok_or_else(|| { - IdeContextError::InvalidResponse( - "initialize response did not include result.clientId".to_string(), - ) - }) -} - #[cfg(any(unix, windows))] fn extract_ide_context(response: Value) -> Result { ensure_success_response(&response)?; @@ -1156,24 +891,6 @@ mod tests { Instant::now() + Duration::from_secs(1) } - #[cfg(unix)] - fn write_initialize_response(stream: &mut impl std::io::Write, request_id: &str) { - write_frame( - stream, - &json!({ - "type": "response", - "requestId": request_id, - "resultType": "success", - "method": "initialize", - "handledByClientId": "server", - "result": { - "clientId": "rust-client" - } - }), - ) - .expect("write initialize response"); - } - #[cfg(unix)] fn write_ide_context_response( stream: &mut impl std::io::Write, @@ -1210,16 +927,17 @@ mod tests { } #[cfg(unix)] - fn write_frame_header(stream: &mut impl std::io::Write, message: &Value) -> Vec { - let payload = serde_json::to_vec(message).expect("serialize test IPC message"); + fn write_raw_frame(stream: &mut impl std::io::Write, payload: &[u8]) { let payload_len = u32::try_from(payload.len()) .expect("test IPC message length fits u32") .to_le_bytes(); stream .write_all(&payload_len) - .expect("write test IPC frame header"); - stream.flush().expect("flush test IPC frame header"); - payload + .expect("write raw test IPC frame header"); + stream + .write_all(payload) + .expect("write raw test IPC frame payload"); + stream.flush().expect("flush raw test IPC frame"); } #[cfg(any(unix, windows))] @@ -1303,7 +1021,7 @@ mod tests { #[cfg(unix)] #[test] - fn fetch_ide_context_handles_interleaved_messages() { + fn fetch_ide_context_uses_unregistered_request_route() { use std::os::unix::net::UnixListener; use std::thread; @@ -1314,24 +1032,6 @@ mod tests { let server = thread::spawn(move || { let (mut stream, _) = listener.accept().expect("accept"); - let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); - assert_eq!( - initialize.get("method").and_then(Value::as_str), - Some("initialize") - ); - assert_eq!( - initialize - .get("params") - .and_then(|params| params.get("clientType")) - .and_then(Value::as_str), - Some("desktop") - ); - let initialize_request_id = initialize - .get("requestId") - .and_then(Value::as_str) - .expect("initialize request id"); - write_initialize_response(&mut stream, initialize_request_id); - let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); assert_eq!( ide_context.get("method").and_then(Value::as_str), @@ -1339,7 +1039,7 @@ mod tests { ); assert_eq!( ide_context.get("sourceClientId").and_then(Value::as_str), - Some("rust-client") + Some(TUI_SOURCE_CLIENT_ID) ); assert_eq!( ide_context @@ -1355,18 +1055,26 @@ mod tests { write_frame( &mut stream, &json!({ - "type": "broadcast", - "method": "client-status-changed", + "type": "request", + "requestId": "inbound-request", "sourceClientId": "vscode-client", "version": 0, - "params": { - "clientId": "vscode-client", - "clientType": "vscode", - "status": "connected" - } + "method": "unknown-method", + "params": {} }), ) - .expect("write broadcast before ide-context response"); + .expect("write inbound request before ide-context response"); + let inbound_response = read_frame(&mut stream, test_deadline()) + .expect("read inbound request response before ide-context response"); + assert_eq!( + inbound_response, + json!({ + "type": "response", + "requestId": "inbound-request", + "resultType": "error", + "error": "no-handler-for-request" + }) + ); write_frame( &mut stream, @@ -1395,6 +1103,10 @@ mod tests { Some(false) ); + let mut broadcast = + br#"{"type":"broadcast","method":"thread-stream-state-changed","params":"#.to_vec(); + broadcast.resize(2 * 1024 * 1024, b' '); + write_raw_frame(&mut stream, &broadcast); write_ide_context_response(&mut stream, ide_context_request_id, "use"); }); @@ -1411,150 +1123,4 @@ mod tests { Some("use") ); } - - #[cfg(unix)] - #[test] - fn ide_context_client_sends_request_while_idle_frame_is_incomplete() { - use std::io::Write; - use std::os::unix::net::UnixListener; - use std::thread; - - let tempdir = tempfile::tempdir().expect("tempdir"); - let socket_path = tempdir.path().join("codex-ipc.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind socket"); - - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - - let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); - let initialize_request_id = initialize - .get("requestId") - .and_then(Value::as_str) - .expect("initialize request id"); - write_initialize_response(&mut stream, initialize_request_id); - - let payload = write_frame_header( - &mut stream, - &json!({ - "type": "broadcast", - "method": "client-status-changed", - "sourceClientId": "vscode-client", - "version": 0, - "params": { - "clientId": "vscode-client", - "clientType": "vscode", - "status": "connected" - } - }), - ); - - let ide_context = read_frame(&mut stream, test_deadline()) - .expect("client should write ide-context request before idle frame completes"); - let ide_context_request_id = ide_context - .get("requestId") - .and_then(Value::as_str) - .expect("ide-context request id"); - - stream - .write_all(&payload) - .expect("write delayed idle frame payload"); - stream.flush().expect("flush delayed idle frame payload"); - write_ide_context_response( - &mut stream, - ide_context_request_id, - "after incomplete idle frame", - ); - }); - - let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) - .expect("connect IDE context client"); - std::thread::sleep(IDE_CONTEXT_IDLE_READ_TIMEOUT * 2); - let context = client - .fetch_ide_context(Path::new("/repo")) - .expect("fetch IDE context while idle frame is incomplete"); - - server.join().expect("server joins"); - assert_eq!( - context - .active_file - .as_ref() - .map(|file| file.active_selection_content.as_str()), - Some("after incomplete idle frame") - ); - } - - #[cfg(unix)] - #[test] - fn ide_context_client_reuses_initialized_connection_for_prompt_requests() { - use std::os::unix::net::UnixListener; - use std::thread; - - let tempdir = tempfile::tempdir().expect("tempdir"); - let socket_path = tempdir.path().join("codex-ipc.sock"); - let listener = UnixListener::bind(&socket_path).expect("bind socket"); - - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - - let initialize = read_frame(&mut stream, test_deadline()).expect("read initialize"); - let initialize_request_id = initialize - .get("requestId") - .and_then(Value::as_str) - .expect("initialize request id"); - write_initialize_response(&mut stream, initialize_request_id); - - for active_selection_content in ["first", "second"] { - let ide_context = - read_frame(&mut stream, test_deadline()).expect("read ide-context"); - assert_eq!( - ide_context.get("method").and_then(Value::as_str), - Some("ide-context") - ); - assert_eq!( - ide_context.get("sourceClientId").and_then(Value::as_str), - Some("rust-client") - ); - assert_eq!( - ide_context - .get("params") - .and_then(|params| params.get("workspaceRoot")) - .and_then(Value::as_str), - Some("/repo") - ); - let ide_context_request_id = ide_context - .get("requestId") - .and_then(Value::as_str) - .expect("ide-context request id"); - write_ide_context_response( - &mut stream, - ide_context_request_id, - active_selection_content, - ); - } - }); - - let mut client = IdeContextClient::connect_to_socket(socket_path, Duration::from_secs(1)) - .expect("connect IDE context client"); - let first = client - .fetch_ide_context(Path::new("/repo")) - .expect("fetch first IDE context"); - let second = client - .fetch_ide_context(Path::new("/repo")) - .expect("fetch second IDE context"); - - server.join().expect("server joins"); - assert_eq!( - [ - first - .active_file - .as_ref() - .map(|file| file.active_selection_content.as_str()), - second - .active_file - .as_ref() - .map(|file| file.active_selection_content.as_str()), - ], - [Some("first"), Some("second")] - ); - } } diff --git a/codex-rs/tui/src/ide_context/windows_pipe.rs b/codex-rs/tui/src/ide_context/windows_pipe.rs index e88ca0039135..8074b65e8045 100644 --- a/codex-rs/tui/src/ide_context/windows_pipe.rs +++ b/codex-rs/tui/src/ide_context/windows_pipe.rs @@ -6,8 +6,6 @@ use std::io::Write; use std::os::windows::ffi::OsStrExt; use std::path::PathBuf; use std::ptr; -use std::thread; -use std::time::Duration; use std::time::Instant; use windows_sys::Win32::Foundation::BOOL; @@ -38,7 +36,6 @@ use windows_sys::Win32::System::IO::CancelIoEx; use windows_sys::Win32::System::IO::GetOverlappedResult; use windows_sys::Win32::System::IO::OVERLAPPED; use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId; -use windows_sys::Win32::System::Pipes::PeekNamedPipe; use windows_sys::Win32::System::Threading::CreateEventW; use windows_sys::Win32::System::Threading::GetCurrentProcess; use windows_sys::Win32::System::Threading::OpenProcess; @@ -87,52 +84,6 @@ impl WindowsPipeStream { pub(super) fn set_deadline(&mut self, deadline: Instant) { self.deadline = deadline; } - - pub(super) fn wait_until_readable(&self, deadline: Instant) -> io::Result { - loop { - if self.has_bytes_available()? { - return Ok(true); - } - let now = Instant::now(); - if now >= deadline { - return Ok(false); - } - - thread::sleep((deadline - now).min(Duration::from_millis(5))); - } - } - - fn has_bytes_available(&self) -> io::Result { - let mut bytes_available = 0; - let result = unsafe { - PeekNamedPipe( - self.handle.raw(), - ptr::null_mut(), - 0, - ptr::null_mut(), - &mut bytes_available, - ptr::null_mut(), - ) - }; - if result == 0 { - return Err(io::Error::last_os_error()); - } - - Ok(bytes_available > 0) - } - - pub(super) fn try_read_available(&mut self, buf: &mut [u8]) -> io::Result> { - if !self.has_bytes_available()? { - return Ok(None); - } - - self.deadline = Instant::now() + Duration::from_millis(1); - match self.read(buf) { - Ok(bytes_read) => Ok(Some(bytes_read)), - Err(err) if err.kind() == io::ErrorKind::TimedOut => Ok(None), - Err(err) => Err(err), - } - } } impl Read for WindowsPipeStream { From fc78c3e8967442affaf2db09ee324298f02f108e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 01:01:10 -0700 Subject: [PATCH 24/35] Simplify TUI IDE context state Remove the leftover persistent-client state now that IDE context uses request-scoped IPC sockets. Also collapse the single-state status-line indicator to a boolean and drop a helper-only deadline test. Addresses openai/codex#13834. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 16 +- codex-rs/tui/src/bottom_pane/footer.rs | 27 ++-- codex-rs/tui/src/bottom_pane/mod.rs | 8 +- codex-rs/tui/src/chatwidget/ide_context.rs | 98 +----------- codex-rs/tui/src/ide_context.rs | 3 +- codex-rs/tui/src/ide_context/ipc.rs | 151 +----------------- 6 files changed, 30 insertions(+), 273 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index b6bb9307237f..e25b54fa6996 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -157,7 +157,6 @@ use super::footer::FooterKeyHints; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::GoalStatusIndicator; -use super::footer::IdeContextStatusIndicator; use super::footer::SummaryLeft; use super::footer::can_show_left_with_context; use super::footer::context_window_line; @@ -384,7 +383,7 @@ pub(crate) struct ChatComposer { config: ChatComposerConfig, collaboration_mode_indicator: Option, goal_status_indicator: Option, - ide_context_status_indicator: Option, + ide_context_active: bool, connectors_enabled: bool, plugins_command_enabled: bool, fast_command_enabled: bool, @@ -563,7 +562,7 @@ impl ChatComposer { config, collaboration_mode_indicator: None, goal_status_indicator: None, - ide_context_status_indicator: None, + ide_context_active: false, connectors_enabled: false, plugins_command_enabled: false, fast_command_enabled: false, @@ -721,11 +720,8 @@ impl ChatComposer { self.goal_status_indicator = indicator; } - pub fn set_ide_context_status_indicator( - &mut self, - indicator: Option, - ) { - self.ide_context_status_indicator = indicator; + pub fn set_ide_context_active(&mut self, active: bool) { + self.ide_context_active = active; } pub fn set_personality_command_enabled(&mut self, enabled: bool) { @@ -4161,13 +4157,13 @@ impl ChatComposer { let full = status_line_right_indicator_line( self.collaboration_mode_indicator, self.goal_status_indicator.as_ref(), - self.ide_context_status_indicator.as_ref(), + self.ide_context_active, show_cycle_hint, ); let compact = status_line_right_indicator_line( self.collaboration_mode_indicator, self.goal_status_indicator.as_ref(), - self.ide_context_status_indicator.as_ref(), + self.ide_context_active, /*show_cycle_hint*/ false, ); let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 071971d02db9..3159781b39ee 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -104,11 +104,6 @@ pub(crate) enum GoalStatusIndicator { Complete { usage: Option }, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum IdeContextStatusIndicator { - Active, -} - const MODE_CYCLE_HINT: &str = "shift+tab to cycle"; const FOOTER_CONTEXT_GAP_COLS: u16 = 1; @@ -576,14 +571,12 @@ pub(crate) fn goal_status_indicator_line( pub(crate) fn status_line_right_indicator_line( collaboration_mode_indicator: Option, goal_status_indicator: Option<&GoalStatusIndicator>, - ide_context_indicator: Option<&IdeContextStatusIndicator>, + ide_context_active: bool, show_cycle_hint: bool, ) -> Option> { let primary_indicator = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint) .or_else(|| goal_status_indicator_line(goal_status_indicator)); - let ide_context_indicator = ide_context_indicator.map(|indicator| match indicator { - IdeContextStatusIndicator::Active => Line::from(vec!["IDE context".cyan()]), - }); + let ide_context_indicator = ide_context_active.then(|| Line::from(vec!["IDE context".cyan()])); let mut line: Option> = None; for indicator in [primary_indicator, ide_context_indicator] @@ -1283,7 +1276,7 @@ mod tests { height: u16, props: &FooterProps, collaboration_mode_indicator: Option, - ide_context_indicator: Option, + ide_context_active: bool, ) { terminal .draw(|f| { @@ -1347,13 +1340,13 @@ mod tests { let full = status_line_right_indicator_line( collaboration_mode_indicator, /*goal_status_indicator*/ None, - ide_context_indicator.as_ref(), + ide_context_active, show_cycle_hint, ); let compact = status_line_right_indicator_line( collaboration_mode_indicator, /*goal_status_indicator*/ None, - ide_context_indicator.as_ref(), + ide_context_active, /*show_cycle_hint*/ false, ); let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0); @@ -1468,7 +1461,7 @@ mod tests { height, props, collaboration_mode_indicator, - /*ide_context_indicator*/ None, + /*ide_context_active*/ false, ); assert_snapshot!(name, terminal.backend()); } @@ -1485,7 +1478,7 @@ mod tests { height, props, collaboration_mode_indicator, - /*ide_context_indicator*/ None, + /*ide_context_active*/ false, ); terminal.backend().vt100().screen().contents() } @@ -1495,7 +1488,7 @@ mod tests { width: u16, props: &FooterProps, collaboration_mode_indicator: Option, - ide_context_indicator: Option, + ide_context_active: bool, ) { let height = footer_height(props).max(1); let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); @@ -1504,7 +1497,7 @@ mod tests { height, props, collaboration_mode_indicator, - ide_context_indicator, + ide_context_active, ); assert_snapshot!(name, terminal.backend()); } @@ -1839,7 +1832,7 @@ mod tests { /*width*/ 120, &props, Some(CollaborationModeIndicator::Plan), - Some(IdeContextStatusIndicator::Active), + /*ide_context_active*/ true, ); let props = FooterProps { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2f80ad15e8d4..6b1d010c4376 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -97,7 +97,6 @@ mod skills_toggle_view; pub(crate) mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use footer::GoalStatusIndicator; -pub(crate) use footer::IdeContextStatusIndicator; #[cfg(test)] pub(crate) use footer::goal_status_indicator_line; pub(crate) use list_selection_view::ColumnWidthMode; @@ -377,11 +376,8 @@ impl BottomPane { self.request_redraw(); } - pub fn set_ide_context_status_indicator( - &mut self, - indicator: Option, - ) { - self.composer.set_ide_context_status_indicator(indicator); + pub fn set_ide_context_active(&mut self, active: bool) { + self.composer.set_ide_context_active(active); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/ide_context.rs b/codex-rs/tui/src/chatwidget/ide_context.rs index d7d40a61ad10..bd564ffbaef0 100644 --- a/codex-rs/tui/src/chatwidget/ide_context.rs +++ b/codex-rs/tui/src/chatwidget/ide_context.rs @@ -1,27 +1,13 @@ //! Chat-widget wiring for the `/ide` command and IDE context prompt injection. -use std::path::Path; -use std::time::Duration; -use std::time::Instant; - use codex_protocol::user_input::UserInput; use super::ChatWidget; -use crate::bottom_pane::IdeContextStatusIndicator; -use crate::ide_context::IdeContext; -use crate::ide_context::IdeContextClient; -use crate::ide_context::IdeContextError; - -const IDE_CONTEXT_RECENT_TOGGLE_RETRY_WINDOW: Duration = Duration::from_secs(5); -const IDE_CONTEXT_RECENT_TOGGLE_RETRY_DELAY: Duration = Duration::from_millis(250); -const IDE_CONTEXT_RECENT_TOGGLE_RETRY_ATTEMPTS: usize = 12; #[derive(Default)] pub(super) struct IdeContextState { enabled: bool, prompt_fetch_warned: bool, - last_disabled_at: Option, - client: Option, } impl IdeContextState { @@ -37,73 +23,10 @@ impl IdeContextState { fn disable(&mut self) { self.enabled = false; self.prompt_fetch_warned = false; - self.last_disabled_at = Some(Instant::now()); - self.client = None; } fn mark_available(&mut self) { self.prompt_fetch_warned = false; - self.last_disabled_at = None; - } - - fn status_indicator(&self) -> Option { - self.enabled.then_some(IdeContextStatusIndicator::Active) - } - - fn should_retry_recent_toggle(&self) -> bool { - self.last_disabled_at.is_some_and(|disabled_at| { - disabled_at.elapsed() <= IDE_CONTEXT_RECENT_TOGGLE_RETRY_WINDOW - }) - } - - fn fetch_context_for_probe( - &mut self, - workspace_root: &Path, - ) -> Result { - let client = if let Some(client) = self.client.as_mut() { - client - } else { - self.client.insert(IdeContextClient::connect()?) - }; - - let result = client.fetch_ide_context(workspace_root); - if let Err(err) = &result - && err.should_reset_client() - { - self.client = None; - } - result - } - - fn fetch_context_for_prompt( - &mut self, - workspace_root: &Path, - ) -> Result { - let mut retried_after_reset = false; - loop { - let client = if let Some(client) = self.client.as_mut() { - client - } else { - self.client.insert(IdeContextClient::connect()?) - }; - - let result = client.fetch_ide_context(workspace_root); - match result { - Ok(context) => return Ok(context), - Err(err) => { - let should_retry = - !retried_after_reset && err.should_retry_prompt_fetch_after_reset(); - if err.should_reset_client() || should_retry { - self.client = None; - } - if should_retry { - retried_after_reset = true; - continue; - } - return Err(err); - } - } - } } } @@ -146,7 +69,7 @@ impl ChatWidget { return; } - match self.ide_context.fetch_context_for_prompt(&self.config.cwd) { + match crate::ide_context::fetch_ide_context(&self.config.cwd) { Ok(context) => { self.ide_context.mark_available(); self.sync_ide_context_status_indicator(); @@ -172,22 +95,7 @@ impl ChatWidget { return; } - let mut fetch_result = self.ide_context.fetch_context_for_probe(&self.config.cwd); - if self.ide_context.should_retry_recent_toggle() { - // The previous IDE context connection may still be winding down. - for _ in 0..IDE_CONTEXT_RECENT_TOGGLE_RETRY_ATTEMPTS { - if !matches!( - fetch_result, - Err(ref err) if err.is_retryable_after_recent_toggle() - ) { - break; - } - std::thread::sleep(IDE_CONTEXT_RECENT_TOGGLE_RETRY_DELAY); - fetch_result = self.ide_context.fetch_context_for_probe(&self.config.cwd); - } - } - - match fetch_result { + match crate::ide_context::fetch_ide_context(&self.config.cwd) { Ok(context) => { self.ide_context.mark_available(); self.sync_ide_context_status_indicator(); @@ -219,6 +127,6 @@ impl ChatWidget { pub(super) fn sync_ide_context_status_indicator(&mut self) { self.bottom_pane - .set_ide_context_status_indicator(self.ide_context.status_indicator()); + .set_ide_context_active(self.ide_context.is_enabled()); } } diff --git a/codex-rs/tui/src/ide_context.rs b/codex-rs/tui/src/ide_context.rs index 356125f12649..bfea02ef5013 100644 --- a/codex-rs/tui/src/ide_context.rs +++ b/codex-rs/tui/src/ide_context.rs @@ -5,8 +5,7 @@ mod prompt; #[cfg(windows)] mod windows_pipe; -pub(crate) use ipc::IdeContextClient; -pub(crate) use ipc::IdeContextError; +pub(crate) use ipc::fetch_ide_context; pub(crate) use prompt::apply_ide_context_to_user_input; pub(crate) use prompt::extract_prompt_request_with_offset; pub(crate) use prompt::has_prompt_context; diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 56e036689d72..3ac95854789d 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -54,21 +54,6 @@ pub(crate) enum IdeContextError { } impl IdeContextError { - /// Returns true for short-lived states that can appear just after the TUI disconnects. - #[cfg(any(unix, windows))] - pub(crate) fn is_retryable_after_recent_toggle(&self) -> bool { - match self { - IdeContextError::RequestFailed(error) => { - matches!(error.as_str(), "no-client-found" | "client-disconnected") - } - IdeContextError::Read(error) => error.kind() == std::io::ErrorKind::WouldBlock, - IdeContextError::Connect(_) - | IdeContextError::Send(_) - | IdeContextError::InvalidResponse(_) - | IdeContextError::ResponseTooLarge => false, - } - } - #[cfg(any(unix, windows))] pub(crate) fn user_facing_hint(&self) -> String { match self { @@ -148,45 +133,6 @@ impl IdeContextError { } } - #[cfg(any(unix, windows))] - pub(crate) fn should_retry_prompt_fetch_after_reset(&self) -> bool { - match self { - IdeContextError::Send(_) => true, - IdeContextError::Read(error) => matches!( - error.kind(), - std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::UnexpectedEof - ), - IdeContextError::RequestFailed(error) => error == "client-disconnected", - IdeContextError::Connect(_) - | IdeContextError::InvalidResponse(_) - | IdeContextError::ResponseTooLarge => false, - } - } - - #[cfg(any(unix, windows))] - pub(crate) fn should_reset_client(&self) -> bool { - match self { - IdeContextError::Connect(_) | IdeContextError::Send(_) => true, - IdeContextError::Read(error) => matches!( - error.kind(), - std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::UnexpectedEof - ), - IdeContextError::InvalidResponse(_) | IdeContextError::ResponseTooLarge => true, - IdeContextError::RequestFailed(_) => false, - } - } - - #[cfg(not(any(unix, windows)))] - pub(crate) fn is_retryable_after_recent_toggle(&self) -> bool { - false - } - #[cfg(not(any(unix, windows)))] pub(crate) fn user_facing_hint(&self) -> String { self.to_string() @@ -196,25 +142,8 @@ impl IdeContextError { pub(crate) fn prompt_skip_hint(&self) -> String { self.to_string() } - - #[cfg(not(any(unix, windows)))] - pub(crate) fn should_reset_client(&self) -> bool { - false - } - - #[cfg(not(any(unix, windows)))] - pub(crate) fn should_retry_prompt_fetch_after_reset(&self) -> bool { - false - } } -/// IPC request handle used while TUI `/ide` mode is enabled. -/// -/// Each fetch uses a short-lived socket without sending `initialize`. That keeps the TUI out of -/// the router's broadcast fanout while still using the same `ide-context` request route. -#[cfg(any(unix, windows))] -pub(crate) struct IdeContextClient; - #[cfg(unix)] type IdeContextStream = UnixDeadlineStream; @@ -355,38 +284,17 @@ fn frame_payload_is_broadcast(payload: &[u8]) -> bool { } #[cfg(any(unix, windows))] -impl IdeContextClient { - pub(crate) fn connect() -> Result { - Ok(Self) - } - - pub(crate) fn fetch_ide_context( - &mut self, - workspace_root: &Path, - ) -> Result { - fetch_ide_context_from_socket( - default_ipc_socket_path(), - workspace_root, - IDE_CONTEXT_REQUEST_TIMEOUT, - ) - } +pub(crate) fn fetch_ide_context(workspace_root: &Path) -> Result { + fetch_ide_context_from_socket( + default_ipc_socket_path(), + workspace_root, + IDE_CONTEXT_REQUEST_TIMEOUT, + ) } #[cfg(not(any(unix, windows)))] -pub(crate) struct IdeContextClient; - -#[cfg(not(any(unix, windows)))] -impl IdeContextClient { - pub(crate) fn connect() -> Result { - Err(IdeContextError::UnsupportedPlatform) - } - - pub(crate) fn fetch_ide_context( - &mut self, - _workspace_root: &Path, - ) -> Result { - Err(IdeContextError::UnsupportedPlatform) - } +pub(crate) fn fetch_ide_context(_workspace_root: &Path) -> Result { + Err(IdeContextError::UnsupportedPlatform) } #[cfg(unix)] @@ -940,49 +848,6 @@ mod tests { stream.flush().expect("flush raw test IPC frame"); } - #[cfg(any(unix, windows))] - #[test] - fn read_frame_respects_deadline_while_reading_payload() { - struct SlowPayloadReader { - header: [u8; 4], - header_sent: bool, - payload: Vec, - } - - impl std::io::Read for SlowPayloadReader { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - if !self.header_sent { - self.header_sent = true; - buf[..self.header.len()].copy_from_slice(&self.header); - return Ok(self.header.len()); - } - - std::thread::sleep(Duration::from_millis(20)); - let bytes_to_copy = self.payload.len().min(buf.len()); - buf[..bytes_to_copy].copy_from_slice(&self.payload[..bytes_to_copy]); - self.payload.drain(..bytes_to_copy); - Ok(bytes_to_copy) - } - } - - let payload = br#"{"type":"response"}"#.to_vec(); - let mut stream = SlowPayloadReader { - header: u32::try_from(payload.len()) - .expect("payload length fits u32") - .to_le_bytes(), - header_sent: false, - payload, - }; - - let err = read_frame(&mut stream, Instant::now() + Duration::from_millis(1)) - .expect_err("expired deadline should fail while reading payload"); - - assert!(matches!( - err, - IdeContextError::Read(error) if error.kind() == std::io::ErrorKind::TimedOut - )); - } - #[cfg(unix)] #[test] fn unix_deadline_stream_uses_remaining_deadline_for_blocking_reads() { From ecf38458bf48130991c525637614738160bb4c57 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 01:12:56 -0700 Subject: [PATCH 25/35] Address IDE context review feedback Document the desktop-compatible prompt delimiter behavior and fix the review issues around Windows overlapped timeouts and hidden-context history rendering. Addresses openai/codex#13834 --- codex-rs/tui/src/chatwidget.rs | 59 +++---------- codex-rs/tui/src/chatwidget/realtime.rs | 16 +++- .../chatwidget/tests/composer_submission.rs | 86 +++++++++++++++++++ codex-rs/tui/src/ide_context/prompt.rs | 4 + codex-rs/tui/src/ide_context/windows_pipe.rs | 11 +-- 5 files changed, 120 insertions(+), 56 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 51b2fa4795fe..c45a6f5ff4ac 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6300,9 +6300,8 @@ impl ChatWidget { self.refresh_pending_input_preview(); } - // Show replayable user content in conversation history. - let display_user_message = render_in_history.then(|| { - user_message_for_restore( + if render_in_history { + let event = user_message_event_for_display( UserMessage { text, local_images, @@ -6311,48 +6310,15 @@ impl ChatWidget { mention_bindings, }, &history_record, - ) - }); - if let Some(display_user_message) = display_user_message { - let UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings: _, - } = display_user_message; - if !text.is_empty() { - let local_image_paths = local_images - .into_iter() - .map(|img| img.path) - .collect::>(); - self.last_rendered_user_message_event = - Some(Self::rendered_user_message_event_from_parts( - text.clone(), - text_elements.clone(), - local_image_paths.clone(), - remote_image_urls.clone(), - )); - self.add_to_history(history_cell::new_user_prompt( - text, - text_elements, - local_image_paths, - remote_image_urls, - )); - self.record_visible_user_turn_for_copy(); - } else if !remote_image_urls.is_empty() { - self.last_rendered_user_message_event = - Some(Self::rendered_user_message_event_from_parts( - String::new(), - Vec::new(), - Vec::new(), - remote_image_urls.clone(), - )); + ); + let rendered = Self::rendered_user_message_event_from_event(&event); + self.last_rendered_user_message_event = Some(rendered.clone()); + if Self::rendered_user_message_event_has_visible_content(&rendered) { self.add_to_history(history_cell::new_user_prompt( - String::new(), - Vec::new(), - Vec::new(), - remote_image_urls, + rendered.message, + rendered.text_elements, + rendered.local_images, + rendered.remote_image_urls, )); self.record_visible_user_turn_for_copy(); } @@ -7779,10 +7745,7 @@ impl ChatWidget { fn on_user_message_event(&mut self, event: UserMessageEvent) { let rendered = Self::rendered_user_message_event_from_event(&event); self.last_rendered_user_message_event = Some(rendered.clone()); - if !rendered.message.trim().is_empty() - || !rendered.text_elements.is_empty() - || !rendered.remote_image_urls.is_empty() - { + if Self::rendered_user_message_event_has_visible_content(&rendered) { self.record_visible_user_turn_for_copy(); self.add_to_history(history_cell::new_user_prompt( rendered.message, diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index e059f265a359..cd71d70efbf7 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -86,9 +86,10 @@ fn rebase_text_elements_for_prompt_request( prompt_request_offset: usize, prompt_request_len: usize, ) -> Vec { - // Prompt context is folded into the raw user message for the agent, but the transcript shows - // only the user's request. Keep elements inside that visible request and shift their byte - // ranges so mentions/images still line up with the rendered text. + // Prompt context uses the same delimiter and stripping behavior as the desktop app and IDE + // extension. The raw user message goes to the agent, but every surface renders only the request + // after that delimiter, so keep elements inside the visible request and shift their byte ranges + // to match. let prompt_request_end = prompt_request_offset + prompt_request_len; text_elements .iter() @@ -138,6 +139,15 @@ impl ChatWidget { ) } + pub(super) fn rendered_user_message_event_has_visible_content( + rendered: &RenderedUserMessageEvent, + ) -> bool { + !rendered.message.trim().is_empty() + || !rendered.text_elements.is_empty() + || !rendered.local_images.is_empty() + || !rendered.remote_image_urls.is_empty() + } + /// Build the compare key for a submitted pending steer without invoking the /// expensive request-serialization path. Pending steers only need to match the /// committed `ItemCompleted(UserMessage)` emitted after core drains input, which diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 8674734a5871..17a85eafa11d 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1221,6 +1221,92 @@ async fn committed_user_message_hides_prompt_context_in_history_cell() { assert_snapshot!(rendered_cell.trim(), @"› Ask Codex"); } +#[tokio::test] +async fn committed_user_message_with_hidden_prompt_context_renders_local_images() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let local_image = PathBuf::from("/tmp/context-image.png"); + let raw_message = + "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\n"; + + complete_user_message_for_inputs( + &mut chat, + "user-1", + vec![ + UserInput::Text { + text: raw_message.to_string(), + text_elements: Vec::new(), + }, + UserInput::LocalImage { + path: local_image.clone(), + }, + ], + ); + + let mut user_cell = None; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some((cell.message.clone(), cell.local_image_paths.clone())); + break; + } + } + + let (message, local_images) = user_cell.expect("expected user history cell"); + assert_eq!(message, ""); + assert_eq!(local_images, vec![local_image]); +} + +#[tokio::test] +async fn submitted_user_message_uses_same_prompt_request_rendering_for_dedupe() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + cwd: test_path_buf("/home/user/project").abs(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + drain_insert_history(&mut rx); + let raw_message = "Before\n## My request for Codex:\nAfter"; + + chat.bottom_pane + .set_composer_text(raw_message.to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { .. } => {} + other => panic!("expected Op::UserTurn, got {other:?}"), + } + + complete_user_message(&mut chat, "user-1", raw_message); + + let mut user_messages = Vec::new(); + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_messages.push(cell.message.clone()); + } + } + + assert_eq!(user_messages, vec!["After".to_string()]); +} + #[tokio::test] async fn interrupt_restores_queued_messages_into_composer() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/ide_context/prompt.rs b/codex-rs/tui/src/ide_context/prompt.rs index 0eeee1870229..3390c4b49946 100644 --- a/codex-rs/tui/src/ide_context/prompt.rs +++ b/codex-rs/tui/src/ide_context/prompt.rs @@ -7,6 +7,10 @@ use codex_protocol::user_input::UserInput; use super::IdeContext; const MAX_ACTIVE_SELECTION_CHARS: usize = 200_000; +// Match the desktop app and IDE extension delimiter exactly. IDE context is serialized into the +// raw prompt before this marker, then transcript rendering strips back to the request after the last +// marker. Keeping the same marker and stripping semantics lets threads created with IDE context in +// one surface replay cleanly in the others. const PROMPT_REQUEST_BEGIN: &str = "## My request for Codex:"; pub(crate) fn apply_ide_context_to_user_input( diff --git a/codex-rs/tui/src/ide_context/windows_pipe.rs b/codex-rs/tui/src/ide_context/windows_pipe.rs index 8074b65e8045..95d2abf0f611 100644 --- a/codex-rs/tui/src/ide_context/windows_pipe.rs +++ b/codex-rs/tui/src/ide_context/windows_pipe.rs @@ -170,8 +170,9 @@ impl OverlappedOperation { return Err(error); } - match unsafe { WaitForSingleObject(self.event.raw(), remaining_timeout_ms(deadline)?) } - { + // Use a zero wait after the deadline so pending overlapped I/O still flows through + // cancel_and_timeout instead of returning while the OS operation owns this OVERLAPPED. + match unsafe { WaitForSingleObject(self.event.raw(), remaining_timeout_ms(deadline)) } { WAIT_OBJECT_0 => {} WAIT_TIMEOUT => return Err(self.cancel_and_timeout(handle)), WAIT_FAILED => return Err(io::Error::last_os_error()), @@ -312,14 +313,14 @@ fn token_user(token: HANDLE) -> io::Result { Ok(TokenUserBuffer { buffer }) } -fn remaining_timeout_ms(deadline: Instant) -> io::Result { +fn remaining_timeout_ms(deadline: Instant) -> u32 { let now = Instant::now(); if now >= deadline { - return Err(timeout_io_error()); + return 0; } let millis = deadline.duration_since(now).as_millis().max(1); - Ok(u32::try_from(millis).unwrap_or(u32::MAX)) + u32::try_from(millis).unwrap_or(u32::MAX) } fn timeout_io_error() -> io::Error { From b23dcb91ac8b6ab3690a93b256203df4707845e4 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 01:23:45 -0700 Subject: [PATCH 26/35] codex: fix CI failure on PR #20294 Add the active permission profile field to the new TUI session configuration test initializer after rebasing onto the latest main. Addresses openai/codex#13834 --- codex-rs/tui/src/chatwidget/tests/composer_submission.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 17a85eafa11d..e36f86d73a1b 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1270,6 +1270,7 @@ async fn submitted_user_message_uses_same_prompt_request_rendering_for_dedupe() approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, From e6711d44496c7944010473be9695f23e027b4927 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 01:36:04 -0700 Subject: [PATCH 27/35] codex: address PR review feedback (#20294) Update history replay coverage so local-image-only user messages are rendered, matching the IDE-context hidden prompt behavior fixed in this PR. Addresses openai/codex#13834 --- .../tui/src/chatwidget/tests/history_replay.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 5fdb615b9731..c0d522e0d086 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -435,7 +435,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { } #[tokio::test] -async fn replayed_user_message_with_only_local_images_does_not_render_history_cell() { +async fn replayed_user_message_with_only_local_images_renders_history_cell() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; let local_images = vec![PathBuf::from("/tmp/replay-local-only.png")]; @@ -461,7 +461,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce message: String::new(), images: None, text_elements: Vec::new(), - local_images, + local_images: local_images.clone(), })]), network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -472,17 +472,20 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce msg: EventMsg::SessionConfigured(configured), }); - let mut found_user_history_cell = false; + let mut user_cell = None; while let Ok(ev) = rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = ev - && cell.as_any().downcast_ref::().is_some() + && let Some(cell) = cell.as_any().downcast_ref::() { - found_user_history_cell = true; + user_cell = Some((cell.message.clone(), cell.local_image_paths.clone())); break; } } - assert!(!found_user_history_cell); + let (stored_message, stored_local_images) = + user_cell.expect("expected a replayed local-image-only user history cell"); + assert!(stored_message.is_empty()); + assert_eq!(stored_local_images, local_images); } #[tokio::test] From b58e7239a43a3f55765948f4a7353d06e294f9f9 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 01:40:29 -0700 Subject: [PATCH 28/35] codex: address PR review feedback (#20294) Bound injected IDE context size and parse IPC frames before classifying broadcasts. Addresses openai/codex#13834 --- codex-rs/tui/src/ide_context/ipc.rs | 100 +++++-------------------- codex-rs/tui/src/ide_context/prompt.rs | 40 +++++++++- 2 files changed, 57 insertions(+), 83 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 3ac95854789d..1367bbba97ec 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -22,10 +22,6 @@ const IPC_READ_CHUNK_BYTES: usize = 64 * 1024; #[cfg(any(unix, windows))] const MAX_IPC_FRAME_BYTES: usize = 256 * 1024 * 1024; #[cfg(any(unix, windows))] -const BROADCAST_TYPE_JSON: &[u8] = br#""type":"broadcast""#; -#[cfg(any(unix, windows))] -const BROADCAST_PREFIX_SCAN_BYTES: usize = 256; -#[cfg(any(unix, windows))] const TUI_SOURCE_CLIENT_ID: &str = "codex-tui"; #[derive(Debug, Error)] @@ -155,7 +151,6 @@ type IdeContextStream = super::windows_pipe::WindowsPipeStream; struct IpcFrameBuffer { buffer: Vec, expected_len: Option, - discard_remaining: usize, } #[cfg(any(unix, windows))] @@ -196,46 +191,8 @@ impl IpcFrameBuffer { } } - fn push_bytes(&mut self, mut bytes: &[u8]) { - while !bytes.is_empty() { - if self.discard_remaining > 0 { - let bytes_to_discard = bytes.len().min(self.discard_remaining); - self.discard_remaining -= bytes_to_discard; - bytes = &bytes[bytes_to_discard..]; - continue; - } - - self.buffer.extend_from_slice(bytes); - self.maybe_start_discarding_broadcast(); - break; - } - } - - fn maybe_start_discarding_broadcast(&mut self) { - if self.expected_len.is_none() && self.buffer.len() >= 4 { - self.read_frame_len(); - } - - let Some(expected_len) = self.expected_len else { - return; - }; - if expected_len > MAX_IPC_FRAME_BYTES { - return; - } - if self.buffer.len() >= expected_len { - return; - } - - let prefix_len = self.buffer.len().min(BROADCAST_PREFIX_SCAN_BYTES); - if prefix_len < BROADCAST_TYPE_JSON.len() { - return; - } - - if frame_payload_is_broadcast(&self.buffer[..prefix_len]) { - self.discard_remaining = expected_len - self.buffer.len(); - self.buffer.clear(); - self.expected_len = None; - } + fn push_bytes(&mut self, bytes: &[u8]) { + self.buffer.extend_from_slice(bytes); } fn pop_complete_frame(&mut self) -> Result, IdeContextError> { @@ -253,18 +210,15 @@ impl IpcFrameBuffer { return Ok(None); } - if frame_payload_is_broadcast(&self.buffer[..expected_len]) { - self.buffer.drain(..expected_len); - self.expected_len = None; - return Ok(Some(IpcFrame::IgnoredBroadcast)); - } - let payload = self.buffer.drain(..expected_len).collect::>(); self.expected_len = None; - serde_json::from_slice(&payload) - .map(IpcFrame::Message) - .map(Some) - .map_err(|err| IdeContextError::InvalidResponse(format!("invalid JSON payload: {err}"))) + let message = serde_json::from_slice::(&payload).map_err(|err| { + IdeContextError::InvalidResponse(format!("invalid JSON payload: {err}")) + })?; + if message.get("type").and_then(Value::as_str) == Some("broadcast") { + return Ok(Some(IpcFrame::IgnoredBroadcast)); + } + Ok(Some(IpcFrame::Message(message))) } fn read_frame_len(&mut self) { @@ -275,14 +229,6 @@ impl IpcFrameBuffer { } } -#[cfg(any(unix, windows))] -fn frame_payload_is_broadcast(payload: &[u8]) -> bool { - let prefix = &payload[..payload.len().min(BROADCAST_PREFIX_SCAN_BYTES)]; - prefix - .windows(BROADCAST_TYPE_JSON.len()) - .any(|window| window == BROADCAST_TYPE_JSON) -} - #[cfg(any(unix, windows))] pub(crate) fn fetch_ide_context(workspace_root: &Path) -> Result { fetch_ide_context_from_socket( @@ -814,6 +760,7 @@ mod tests { "method": "ide-context", "handledByClientId": "vscode-client", "result": { + "type": "broadcast", "ideContext": { "activeFile": { "label": "lib.rs", @@ -834,20 +781,6 @@ mod tests { .expect("write ide-context response"); } - #[cfg(unix)] - fn write_raw_frame(stream: &mut impl std::io::Write, payload: &[u8]) { - let payload_len = u32::try_from(payload.len()) - .expect("test IPC message length fits u32") - .to_le_bytes(); - stream - .write_all(&payload_len) - .expect("write raw test IPC frame header"); - stream - .write_all(payload) - .expect("write raw test IPC frame payload"); - stream.flush().expect("flush raw test IPC frame"); - } - #[cfg(unix)] #[test] fn unix_deadline_stream_uses_remaining_deadline_for_blocking_reads() { @@ -968,10 +901,15 @@ mod tests { Some(false) ); - let mut broadcast = - br#"{"type":"broadcast","method":"thread-stream-state-changed","params":"#.to_vec(); - broadcast.resize(2 * 1024 * 1024, b' '); - write_raw_frame(&mut stream, &broadcast); + write_frame( + &mut stream, + &json!({ + "type": "broadcast", + "method": "thread-stream-state-changed", + "params": "x".repeat(2 * 1024 * 1024), + }), + ) + .expect("write large broadcast"); write_ide_context_response(&mut stream, ide_context_request_id, "use"); }); diff --git a/codex-rs/tui/src/ide_context/prompt.rs b/codex-rs/tui/src/ide_context/prompt.rs index 3390c4b49946..4a8e2b23799d 100644 --- a/codex-rs/tui/src/ide_context/prompt.rs +++ b/codex-rs/tui/src/ide_context/prompt.rs @@ -6,7 +6,9 @@ use codex_protocol::user_input::UserInput; use super::IdeContext; -const MAX_ACTIVE_SELECTION_CHARS: usize = 200_000; +const MAX_ACTIVE_SELECTION_CHARS: usize = 40_000; +const MAX_OPEN_TABS: usize = 100; +const MAX_OPEN_TABS_CHARS: usize = 20_000; // Match the desktop app and IDE extension delimiter exactly. IDE context is serialized into the // raw prompt before this marker, then transcript rendering strips back to the request after the last // marker. Keeping the same marker and stripping semantics lets threads created with IDE context in @@ -138,8 +140,26 @@ fn render_prompt_context(context: &IdeContext) -> Option { if !context.open_tabs.is_empty() { ide_context_section.push_str("\n## Open tabs:\n"); + let mut rendered_tabs = 0; + let mut rendered_tab_chars = 0; for tab in &context.open_tabs { - ide_context_section.push_str(&format!("- {}: {}\n", tab.label, tab.path)); + if rendered_tabs >= MAX_OPEN_TABS { + break; + } + + let tab_line = format!("- {}: {}\n", tab.label, tab.path); + if rendered_tab_chars + tab_line.len() > MAX_OPEN_TABS_CHARS { + break; + } + + ide_context_section.push_str(&tab_line); + rendered_tabs += 1; + rendered_tab_chars += tab_line.len(); + } + + let omitted_tabs = context.open_tabs.len() - rendered_tabs; + if omitted_tabs > 0 { + ide_context_section.push_str(&format!("[{omitted_tabs} open tabs omitted.]\n")); } } @@ -344,4 +364,20 @@ mod tests { ))); assert!(!rendered.contains("tail")); } + + #[test] + fn render_prompt_context_omits_excess_open_tabs() { + let open_tabs = (0..MAX_OPEN_TABS + 2) + .map(|index| descriptor(&format!("file-{index}.rs"), &format!("src/file-{index}.rs"))) + .collect::>(); + let context = IdeContext { + active_file: None, + open_tabs, + }; + + let rendered = render_prompt_context(&context).expect("rendered IDE context"); + assert!(rendered.contains("- file-99.rs: src/file-99.rs\n")); + assert!(!rendered.contains("- file-100.rs: src/file-100.rs\n")); + assert!(rendered.contains("[2 open tabs omitted.]\n")); + } } From e4b50b4c2f1e742d4f783059b8a8902bd137a07c Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 01:51:56 -0700 Subject: [PATCH 29/35] codex: fix Windows clippy on PR #20294 --- codex-rs/tui/src/ide_context/ipc.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 1367bbba97ec..1a9d9e4b8d8a 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -579,7 +579,7 @@ fn write_frame(stream: &mut T, message: &Value) -> s stream.flush() } -#[cfg(all(test, any(unix, windows)))] +#[cfg(all(test, unix))] fn read_frame( stream: &mut T, deadline: Instant, @@ -597,7 +597,7 @@ fn read_frame( .map_err(|err| IdeContextError::InvalidResponse(format!("invalid JSON payload: {err}"))) } -#[cfg(all(test, any(unix, windows)))] +#[cfg(all(test, unix))] fn read_exact_before_deadline( stream: &mut T, buf: &mut [u8], @@ -734,7 +734,7 @@ fn ensure_success_response(response: &Value) -> Result<(), IdeContextError> { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; #[cfg(unix)] From 6b7cc419bd4ade4c328a5604863d316f96ef51e6 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 01:57:10 -0700 Subject: [PATCH 30/35] codex: address PR review feedback (#20294) --- codex-rs/tui/src/ide_context/ipc.rs | 250 +++++++++++++++++++++++----- 1 file changed, 210 insertions(+), 40 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 1a9d9e4b8d8a..70a0a3176eac 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -289,10 +289,8 @@ struct UnixDeadlineStream { #[cfg(unix)] impl UnixDeadlineStream { fn connect(socket_path: PathBuf, deadline: Instant) -> std::io::Result { - validate_unix_socket_path(&socket_path)?; - let stream = std::os::unix::net::UnixStream::connect(socket_path)?; + let stream = connect_unix_stream_before_deadline(&socket_path, deadline)?; validate_unix_peer_owner(&stream)?; - stream.set_nonblocking(true)?; Ok(Self::new(stream, deadline)) } @@ -304,49 +302,221 @@ impl UnixDeadlineStream { self.deadline = deadline; } - fn remaining_timeout(&self) -> std::io::Result { - self.deadline - .checked_duration_since(Instant::now()) - .filter(|duration| !duration.is_zero()) - .ok_or_else(deadline_timeout_io_error) + fn wait_for_ready(&self, events: libc::c_short) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + wait_for_fd_ready(self.stream.as_raw_fd(), events, self.deadline) } +} - fn remaining_timeout_ms(&self) -> std::io::Result { - let millis = self.remaining_timeout()?.as_millis().max(1); - Ok(libc::c_int::try_from(millis).unwrap_or(libc::c_int::MAX)) +#[cfg(unix)] +fn connect_unix_stream_before_deadline( + socket_path: &Path, + deadline: Instant, +) -> std::io::Result { + use std::os::fd::AsRawFd; + use std::os::fd::FromRawFd; + use std::os::fd::IntoRawFd; + use std::os::fd::OwnedFd; + + validate_unix_socket_path(socket_path)?; + let (addr, addr_len) = unix_socket_addr(socket_path)?; + let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); } + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + set_fd_close_on_exec(fd.as_raw_fd())?; + set_fd_nonblocking(fd.as_raw_fd())?; - fn wait_for_ready(&self, events: libc::c_short) -> std::io::Result<()> { - use std::os::fd::AsRawFd; + let result = unsafe { + libc::connect( + fd.as_raw_fd(), + &addr as *const libc::sockaddr_un as *const libc::sockaddr, + addr_len, + ) + }; + if result != 0 { + let error = std::io::Error::last_os_error(); + if !is_in_progress_connect_error(&error) { + return Err(error); + } - loop { - // Keep deadline handling in user space. Some macOS Unix socket environments reject - // SO_RCVTIMEO/SO_SNDTIMEO, but poll works consistently for our request-scoped timeout. - let mut poll_fd = libc::pollfd { - fd: self.stream.as_raw_fd(), - events, - revents: 0, - }; - let result = unsafe { libc::poll(&mut poll_fd, 1, self.remaining_timeout_ms()?) }; - if result == 0 { - return Err(deadline_timeout_io_error()); - } - if result < 0 { - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::Interrupted { - continue; - } - return Err(error); - } - if poll_fd.revents & libc::POLLNVAL != 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "invalid IDE context Unix socket", - )); - } - if poll_fd.revents & (events | libc::POLLERR | libc::POLLHUP) != 0 { - return Ok(()); + wait_for_fd_ready(fd.as_raw_fd(), libc::POLLOUT, deadline)?; + let socket_error = socket_error(fd.as_raw_fd())?; + if socket_error != 0 { + return Err(std::io::Error::from_raw_os_error(socket_error)); + } + } + + Ok(unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd.into_raw_fd()) }) +} + +#[cfg(unix)] +fn unix_socket_addr(socket_path: &Path) -> std::io::Result<(libc::sockaddr_un, libc::socklen_t)> { + use std::os::unix::ffi::OsStrExt; + + let path_bytes = socket_path.as_os_str().as_bytes(); + if path_bytes.contains(&0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IDE context Unix socket path contains a nul byte", + )); + } + + let mut addr = unsafe { std::mem::zeroed::() }; + if path_bytes.len() >= addr.sun_path.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IDE context Unix socket path is too long", + )); + } + + addr.sun_family = libc::AF_UNIX as libc::sa_family_t; + for (slot, byte) in addr.sun_path.iter_mut().zip(path_bytes) { + *slot = *byte as libc::c_char; + } + + let addr_len = + std::mem::size_of::() - addr.sun_path.len() + path_bytes.len() + 1; + #[cfg(any( + target_os = "macos", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" + ))] + { + addr.sun_len = u8::try_from(addr_len).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IDE context Unix socket address is too long", + ) + })?; + } + + let addr_len = libc::socklen_t::try_from(addr_len).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IDE context Unix socket address is too long", + ) + })?; + Ok((addr, addr_len)) +} + +#[cfg(unix)] +fn set_fd_close_on_exec(fd: libc::c_int) -> std::io::Result<()> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + return Err(std::io::Error::last_os_error()); + } + let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) }; + if result < 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) +} + +#[cfg(unix)] +fn set_fd_nonblocking(fd: libc::c_int) -> std::io::Result<()> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 { + return Err(std::io::Error::last_os_error()); + } + let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }; + if result < 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) +} + +#[cfg(unix)] +fn is_in_progress_connect_error(error: &std::io::Error) -> bool { + matches!( + error.raw_os_error(), + Some(code) + if code == libc::EINPROGRESS + || code == libc::EALREADY + || code == libc::EWOULDBLOCK + || code == libc::EINTR + ) +} + +#[cfg(unix)] +fn socket_error(fd: libc::c_int) -> std::io::Result { + let mut socket_error = 0; + let mut socket_error_len = libc::socklen_t::try_from(std::mem::size_of::()) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid socket error length", + ) + })?; + let result = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_ERROR, + &mut socket_error as *mut _ as *mut libc::c_void, + &mut socket_error_len, + ) + }; + if result != 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(socket_error) +} + +#[cfg(unix)] +fn remaining_timeout(deadline: Instant) -> std::io::Result { + deadline + .checked_duration_since(Instant::now()) + .filter(|duration| !duration.is_zero()) + .ok_or_else(deadline_timeout_io_error) +} + +#[cfg(unix)] +fn remaining_timeout_ms(deadline: Instant) -> std::io::Result { + let millis = remaining_timeout(deadline)?.as_millis().max(1); + Ok(libc::c_int::try_from(millis).unwrap_or(libc::c_int::MAX)) +} + +#[cfg(unix)] +fn wait_for_fd_ready( + fd: libc::c_int, + events: libc::c_short, + deadline: Instant, +) -> std::io::Result<()> { + loop { + // Keep deadline handling in user space. Some macOS Unix socket environments reject + // SO_RCVTIMEO/SO_SNDTIMEO, but poll works consistently for our request-scoped timeout. + let mut poll_fd = libc::pollfd { + fd, + events, + revents: 0, + }; + let result = unsafe { libc::poll(&mut poll_fd, 1, remaining_timeout_ms(deadline)?) }; + if result == 0 { + return Err(deadline_timeout_io_error()); + } + if result < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; } + return Err(error); + } + if poll_fd.revents & libc::POLLNVAL != 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid IDE context Unix socket", + )); + } + if poll_fd.revents & (events | libc::POLLERR | libc::POLLHUP) != 0 { + return Ok(()); } } } From 62bdcad487f5a97873e9bfbbc0b96d4c81f03031 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 02:05:15 -0700 Subject: [PATCH 31/35] codex: fix Bazel clippy on PR #20294 --- codex-rs/tui/src/ide_context/ipc.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 70a0a3176eac..7d057cc893b2 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -921,7 +921,7 @@ mod tests { request_id: &str, active_selection_content: &str, ) { - write_frame( + if let Err(err) = write_frame( stream, &json!({ "type": "response", @@ -947,8 +947,9 @@ mod tests { } } }), - ) - .expect("write ide-context response"); + ) { + panic!("write ide-context response failed: {err}"); + } } #[cfg(unix)] From ae7f369c3cefd22205d206af42b331de63bb9163 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 02:19:14 -0700 Subject: [PATCH 32/35] codex: address PR review feedback (#20294) --- codex-rs/tui/src/ide_context/windows_pipe.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/ide_context/windows_pipe.rs b/codex-rs/tui/src/ide_context/windows_pipe.rs index 95d2abf0f611..f60afeb85de4 100644 --- a/codex-rs/tui/src/ide_context/windows_pipe.rs +++ b/codex-rs/tui/src/ide_context/windows_pipe.rs @@ -243,9 +243,20 @@ struct TokenUserBuffer { } impl TokenUserBuffer { - fn sid(&self) -> windows_sys::Win32::Foundation::PSID { - let token_user = unsafe { &*(self.buffer.as_ptr() as *const TOKEN_USER) }; - token_user.User.Sid + fn sid(&self) -> io::Result { + if self.buffer.len() < std::mem::size_of::() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "token user buffer is too small", + )); + } + + // GetTokenInformation writes TOKEN_USER into a byte buffer. Vec has + // no TOKEN_USER alignment guarantee, so copy the fixed header out with + // an unaligned read before using its SID pointer. + let token_user = + unsafe { std::ptr::read_unaligned(self.buffer.as_ptr() as *const TOKEN_USER) }; + Ok(token_user.User.Sid) } } @@ -267,7 +278,7 @@ fn validate_pipe_server_owner(pipe_handle: HANDLE) -> io::Result<()> { let server_user = token_user(server_token.raw())?; let current_user = token_user(current_token.raw())?; - if unsafe { EqualSid(server_user.sid(), current_user.sid()) } == 0 { + if unsafe { EqualSid(server_user.sid()?, current_user.sid()?) } == 0 { return Err(io::Error::new( io::ErrorKind::PermissionDenied, "IDE context provider is not owned by the current user", From 2ea29ebe8b8bcaa1f6b21dcc9ada1530cf4412cc Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 02:34:09 -0700 Subject: [PATCH 33/35] codex: address PR review feedback (#20294) --- codex-rs/tui/src/chatwidget.rs | 19 ++++++++++++++----- .../chatwidget/tests/composer_submission.rs | 1 + .../tui/src/chatwidget/tests/review_mode.rs | 11 +++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c45a6f5ff4ac..2b7a27ec88fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1214,6 +1214,7 @@ pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, pending_steer_history_records: VecDeque, + pending_steer_compare_keys: VecDeque, rejected_steers_queue: VecDeque, rejected_steer_history_records: VecDeque, queued_user_messages: VecDeque, @@ -3628,6 +3629,11 @@ impl ChatWidget { .iter() .map(|pending| pending.history_record.clone()) .collect(), + pending_steer_compare_keys: self + .pending_steers + .iter() + .map(|pending| pending.compare_key.clone()) + .collect(), rejected_steers_queue: self.rejected_steers_queue.clone(), rejected_steer_history_records: self.rejected_steer_history_records.clone(), queued_user_messages: self.queued_user_messages.clone(), @@ -3681,16 +3687,19 @@ impl ChatWidget { input_state.pending_steers.len(), UserMessageHistoryRecord::UserMessageText, ); + let mut pending_steer_compare_keys = input_state.pending_steer_compare_keys; self.pending_steers = input_state .pending_steers .into_iter() .zip(pending_steer_history_records) .map(|(user_message, history_record)| PendingSteer { - compare_key: PendingSteerCompareKey { - message: user_message.text.clone(), - image_count: user_message.local_images.len() - + user_message.remote_image_urls.len(), - }, + compare_key: pending_steer_compare_keys.pop_front().unwrap_or_else(|| { + PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + } + }), history_record, user_message, }) diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index e36f86d73a1b..e9220a3da639 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -921,6 +921,7 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { composer: None, pending_steers: VecDeque::new(), pending_steer_history_records: VecDeque::new(), + pending_steer_compare_keys: VecDeque::new(), rejected_steers_queue: VecDeque::new(), rejected_steer_history_records: VecDeque::new(), queued_user_messages: VecDeque::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 720b17c1859b..983d40f154d8 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -433,6 +433,12 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut pending_steers = VecDeque::new(); pending_steers.push_back(UserMessage::from("pending steer")); + let expected_compare_key = PendingSteerCompareKey { + message: "hidden IDE context\npending steer".to_string(), + image_count: 0, + }; + let mut pending_steer_compare_keys = VecDeque::new(); + pending_steer_compare_keys.push_back(expected_compare_key.clone()); let mut rejected_steers_queue = VecDeque::new(); rejected_steers_queue.push_back(UserMessage::from("already rejected")); let mut queued_user_messages = VecDeque::new(); @@ -442,6 +448,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ composer: None, pending_steers, pending_steer_history_records: VecDeque::new(), + pending_steer_compare_keys, rejected_steers_queue, rejected_steer_history_records: VecDeque::new(), queued_user_messages, @@ -462,6 +469,10 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ chat.pending_steers.front().unwrap().user_message.text, "pending steer" ); + assert_eq!( + chat.pending_steers.front().unwrap().compare_key, + expected_compare_key + ); } #[tokio::test] From 69f88a1e60be6fbff4a9e27e734b77421b54c7dd Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 02:52:54 -0700 Subject: [PATCH 34/35] codex: address PR review feedback (#20294) --- codex-rs/tui/src/ide_context/prompt.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/ide_context/prompt.rs b/codex-rs/tui/src/ide_context/prompt.rs index 4a8e2b23799d..52079fdb4170 100644 --- a/codex-rs/tui/src/ide_context/prompt.rs +++ b/codex-rs/tui/src/ide_context/prompt.rs @@ -28,7 +28,15 @@ pub(crate) fn apply_ide_context_to_user_input( .iter() .position(|item| matches!(item, UserInput::Text { .. })) { - let item = items.remove(text_index); + // Prefix the existing text item in place so image and text items keep + // the same relative order they had in the user's original submission. + let item = std::mem::replace( + &mut items[text_index], + UserInput::Text { + text: String::new(), + text_elements: Vec::new(), + }, + ); let UserInput::Text { text, text_elements, @@ -36,7 +44,7 @@ pub(crate) fn apply_ide_context_to_user_input( else { unreachable!("position matched a text item"); }; - items.insert(0, prefixed_text_input(prefix, text, text_elements)); + items[text_index] = prefixed_text_input(prefix, text, text_elements); } else { items.insert( 0, @@ -271,6 +279,9 @@ mod tests { assert_eq!( items, vec![ + UserInput::LocalImage { + path: PathBuf::from("/tmp/screenshot.png"), + }, UserInput::Text { text: format!("{expected_prefix}Ask $figma"), text_elements: vec![TextElement::new( @@ -278,9 +289,6 @@ mod tests { Some("$figma".to_string()), )], }, - UserInput::LocalImage { - path: PathBuf::from("/tmp/screenshot.png"), - }, ] ); } From ef922b49029041f44d932adf69a880bc428bf775 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 30 Apr 2026 10:30:55 -0700 Subject: [PATCH 35/35] Simplify TUI IDE context handling --- .../chatwidget/tests/composer_submission.rs | 75 --------- codex-rs/tui/src/ide_context.rs | 32 ++-- codex-rs/tui/src/ide_context/ipc.rs | 145 ++++-------------- 3 files changed, 43 insertions(+), 209 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index e9220a3da639..d17c5e5782d8 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1198,30 +1198,6 @@ fn rendered_user_message_event_from_event_hides_prompt_context() { ); } -#[tokio::test] -async fn committed_user_message_hides_prompt_context_in_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let raw_message = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\nAsk Codex"; - - complete_user_message(&mut chat, "user-1", raw_message); - - let mut user_message = None; - let mut rendered_cell = None; - while let Ok(event) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event - && let Some(cell) = cell.as_any().downcast_ref::() - { - user_message = Some(cell.message.clone()); - rendered_cell = Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))); - break; - } - } - - assert_eq!(user_message.as_deref(), Some("Ask Codex")); - let rendered_cell = rendered_cell.expect("rendered user history cell"); - assert_snapshot!(rendered_cell.trim(), @"› Ask Codex"); -} - #[tokio::test] async fn committed_user_message_with_hidden_prompt_context_renders_local_images() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1258,57 +1234,6 @@ async fn committed_user_message_with_hidden_prompt_context_renders_local_images( assert_eq!(local_images, vec![local_image]); } -#[tokio::test] -async fn submitted_user_message_uses_same_prompt_request_rendering_for_dedupe() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let configured = codex_protocol::protocol::SessionConfiguredEvent { - session_id: ThreadId::new(), - forked_from_id: None, - thread_name: None, - model: "test-model".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: ApprovalsReviewer::User, - permission_profile: PermissionProfile::read_only(), - active_permission_profile: None, - cwd: test_path_buf("/home/user/project").abs(), - reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: None, - }; - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); - drain_insert_history(&mut rx); - let raw_message = "Before\n## My request for Codex:\nAfter"; - - chat.bottom_pane - .set_composer_text(raw_message.to_string(), Vec::new(), Vec::new()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match next_submit_op(&mut op_rx) { - Op::UserTurn { .. } => {} - other => panic!("expected Op::UserTurn, got {other:?}"), - } - - complete_user_message(&mut chat, "user-1", raw_message); - - let mut user_messages = Vec::new(); - while let Ok(event) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event - && let Some(cell) = cell.as_any().downcast_ref::() - { - user_messages.push(cell.message.clone()); - } - } - - assert_eq!(user_messages, vec!["After".to_string()]); -} - #[tokio::test] async fn interrupt_restores_queued_messages_into_composer() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/ide_context.rs b/codex-rs/tui/src/ide_context.rs index bfea02ef5013..9701b5ad85d6 100644 --- a/codex-rs/tui/src/ide_context.rs +++ b/codex-rs/tui/src/ide_context.rs @@ -15,40 +15,40 @@ use serde::Deserialize; #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct IdeContext { - pub(crate) active_file: Option, + active_file: Option, #[serde(default)] - pub(crate) open_tabs: Vec, + open_tabs: Vec, } #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub(crate) struct ActiveFile { +struct ActiveFile { #[serde(flatten)] - pub(crate) descriptor: FileDescriptor, - pub(crate) selection: Range, + descriptor: FileDescriptor, + selection: Range, #[serde(default)] - pub(crate) active_selection_content: String, + active_selection_content: String, #[serde(default)] - pub(crate) selections: Vec, + selections: Vec, } #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub(crate) struct FileDescriptor { - pub(crate) label: String, - pub(crate) path: String, +struct FileDescriptor { + label: String, + path: String, } #[derive(Debug, Clone, Deserialize, PartialEq)] -pub(crate) struct Range { - pub(crate) start: Position, - pub(crate) end: Position, +struct Range { + start: Position, + end: Position, } #[derive(Debug, Clone, Deserialize, PartialEq)] -pub(crate) struct Position { - pub(crate) line: u32, - pub(crate) character: u32, +struct Position { + line: u32, + character: u32, } #[cfg(test)] diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs index 7d057cc893b2..57942d931037 100644 --- a/codex-rs/tui/src/ide_context/ipc.rs +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -18,11 +18,16 @@ use super::IdeContext; // deadline can incorrectly skip context even though the IDE answers normally. const IDE_CONTEXT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(any(unix, windows))] -const IPC_READ_CHUNK_BYTES: usize = 64 * 1024; -#[cfg(any(unix, windows))] const MAX_IPC_FRAME_BYTES: usize = 256 * 1024 * 1024; #[cfg(any(unix, windows))] const TUI_SOURCE_CLIENT_ID: &str = "codex-tui"; +#[cfg(any(unix, windows))] +const OPEN_IDE_HINT: &str = + "Open this project in VS Code or Cursor with the Codex extension active."; +#[cfg(any(unix, windows))] +const IDE_DID_NOT_PROVIDE_CONTEXT_HINT: &str = "The IDE extension did not provide context."; +#[cfg(any(unix, windows))] +const KEEP_TRYING_HINT: &str = "Codex will keep trying on future messages."; #[derive(Debug, Error)] pub(crate) enum IdeContextError { @@ -53,16 +58,12 @@ impl IdeContextError { #[cfg(any(unix, windows))] pub(crate) fn user_facing_hint(&self) -> String { match self { - IdeContextError::Connect(_) => { - "Open this project in VS Code or Cursor with the Codex extension active." - .to_string() - } + IdeContextError::Connect(_) => OPEN_IDE_HINT.to_string(), IdeContextError::RequestFailed(error) if error == "no-client-found" => { - "Open this project in VS Code or Cursor with the Codex extension active." - .to_string() + OPEN_IDE_HINT.to_string() } IdeContextError::RequestFailed(_) => { - "The IDE extension did not provide context. Try /ide again.".to_string() + format!("{IDE_DID_NOT_PROVIDE_CONTEXT_HINT} Try /ide again.") } IdeContextError::ResponseTooLarge => { "The selected IDE context is too large. Clear any large selection in your IDE and try /ide again.".to_string() @@ -83,25 +84,19 @@ impl IdeContextError { "The selected IDE context is too large. Clear any large selection in your IDE." .to_string() } - IdeContextError::Connect(_) => { - "Open this project in VS Code or Cursor with the Codex extension active." - .to_string() - } + IdeContextError::Connect(_) => OPEN_IDE_HINT.to_string(), IdeContextError::RequestFailed(error) if error == "no-client-found" => { - "Open this project in VS Code or Cursor with the Codex extension active." - .to_string() + OPEN_IDE_HINT.to_string() } IdeContextError::Read(error) if error.kind() == std::io::ErrorKind::TimedOut => { "Codex timed out waiting for IDE context. It will keep trying on future messages." .to_string() } IdeContextError::RequestFailed(error) if error == "client-disconnected" => { - "The IDE connection changed while Codex was requesting context. Codex will keep trying on future messages." - .to_string() + hint_with_retry("The IDE connection changed while Codex was requesting context.") } IdeContextError::RequestFailed(error) if error == "request-timeout" => { - "The IDE extension did not answer in time. Codex will keep trying on future messages." - .to_string() + hint_with_retry("The IDE extension did not answer in time.") } IdeContextError::RequestFailed(error) if error == "request-version-mismatch" => { "The connected IDE extension is not compatible with this IDE context request." @@ -111,21 +106,13 @@ impl IdeContextError { "The connected IDE client does not support IDE context requests.".to_string() } IdeContextError::Send(_) => { - "Codex lost the IDE connection while requesting context. Codex will keep trying on future messages." - .to_string() + hint_with_retry("Codex lost the IDE connection while requesting context.") } IdeContextError::InvalidResponse(_) => { - "Codex received an unexpected IDE context response. Codex will keep trying on future messages." - .to_string() - } - IdeContextError::RequestFailed(_) => { - "The IDE extension did not provide context. Codex will keep trying on future messages." - .to_string() - } - IdeContextError::Read(_) => { - "Codex could not read IDE context. Codex will keep trying on future messages." - .to_string() + hint_with_retry("Codex received an unexpected IDE context response.") } + IdeContextError::RequestFailed(_) => hint_with_retry(IDE_DID_NOT_PROVIDE_CONTEXT_HINT), + IdeContextError::Read(_) => hint_with_retry("Codex could not read IDE context."), } } @@ -140,95 +127,17 @@ impl IdeContextError { } } +#[cfg(any(unix, windows))] +fn hint_with_retry(message: &str) -> String { + format!("{message} {KEEP_TRYING_HINT}") +} + #[cfg(unix)] type IdeContextStream = UnixDeadlineStream; #[cfg(windows)] type IdeContextStream = super::windows_pipe::WindowsPipeStream; -#[cfg(any(unix, windows))] -#[derive(Default)] -struct IpcFrameBuffer { - buffer: Vec, - expected_len: Option, -} - -#[cfg(any(unix, windows))] -enum IpcFrame { - Message(Value), - IgnoredBroadcast, -} - -#[cfg(any(unix, windows))] -impl IpcFrameBuffer { - fn read_next_message( - &mut self, - stream: &mut IdeContextStream, - deadline: Instant, - ) -> Result { - loop { - while let Some(frame) = self.pop_complete_frame()? { - match frame { - IpcFrame::Message(message) => return Ok(message), - IpcFrame::IgnoredBroadcast => {} - } - } - - ensure_deadline_not_expired(deadline)?; - stream.set_deadline(deadline); - let mut chunk = [0_u8; IPC_READ_CHUNK_BYTES]; - match std::io::Read::read(stream, &mut chunk) { - Ok(0) => { - return Err(IdeContextError::Read(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "IDE context IPC stream closed", - ))); - } - Ok(bytes_read) => self.push_bytes(&chunk[..bytes_read]), - Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} - Err(error) => return Err(IdeContextError::Read(error)), - } - } - } - - fn push_bytes(&mut self, bytes: &[u8]) { - self.buffer.extend_from_slice(bytes); - } - - fn pop_complete_frame(&mut self) -> Result, IdeContextError> { - if self.expected_len.is_none() && self.buffer.len() >= 4 { - self.read_frame_len(); - } - - let Some(expected_len) = self.expected_len else { - return Ok(None); - }; - if expected_len > MAX_IPC_FRAME_BYTES { - return Err(IdeContextError::ResponseTooLarge); - } - if self.buffer.len() < expected_len { - return Ok(None); - } - - let payload = self.buffer.drain(..expected_len).collect::>(); - self.expected_len = None; - let message = serde_json::from_slice::(&payload).map_err(|err| { - IdeContextError::InvalidResponse(format!("invalid JSON payload: {err}")) - })?; - if message.get("type").and_then(Value::as_str) == Some("broadcast") { - return Ok(Some(IpcFrame::IgnoredBroadcast)); - } - Ok(Some(IpcFrame::Message(message))) - } - - fn read_frame_len(&mut self) { - let mut len_bytes = [0_u8; 4]; - len_bytes.copy_from_slice(&self.buffer[..4]); - self.buffer.drain(..4); - self.expected_len = Some(u32::from_le_bytes(len_bytes) as usize); - } -} - #[cfg(any(unix, windows))] pub(crate) fn fetch_ide_context(workspace_root: &Path) -> Result { fetch_ide_context_from_socket( @@ -749,7 +658,7 @@ fn write_frame(stream: &mut T, message: &Value) -> s stream.flush() } -#[cfg(all(test, unix))] +#[cfg(any(unix, windows))] fn read_frame( stream: &mut T, deadline: Instant, @@ -767,7 +676,7 @@ fn read_frame( .map_err(|err| IdeContextError::InvalidResponse(format!("invalid JSON payload: {err}"))) } -#[cfg(all(test, unix))] +#[cfg(any(unix, windows))] fn read_exact_before_deadline( stream: &mut T, buf: &mut [u8], @@ -802,10 +711,10 @@ fn read_response_frame( request_id: &str, deadline: Instant, ) -> Result { - let mut frame_buffer = IpcFrameBuffer::default(); loop { ensure_deadline_not_expired(deadline)?; - let message = frame_buffer.read_next_message(stream, deadline)?; + stream.set_deadline(deadline); + let message = read_frame(stream, deadline)?; match message.get("type").and_then(Value::as_str) { Some("response") => { if message.get("requestId").and_then(Value::as_str) == Some(request_id) {