Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ jobs:

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

# GPUI's X11 and Wayland backends link these; the tray icon and the keyring speak D-Bus in pure Rust.
- name: Install Linux build dependencies
Expand All @@ -87,6 +89,20 @@ jobs:
- name: Cache Cargo
uses: Swatinem/rust-cache@v2

- name: Observe original and candidate Windows console seams
if: runner.os == 'Windows'
env:
CONSOLE_CANDIDATE_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
run: python scripts/qualify-provider-console.py

- name: Upload Windows console qualification
if: always() && runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: provider-console
path: artifacts/provider-console/
retention-days: 14

- name: Format Check
run: cargo fmt --all --check

Expand Down
2 changes: 1 addition & 1 deletion crates/codexbar-app/src/widgets/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ fn open_codexbar(target: Option<&host::Target>) {
command.arg(crate::handoff::METRIC_ARG).arg(metric);
}
}
let _ = command.spawn();
let _ = codexbar_providers::platform::spawn(&mut command);
}

/// One provider object per client request; the server lives as long as any is held (out-of-process COM's rule).
Expand Down
2 changes: 1 addition & 1 deletion crates/codexbar-providers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ unicode-normalization = "0.1.25"
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows.workspace = true
windows = { workspace = true, features = ["Win32_System_Console", "Win32_System_StationsAndDesktops", "Win32_System_SystemInformation"] }

[lints]
workspace = true
Expand Down
43 changes: 28 additions & 15 deletions crates/codexbar-providers/src/command.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#[cfg(not(windows))]
use std::process::Stdio;
use std::time::{Duration, Instant};

Expand All @@ -23,29 +24,28 @@ pub trait CommandRunner: Send + Sync {
fn run(&self, program: &str, args: &[&str], timeout: Duration) -> Result<CommandOutput, CommandError>;
}

/// Runs commands with no console window, bounded output and a deadline covering collection and process exit.
/// Runs background commands with no visible console, bounded output and a deadline covering collection and exit.
/// Each stream is limited to 8 MiB. Oversized output fails without returning or logging a partial payload.
#[derive(Default)]
pub struct SystemCommandRunner;

#[cfg(windows)]
mod background;
mod pipe;
#[cfg(any(windows, test))]
mod windows_plan;
#[cfg(windows)]
use background::{Child, Contained, spawn};
#[cfg(not(windows))]
type Child = std::process::Child;
#[cfg(not(windows))]
type Contained = crate::contained::Contained;
const OUTPUT_LIMIT: usize = 8 * 1024 * 1024;
const CHECK_INTERVAL: Duration = Duration::from_millis(25);

impl CommandRunner for SystemCommandRunner {
fn run(&self, program: &str, args: &[&str], timeout: Duration) -> Result<CommandOutput, CommandError> {
let mut command = crate::platform::command(program);
command
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
command.process_group(0);
}
let inner = crate::contained::spawn(&mut command).map_err(spawn_error)?;
let inner = spawn(program, args).map_err(spawn_error)?;
let mut process = Running { inner, stopped: false };
let output = collect(&mut process.inner.child, timeout);
let status = process.stop().map_err(|_| CommandError::Failed)?;
Expand All @@ -58,6 +58,19 @@ impl CommandRunner for SystemCommandRunner {
}
}

#[cfg(not(windows))]
fn spawn(program: &str, args: &[&str]) -> std::io::Result<Contained> {
use std::os::unix::process::CommandExt as _;
let mut command = crate::platform::command(program);
command
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command.process_group(0);
crate::contained::spawn(&mut command)
}

