bug(pty): native assertion failure in conpty.node during teardown leading to trial enforcement bypass

Resolved 💬 1 comment Opened Mar 14, 2026 by RuslanSemchenko Closed Mar 24, 2026

What version of the Codex App are you using (From “About Codex” dialog)?

26.309.3504.0_x64

What subscription do you have?

Trial (Expired)

What platform is your computer?

Microsoft Windows NT 10.0.26100.0 x64

What issue are you seeing?

he application encounters a native assertion failure (C++ __debugbreak()) in the conpty.node module during session termination or trial expiration.

Specifically, the remove_pty_baton assertion is triggered in the native layer. This is caused by a race condition in the portable-pty wrapper where the SlavePty outlives the MasterPty, leading to non-deterministic destruction of PTY handles.

In a production environment, this results in a "Debug Error!" dialog. If a user clicks "Ignore", the application state-machine is interrupted, and the UI fails to transition to the "Trial Expired" lockout screen, allowing continued unauthorized usage of the tool.

What steps can reproduce the bug?

Use the Codex App on Windows with an active or nearly expired trial.

Trigger a PTY-intensive task (e.g., long code generation or terminal activity).

Allow the trial to expire or force a session teardown.

Observe the conpty.node assertion failure: Assertion failed: remove_pty_baton.

Click "Ignore" on the Windows CRT dialog.

What is the expected behavior?

The application should handle PTY destruction deterministically. Native modules should be compiled with NDEBUG to prevent __debugbreak() dialogs in release builds. Upon trial expiration, the application should gracefully close all PTY sessions and lock the UI without crashing or allowing bypass.

Additional information

The Rust implementation in codex-rs/utils/pty/src/win/conpty.rs uses Arc<Mutex<Inner>> to manage the PTY lifecycle. Because both MasterPty and SlavePty hold strong references, the Inner struct (and the underlying PsuedoCon) is dropped only when the last reference is gone. If the main thread initiates shutdown while a background task still holds a SlavePty reference, the native environment is torn down while the PTY baton is still active, causing the crash.

Wrapping PsuedoCon in an Option inside the Inner struct.

Implementing an explicit Drop for ConPtyMasterPty to call .take() on the connection, forcing synchronous cleanup regardless of remaining SlavePty references.

<img width="1912" height="1075" alt="Image" src="https://github.com/user-attachments/assets/74a539d0-4ae1-4e70-98a7-d7f204fb124d" />

<img width="1920" height="1080" alt="Image" src="https://github.com/user-attachments/assets/c04010a8-5e9f-405a-95d3-3eab3f54a6b5" />
Also i ahve dump file of this problem

I reported it to security, and they told me it wasn't a vulnerability, even though it's essentially a bug in the code: when the trial ends, I can still generate the code completely for free. So I don't know how to respond—either they're missing the point, or there's something else going on. I definitely don't understand the logic, of course, which is why I'm writing to you here. I reported it on Bugcrowd before, and they dismissed it too, even though the asset in the release build wasn't supposed to appear at all

#![allow(clippy::unwrap_used)]

// This file is copied from https://github.com/wezterm/wezterm (MIT license).
// Copyright (c) 2018-Present Wez Furlong
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

use crate::win::psuedocon::PsuedoCon;
use anyhow::Error;
use filedescriptor::FileDescriptor;
use filedescriptor::Pipe;
use portable_pty::cmdbuilder::CommandBuilder;
use portable_pty::Child;
use portable_pty::MasterPty;
use portable_pty::PtyPair;
use portable_pty::PtySize;
use portable_pty::PtySystem;
use portable_pty::SlavePty;
use std::sync::Arc;
use std::sync::Mutex;
use winapi::um::wincon::COORD;

#[derive(Default)]
pub struct ConPtySystem {}

impl PtySystem for ConPtySystem {
    fn openpty(&self, size: PtySize) -> anyhow::Result<PtyPair> {
        let stdin = Pipe::new()?;
        let stdout = Pipe::new()?;

        let con = PsuedoCon::new(
            COORD {
                X: size.cols as i16,
                Y: size.rows as i16,
            },
            stdin.read,
            stdout.write,
        )?;

        let master = ConPtyMasterPty {
            inner: Arc::new(Mutex::new(Inner {
                con: Some(con), // Wrap inside an Option for explicit lifecycle control
                readable: stdout.read,
                writable: Some(stdin.write),
                size,
            })),
        };

        let slave = ConPtySlavePty {
            inner: master.inner.clone(),
        };

        Ok(PtyPair {
            master: Box::new(master),
            slave: Box::new(slave),
        })
    }
}

struct Inner {
    con: Option<PsuedoCon>, // Changed to Option to prevent non-deterministic FFI drops
    readable: FileDescriptor,
    writable: Option<FileDescriptor>,
    size: PtySize,
}

impl Inner {
    pub fn resize(
        &mut self,
        num_rows: u16,
        num_cols: u16,
        pixel_width: u16,
        pixel_height: u16,
    ) -> Result<(), Error> {
        // Perform resize only if the connection is still alive
        if let Some(con) = self.con.as_mut() {
            con.resize(COORD {
                X: num_cols as i16,
                Y: num_rows as i16,
            })?;
        }
        self.size = PtySize {
            rows: num_rows,
            cols: num_cols,
            pixel_width,
            pixel_height,
        };
        Ok(())
    }
}

#[derive(Clone)]
pub struct ConPtyMasterPty {
    inner: Arc<Mutex<Inner>>,
}

pub struct ConPtySlavePty {
    inner: Arc<Mutex<Inner>>,
}

// ADDED: Explicit Drop for MasterPty to deterministically close the PTY
impl Drop for ConPtyMasterPty {
    fn drop(&mut self) {
        if let Ok(mut inner) = self.inner.lock() {
            // Forcibly extract PsuedoCon from the Arc. 
            // This triggers its Drop implementation (and remove_pty_baton in C++) IMMEDIATELY,
            // typically in the main thread, before the FFI environment starts tearing down on exit.
            let _ = inner.con.take();
            
            // Also release the write pipe
            let _ = inner.writable.take();
        }
    }
}

impl MasterPty for ConPtyMasterPty {
    fn resize(&self, size: PtySize) -> anyhow::Result<()> {
        let mut inner = self.inner.lock().unwrap();
        inner.resize(size.rows, size.cols, size.pixel_width, size.pixel_height)
    }

    fn get_size(&self) -> Result<PtySize, Error> {
        let inner = self.inner.lock().unwrap();
        Ok(inner.size)
    }

    fn try_clone_reader(&self) -> anyhow::Result<Box<dyn std::io::Read + Send>> {
        Ok(Box::new(self.inner.lock().unwrap().readable.try_clone()?))
    }

    fn take_writer(&self) -> anyhow::Result<Box<dyn std::io::Write + Send>> {
        Ok(Box::new(
            self.inner
                .lock()
                .unwrap()
                .writable
                .take()
                .ok_or_else(|| anyhow::anyhow!("writer already taken"))?,
        ))
    }
}

impl SlavePty for ConPtySlavePty {
    fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result<Box<dyn Child + Send + Sync>> {
        let inner = self.inner.lock().unwrap();
        // Spawn the command only if the PTY hasn't been closed by the master process
        if let Some(con) = inner.con.as_ref() {
            let child = con.spawn_command(cmd)?;
            Ok(Box::new(child))
        } else {
            Err(anyhow::anyhow!("ConPTY has already been explicitly closed by the master process"))
        }
    }
}

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