fn spawn_error(error: std::io::Error) -> CommandError {
if error.kind() == std::io::ErrorKind::NotFound {
CommandError::NotFound
Expand All @@ -68,7 +81,7 @@ fn spawn_error(error: std::io::Error) -> CommandError {

/// The root remains unreaped until group cleanup, so its process-group ID cannot refer to somebody else's work.
struct Running {
inner: crate::contained::Contained,
inner: Contained,
stopped: bool,
}
impl Running {
Expand Down Expand Up @@ -129,7 +142,7 @@ impl<P: pipe::Pipe> Capture<P> {
}
}

fn collect(child: &mut std::process::Child, timeout: Duration) -> Result<(Vec<u8>, Vec<u8>), CommandError> {
fn collect(child: &mut Child, timeout: Duration) -> Result<(Vec<u8>, Vec<u8>), CommandError> {
let start = Instant::now();
let mut stdout = Capture::new(child.stdout.take().ok_or(CommandError::Failed)?)?;
let mut stderr = Capture::new(child.stderr.take().ok_or(CommandError::Failed)?)?;
Expand Down
154 changes: 154 additions & 0 deletions crates/codexbar-providers/src/command/background.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//! Windows usage queries get a console hidden at creation, which ordinary descendants inherit.
//! This is not a sandbox against a process explicitly requesting another console or showing UI.
use std::fs::File;
use std::io;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
use std::os::windows::process::ExitStatusExt as _;
use std::process::ExitStatus;

use windows::Win32::Foundation::{HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT};
use windows::Win32::System::Threading::{
CREATE_NEW_CONSOLE, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, EXTENDED_STARTUPINFO_PRESENT,
GetExitCodeProcess, PROCESS_INFORMATION, ResumeThread, TerminateProcess, WaitForSingleObject,
};
use windows::core::{PCWSTR, PWSTR};

mod arguments;
mod stdio;

#[cfg(test)]
mod tests;

pub(super) struct Child {
process: OwnedHandle,
pub stdout: Option<File>,
pub stderr: Option<File>,
}

impl AsRawHandle for Child {
fn as_raw_handle(&self) -> RawHandle {
self.process.as_raw_handle()
}
}

impl Child {
pub(super) fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
// SAFETY: a retained owned process handle; no blocking wait or PID lookup.
match unsafe { WaitForSingleObject(HANDLE(self.as_raw_handle()), 0) } {
WAIT_OBJECT_0 => self.wait().map(Some),
WAIT_TIMEOUT => Ok(None),
_ => Err(io::Error::last_os_error()),
}
}

pub(super) fn wait(&mut self) -> io::Result<ExitStatus> {
self.wait_for(1000)
}

fn wait_for(&mut self, milliseconds: u32) -> io::Result<ExitStatus> {
let mut code = 0;
// SAFETY: this process was created by this launcher and its handle stays owned during both queries.
unsafe {
if WaitForSingleObject(HANDLE(self.as_raw_handle()), milliseconds) != WAIT_OBJECT_0 {
return Err(io::Error::other("background process exit could not be confirmed"));
}
GetExitCodeProcess(HANDLE(self.as_raw_handle()), &mut code).map_err(io::Error::other)?;
}
Ok(ExitStatus::from_raw(code))
}

fn kill(&self) {
// SAFETY: terminate only our retained process. A failed kill of an exited process is harmless.
let _ = unsafe { TerminateProcess(HANDLE(self.as_raw_handle()), 1) };
}
}

pub(super) struct Contained {
pub child: Child,
job: crate::contained::job::Job,
}

impl Contained {
pub(super) fn stop(&mut self) -> io::Result<ExitStatus> {
let cleanup = self.job.terminate();
self.child.kill();
// The job drain already spent the one-second cleanup budget and verified retained members.
// Never extend it with an unbounded root wait after a failed drain or kill.
let status = self.child.wait_for(0);
cleanup?;
status
}
}

pub(super) fn spawn(program: &str, args: &[&str]) -> io::Result<Contained> {
let mut plan = arguments::command_line(program, args)?;
let mut streams = stdio::Prepared::new()?;
let suspended = Suspended::create(&mut plan, &mut streams)?;
let (stdout, stderr) = streams.capture();
suspended.start(stdout, stderr)
}

/// Own the exact process and primary thread from suspended creation until job assignment and resume commit.
/// Dropping an uncommitted launch always terminates/reaps that same retained process.
struct Suspended {
child: Option<Child>,
thread: OwnedHandle,
}
impl Suspended {
fn create(plan: &mut super::windows_plan::LaunchPlan, streams: &mut stdio::Prepared) -> io::Result<Self> {
let startup = streams.startup();
let mut info = PROCESS_INFORMATION::default();
// SAFETY: owned NUL-terminated application and writable command line, initialized live startup attributes,
// and writable output. Preserve the caller's token, environment, working directory and desktop.
unsafe {
CreateProcessW(
PCWSTR(plan.application.as_ptr()),
Some(PWSTR(plan.line.as_mut_ptr())),
None,
None,
true,
CREATE_NEW_CONSOLE | CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT,
None,
PCWSTR::null(),
&startup.StartupInfo,
&mut info,
)
}
.map_err(|error| io::Error::from_raw_os_error(error.code().0 & 0xffff))?;
// SAFETY: successful CreateProcessW transfers unique noninheritable process and primary-thread handles.
let process = unsafe { OwnedHandle::from_raw_handle(info.hProcess.0) };
let thread = unsafe { OwnedHandle::from_raw_handle(info.hThread.0) };
Ok(Self {
child: Some(Child {
process,
stdout: None,
stderr: None,
}),
thread,
})
}

fn start(mut self, stdout: File, stderr: File) -> io::Result<Contained> {
let child = self.child.as_mut().unwrap();
child.stdout = Some(stdout);
child.stderr = Some(stderr);
let job = crate::contained::job::Job::kill_on_close(child)
.ok_or_else(|| io::Error::other("the background process couldn't enter its job"))?;
// SAFETY: retain this suspended primary thread; no provider code ran before job membership.
if unsafe { ResumeThread(HANDLE(self.thread.as_raw_handle())) } == u32::MAX {
return Err(io::Error::other("the background process couldn't be resumed"));
}
Ok(Contained {
child: self.child.take().unwrap(),
job,
})
}
}
impl Drop for Suspended {
fn drop(&mut self) {
if let Some(child) = self.child.as_mut() {
child.kill();
let _ = child.wait(); // bounded confirmation, including job-assignment/resume failures.
}
}
}
52 changes: 52 additions & 0 deletions crates/codexbar-providers/src/command/background/arguments.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//! Native environment discovery for the portable Windows launch planner.
use super::super::windows_plan::{self, LaunchPlan};
use std::ffi::OsString;
use std::io;
use std::os::windows::ffi::OsStringExt as _;
use std::path::{Path, PathBuf};
use windows::Win32::System::SystemInformation::{GetSystemDirectoryW, GetWindowsDirectoryW};

pub(super) fn command_line(program: &str, args: &[&str]) -> io::Result<LaunchPlan> {
let resolved = windows_plan::resolve(program, &executable_dirs(), Path::exists)?;
LaunchPlan::new(resolved, args, system_interpreter)
}

fn system_interpreter() -> io::Result<String> {
let mut system = [0u16; 32768];
// SAFETY: writable UTF-16 buffer. Use the system interpreter, never a PATH or COMSPEC replacement.
let size = unsafe { GetSystemDirectoryW(Some(&mut system)) } as usize;
if size == 0 || size >= system.len() {
return Err(io::Error::last_os_error());
}
Ok(String::from_utf16(&system[..size]).map_err(io::Error::other)? + "\\cmd.exe")
}

fn executable_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<_> = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_owned))
.into_iter()
.collect();
dirs.extend(system_dirs());
if let Some(path) = std::env::var_os("PATH") {
dirs.extend(std::env::split_paths(&path).filter(|path| !path.as_os_str().is_empty()));
}
dirs
}

fn system_dirs() -> Vec<PathBuf> {
let queries: [fn(&mut [u16]) -> u32; 2] = [
|buffer| unsafe { GetSystemDirectoryW(Some(buffer)) },
|buffer| unsafe { GetWindowsDirectoryW(Some(buffer)) },
];
let mut dirs = Vec::new();
for query in queries {
let mut buffer = [0u16; 32768];
// Each query writes only into this supplied buffer and returns the UTF-16 length.
let size = query(&mut buffer) as usize;
if size > 0 && size < buffer.len() {
dirs.push(PathBuf::from(OsString::from_wide(&buffer[..size])));
}
}
dirs
}
Loading
Loading