From 7722081826c834b3f767965f7b9188b2f622964d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Tue, 14 Apr 2026 16:16:37 +0200 Subject: [PATCH 01/24] PTY support on Windows With ConPTY. --- R/initialize.R | 3 - R/run.R | 27 ++- man/run.Rd | 15 +- src/win/processx-win.h | 1 + src/win/processx.c | 236 ++++++++++++++++++++++- tests/testthat/_snaps/Windows/process.md | 4 +- tests/testthat/_snaps/Windows/run.md | 2 +- tests/testthat/test-pty.R | 37 +++- tests/testthat/test-run.R | 23 +++ 9 files changed, 314 insertions(+), 34 deletions(-) diff --git a/R/initialize.R b/R/initialize.R index 3edf7bec..98dec236 100644 --- a/R/initialize.R +++ b/R/initialize.R @@ -78,9 +78,6 @@ process_initialize <- function( cleanup <- TRUE } - if (pty && os_type() != "unix") { - throw(new_error("`pty = TRUE` is only implemented on Unix")) - } if (pty && tolower(Sys.info()[["sysname"]]) == "sunos") { throw(new_error("`pty = TRUE` is not (yet) implemented on Solaris")) } diff --git a/R/run.R b/R/run.R index dba0dfc1..02442701 100644 --- a/R/run.R +++ b/R/run.R @@ -132,13 +132,14 @@ #' `NULL` (no input) or a file path (whose contents are fed to the #' process via the PTY). #' @param pty Whether to use a pseudo-terminal (PTY) for the process. -#' This is only supported on Unix. When `TRUE`, stdout and stderr are -#' merged into a single stream (accessible via `$stdout` in the result), -#' and `$stderr` is always `NULL`. The process sees a real terminal, so -#' programs that disable colour or interactive features when not attached -#' to a terminal will behave as if they are. `stdout` and `stderr` must -#' be left at their defaults (`"|"`), and `stderr_to_stdout`, -#' `stderr_callback`, and `stderr_line_callback` must not be set. +#' Supported on Unix and on Windows 10 version 1809 or later (via +#' ConPTY). When `TRUE`, stdout and stderr are merged into a single +#' stream (accessible via `$stdout` in the result), and `$stderr` is +#' always `NULL`. The process sees a real terminal, so programs that +#' disable colour or interactive features when not attached to a terminal +#' will behave as if they are. `stdout` and `stderr` must be left at +#' their defaults (`"|"`), and `stderr_to_stdout`, `stderr_callback`, +#' and `stderr_line_callback` must not be set. #' @param pty_options Options for the PTY, a named list. See #' [default_pty_options()] for the available options and their defaults. #' @param ... Extra arguments are passed to `process$new()`, see @@ -566,7 +567,17 @@ run_manage <- function( stdin_remaining <- proc$write_input(stdin_remaining) } if (length(stdin_remaining) == 0L) { - proc$write_input(as.raw(c(0x04L, 0x04L))) + ## Signal EOF to the PTY. + ## Unix: two Ctrl+D (0x04) — first flushes the line buffer, + ## second triggers unconditional EOF. + ## Windows ConPTY: Ctrl+Z (0x1A) — the Windows CRT treats this + ## as EOF when reading from a console in text mode. + eof_bytes <- if (.Platform$OS.type == "windows") { + as.raw(0x1aL) + } else { + as.raw(c(0x04L, 0x04L)) + } + proc$write_input(eof_bytes) stdin_eof_sent <- TRUE } } diff --git a/man/run.Rd b/man/run.Rd index 78f8c067..c61383b3 100644 --- a/man/run.Rd +++ b/man/run.Rd @@ -141,13 +141,14 @@ strings. Line callbacks are not supported in binary mode.} the process has finished.} \item{pty}{Whether to use a pseudo-terminal (PTY) for the process. -This is only supported on Unix. When \code{TRUE}, stdout and stderr are -merged into a single stream (accessible via \verb{$stdout} in the result), -and \verb{$stderr} is always \code{NULL}. The process sees a real terminal, so -programs that disable colour or interactive features when not attached -to a terminal will behave as if they are. \code{stdout} and \code{stderr} must -be left at their defaults (\code{"|"}), and \code{stderr_to_stdout}, -\code{stderr_callback}, and \code{stderr_line_callback} must not be set.} +Supported on Unix and on Windows 10 version 1809 or later (via +ConPTY). When \code{TRUE}, stdout and stderr are merged into a single +stream (accessible via \verb{$stdout} in the result), and \verb{$stderr} is +always \code{NULL}. The process sees a real terminal, so programs that +disable colour or interactive features when not attached to a terminal +will behave as if they are. \code{stdout} and \code{stderr} must be left at +their defaults (\code{"|"}), and \code{stderr_to_stdout}, \code{stderr_callback}, +and \code{stderr_line_callback} must not be set.} \item{pty_options}{Options for the PTY, a named list. See \code{\link[=default_pty_options]{default_pty_options()}} for the available options and their defaults.} diff --git a/src/win/processx-win.h b/src/win/processx-win.h index b17c8537..f43aac3f 100644 --- a/src/win/processx-win.h +++ b/src/win/processx-win.h @@ -14,6 +14,7 @@ typedef struct processx_handle_s { processx_connection_t *pipes[3]; int cleanup; double create_time; + void *ptycon; /* ConPTY handle (HPCON), NULL if not using PTY */ } processx_handle_t; int processx__utf8_to_utf16_alloc(const char* s, WCHAR** ws_ptr); diff --git a/src/win/processx.c b/src/win/processx.c index 3e31837a..b9cced08 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -6,6 +6,43 @@ #include +/* ConPTY support (Windows 10 version 1809+). + We load the API dynamically so processx still loads on older Windows. */ + +#ifndef HPCON +typedef VOID* HPCON; +#endif + +#ifndef PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE +/* ProcThreadAttributeValue(22, FALSE, TRUE, FALSE) */ +#define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE 0x00020016 +#endif + +typedef HRESULT (WINAPI *pfn_CreatePseudoConsole)( + COORD, HANDLE, HANDLE, DWORD, HPCON *); +typedef void (WINAPI *pfn_ClosePseudoConsole)(HPCON); + +static pfn_CreatePseudoConsole processx__CreatePseudoConsole = NULL; +static pfn_ClosePseudoConsole processx__ClosePseudoConsole = NULL; +/* 0 = unchecked, 1 = available, -1 = not available */ +static int processx__pty_api_state = 0; + +static int processx__load_pty_api(void) { + if (processx__pty_api_state != 0) return processx__pty_api_state == 1; + + HMODULE hK32 = GetModuleHandleW(L"kernel32.dll"); + if (hK32) { + processx__CreatePseudoConsole = + (pfn_CreatePseudoConsole) GetProcAddress(hK32, "CreatePseudoConsole"); + processx__ClosePseudoConsole = + (pfn_ClosePseudoConsole) GetProcAddress(hK32, "ClosePseudoConsole"); + } + + processx__pty_api_state = + (processx__CreatePseudoConsole && processx__ClosePseudoConsole) ? 1 : -1; + return processx__pty_api_state == 1; +} + static HANDLE processx__global_job_handle = NULL; static void processx__init_global_job_handle(void) { @@ -862,6 +899,10 @@ SEXP processx__make_handle(SEXP private, int cleanup) { void processx__handle_destroy(processx_handle_t *handle) { if (!handle) return; if (handle->child_stdio_buffer) free(handle->child_stdio_buffer); + if (handle->ptycon && processx__ClosePseudoConsole) { + processx__ClosePseudoConsole((HPCON) handle->ptycon); + handle->ptycon = NULL; + } free(handle); } @@ -877,6 +918,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, const char *ctree_id = CHAR(STRING_ELT(tree_id, 0)); int err = 0; + int cpty = LOGICAL(pty)[0]; WCHAR *path; WCHAR *application_path = NULL, *application = NULL, *arguments = NULL, *cenv = NULL, *cwd = NULL; @@ -957,12 +999,6 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, result = PROTECT(processx__make_handle(private, ccleanup)); handle = R_ExternalPtrAddr(result); - int inherit_std = 0; - err = processx__stdio_create(handle, connections, - &handle->child_stdio_buffer, private, - cencoding, ccommand, &inherit_std); - if (err) { R_THROW_SYSTEM_ERROR_CODE(err, "setup stdio for '%s'", ccommand); } - application_path = processx__search_path(application, cwd, path); /* If a UNC Path, then we try to flip the forward slashes, if any. @@ -977,11 +1013,197 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, if (!application_path) { R_ClearExternalPtr(result); - processx__stdio_destroy(handle->child_stdio_buffer); free(handle); R_THROW_ERROR("Command '%s' not found", ccommand); } + /* ------------------------------------------------------------------ */ + /* ConPTY branch (Windows 10 1809+, pty = TRUE) */ + /* ------------------------------------------------------------------ */ + + if (cpty) { + + if (!processx__load_pty_api()) { + R_ClearExternalPtr(result); + free(handle); + R_THROW_ERROR("PTY is not supported on this version of Windows " + "(requires Windows 10 version 1809 or later)"); + } + + /* Extract PTY options (order matches default_pty_options(): echo, rows, cols) */ + int pty_rows = INTEGER(VECTOR_ELT(pty_options, 1))[0]; + int pty_cols = INTEGER(VECTOR_ELT(pty_options, 2))[0]; + + /* stdin pipe: anonymous, synchronous. + parent writes to ptyin_write; ConPTY reads from ptyin_read (hInput). */ + HANDLE ptyin_read = NULL, ptyin_write = NULL; + { + SECURITY_ATTRIBUTES sa; + sa.nLength = sizeof(sa); + sa.lpSecurityDescriptor = NULL; + sa.bInheritHandle = FALSE; + if (!CreatePipe(&ptyin_read, &ptyin_write, &sa, 0)) { + R_ClearExternalPtr(result); + free(handle); + R_THROW_SYSTEM_ERROR("create PTY stdin pipe for '%s'", ccommand); + } + } + + /* stdout pipe: named + overlapped so the parent can read asynchronously. + processx__create_pipe() gives: + parent (ptyout_read) – overlapped server end, parent reads from here; + child (ptyout_child) – sync client end, ConPTY writes here (hOutput). */ + HANDLE ptyout_read = NULL, ptyout_child = NULL; + err = processx__create_pipe(handle, &ptyout_read, &ptyout_child, ccommand); + if (err) { + CloseHandle(ptyin_read); + CloseHandle(ptyin_write); + R_ClearExternalPtr(result); + free(handle); + R_THROW_SYSTEM_ERROR_CODE(err, "create PTY stdout pipe for '%s'", ccommand); + } + + /* Create the pseudo-console */ + COORD pty_size; + pty_size.X = (SHORT) pty_cols; + pty_size.Y = (SHORT) pty_rows; + HPCON hPC; + HRESULT hr = processx__CreatePseudoConsole( + pty_size, ptyin_read, ptyout_child, 0, &hPC); + /* ConPTY made its own internal copies; close the pipe ends we passed in */ + CloseHandle(ptyin_read); + CloseHandle(ptyout_child); + if (FAILED(hr)) { + CloseHandle(ptyin_write); + CloseHandle(ptyout_read); + R_ClearExternalPtr(result); + free(handle); + R_THROW_ERROR("CreatePseudoConsole failed for '%s' (HRESULT 0x%08lx)", + ccommand, (unsigned long) hr); + } + + /* Build the process-thread attribute list containing the ConPTY handle */ + SIZE_T attrlist_size = 0; + InitializeProcThreadAttributeList(NULL, 1, 0, &attrlist_size); + LPPROC_THREAD_ATTRIBUTE_LIST lpAttrList = + (LPPROC_THREAD_ATTRIBUTE_LIST) malloc(attrlist_size); + if (!lpAttrList) { + processx__ClosePseudoConsole(hPC); + CloseHandle(ptyin_write); + CloseHandle(ptyout_read); + R_ClearExternalPtr(result); + free(handle); + R_THROW_ERROR("Out of memory when creating subprocess '%s'", ccommand); + } + if (!InitializeProcThreadAttributeList(lpAttrList, 1, 0, &attrlist_size)) { + free(lpAttrList); + processx__ClosePseudoConsole(hPC); + CloseHandle(ptyin_write); + CloseHandle(ptyout_read); + R_ClearExternalPtr(result); + free(handle); + R_THROW_SYSTEM_ERROR( + "InitializeProcThreadAttributeList for '%s'", ccommand); + } + if (!UpdateProcThreadAttribute(lpAttrList, 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + hPC, sizeof(HPCON), NULL, NULL)) { + DeleteProcThreadAttributeList(lpAttrList); + free(lpAttrList); + processx__ClosePseudoConsole(hPC); + CloseHandle(ptyin_write); + CloseHandle(ptyout_read); + R_ClearExternalPtr(result); + free(handle); + R_THROW_SYSTEM_ERROR( + "UpdateProcThreadAttribute for '%s'", ccommand); + } + + STARTUPINFOEXW siEx; + memset(&siEx, 0, sizeof(siEx)); + siEx.StartupInfo.cb = sizeof(STARTUPINFOEXW); + /* Do NOT set STARTF_USESTDHANDLES: ConPTY wires up stdin/stdout/stderr */ + siEx.StartupInfo.dwFlags = STARTF_USESHOWWINDOW; + siEx.StartupInfo.wShowWindow = + options.windows_hide ? SW_HIDE : SW_SHOWDEFAULT; + siEx.lpAttributeList = lpAttrList; + + /* ConPTY requires EXTENDED_STARTUPINFO_PRESENT. + Do NOT use CREATE_NO_WINDOW or DETACHED_PROCESS — either flag + prevents the child from attaching to the pseudo-console. */ + DWORD pty_flags = CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED + | EXTENDED_STARTUPINFO_PRESENT; + if (!ccleanup) pty_flags |= CREATE_NEW_PROCESS_GROUP; + + err = CreateProcessW( + /* lpApplicationName = */ application_path, + /* lpCommandLine = */ arguments, + /* lpProcessAttributes = */ NULL, + /* lpThreadAttributes = */ NULL, + /* bInheritHandles = */ TRUE, + /* dwCreationFlags = */ pty_flags, + /* lpEnvironment = */ cenv, + /* lpCurrentDirectory = */ cwd, + /* lpStartupInfo = */ (LPSTARTUPINFOW) &siEx, + /* lpProcessInformation = */ &info); + + DeleteProcThreadAttributeList(lpAttrList); + free(lpAttrList); + + if (!err) { + processx__ClosePseudoConsole(hPC); + CloseHandle(ptyin_write); + CloseHandle(ptyout_read); + R_ClearExternalPtr(result); + free(handle); + UNPROTECT(1); + R_THROW_SYSTEM_ERROR("create process '%s'", ccommand); + } + + handle->ptycon = (void *) hPC; + handle->hProcess = info.hProcess; + handle->dwProcessId = info.dwProcessId; + handle->create_time = processx__create_time(handle->hProcess); + + if (ccleanup) { + if (!processx__global_job_handle) processx__init_global_job_handle(); + if (!AssignProcessToJobObject(processx__global_job_handle, + info.hProcess)) { + DWORD derr = GetLastError(); + if (derr != ERROR_ACCESS_DENIED) { + R_THROW_SYSTEM_ERROR_CODE(derr, "Assign to job object '%s'", + ccommand); + } + } + } + + dwerr = ResumeThread(info.hThread); + if (dwerr == (DWORD) -1) { + R_THROW_SYSTEM_ERROR("resume thread for '%s'", ccommand); + } + CloseHandle(info.hThread); + + /* Create parent-side I/O connections */ + handle->pipes[0] = processx__create_connection( + ptyin_write, "stdin_pipe", private, cencoding, FALSE); + handle->pipes[1] = processx__create_connection( + ptyout_read, "stdout_pipe", private, cencoding, TRUE); + handle->pipes[2] = NULL; + + UNPROTECT(1); + return result; + } + + /* ------------------------------------------------------------------ */ + /* Normal (non-PTY) branch */ + /* ------------------------------------------------------------------ */ + + int inherit_std = 0; + err = processx__stdio_create(handle, connections, + &handle->child_stdio_buffer, private, + cencoding, ccommand, &inherit_std); + if (err) { R_THROW_SYSTEM_ERROR_CODE(err, "setup stdio for '%s'", ccommand); } + startup.cb = sizeof(startup); startup.lpReserved = NULL; startup.lpDesktop = NULL; diff --git a/tests/testthat/_snaps/Windows/process.md b/tests/testthat/_snaps/Windows/process.md index f85822c4..45402038 100644 --- a/tests/testthat/_snaps/Windows/process.md +++ b/tests/testthat/_snaps/Windows/process.md @@ -5,7 +5,7 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:162:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:159:: ! Command '/' not found @win/processx.c:982 (processx_exec) # working directory does not exist @@ -15,7 +15,7 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:162:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:159:: ! create process '/px' (system error 267, The directory name is invalid. ) @win/processx.c:1040 (processx_exec) diff --git a/tests/testthat/_snaps/Windows/run.md b/tests/testthat/_snaps/Windows/run.md index 6c27e464..ffd0f517 100644 --- a/tests/testthat/_snaps/Windows/run.md +++ b/tests/testthat/_snaps/Windows/run.md @@ -5,7 +5,7 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:162:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:159:: ! create process '/px' (system error 267, The directory name is invalid. ) @win/processx.c:1040 (processx_exec) diff --git a/tests/testthat/test-pty.R b/tests/testthat/test-pty.R index 3d49ac91..6b3559ae 100644 --- a/tests/testthat/test-pty.R +++ b/tests/testthat/test-pty.R @@ -1,10 +1,35 @@ -test_that("fails in windows", { +test_that("pty works on windows", { skip_other_platforms("windows") - expect_error( - process$new("R", pty = TRUE), - "only implemented on Unix", - class = "error" - ) + skip_on_cran() + + p <- process$new("cmd.exe", pty = TRUE) + on.exit(p$kill(), add = TRUE) + expect_true(p$is_alive()) + + pr <- p$poll_io(2000) + expect_equal(pr[["output"]], "ready") + + out <- p$read_output() + expect_true(nchar(out) > 0) +}) + +test_that("pty write_input works on windows", { + skip_other_platforms("windows") + skip_on_cran() + + p <- process$new("cmd.exe", pty = TRUE) + on.exit(p$kill(), add = TRUE) + expect_true(p$is_alive()) + + # flush the initial prompt + p$poll_io(2000) + p$read_output() + + p$write_input("echo hello\r\n") + pr <- p$poll_io(2000) + expect_equal(pr[["output"]], "ready") + out <- p$read_output() + expect_match(out, "hello") }) test_that("pty works", { diff --git a/tests/testthat/test-run.R b/tests/testthat/test-run.R index a6b2e98b..af92ca20 100644 --- a/tests/testthat/test-run.R +++ b/tests/testthat/test-run.R @@ -248,6 +248,15 @@ test_that("pty=TRUE collects merged output in stdout", { expect_null(res$stderr) }) +test_that("pty=TRUE collects merged output in stdout (windows)", { + skip_other_platforms("windows") + skip_on_cran() + + res <- run("cmd.exe", c("/c", "echo", "hello", "pty"), pty = TRUE) + expect_match(res$stdout, "hello pty") + expect_null(res$stderr) +}) + test_that("pty=TRUE works with stdout_callback", { skip_other_platforms("unix") skip_on_os("solaris") @@ -263,6 +272,20 @@ test_that("pty=TRUE works with stdout_callback", { expect_null(res$stderr) }) +test_that("pty=TRUE works with stdout_callback (windows)", { + skip_other_platforms("windows") + skip_on_cran() + + chunks <- character() + res <- run( + "cmd.exe", c("/c", "echo", "hello"), + pty = TRUE, + stdout_callback = function(x, ...) chunks <<- c(chunks, x) + ) + expect_match(paste(chunks, collapse = ""), "hello") + expect_null(res$stderr) +}) + test_that("pty=TRUE errors on incompatible arguments", { skip_on_cran() expect_snapshot(error = TRUE, run("echo", pty = TRUE, stdout = NULL)) From 370b4eb2f38ff175f5f23cad39ed40bc907169dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Tue, 14 Apr 2026 16:22:01 +0200 Subject: [PATCH 02/24] Fix compilation on Windows --- src/win/processx-win.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/win/processx-win.h b/src/win/processx-win.h index f43aac3f..5b0896db 100644 --- a/src/win/processx-win.h +++ b/src/win/processx-win.h @@ -31,6 +31,10 @@ int processx__create_pipe(void *id, HANDLE* parent_pipe_ptr, HANDLE* child_pipe_ int processx__create_input_pipe(void *id, HANDLE* parent_pipe_ptr, HANDLE* child_pipe_ptr, const char *cname); +processx_connection_t *processx__create_connection( + HANDLE pipe_handle, const char *membername, SEXP private, + const char *encoding, BOOL async); + void processx__handle_destroy(processx_handle_t *handle); void processx__stdio_noinherit(BYTE* buffer); From a7aa60645c2a6516d6eb0829483f68b481144451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Tue, 14 Apr 2026 16:33:38 +0200 Subject: [PATCH 03/24] Fix run(pty = TRUE) on Windows --- src/win/processx.c | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/win/processx.c b/src/win/processx.c index b9cced08..6eea9f79 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -1310,6 +1310,17 @@ void processx__collect_exit_status(SEXP status, DWORD exitcode) { processx_handle_t *handle = R_ExternalPtrAddr(status); handle->exitcode = exitcode; handle->collected = 1; + + /* For ConPTY processes: close the pseudo-console as soon as we know + the child has exited. Until ClosePseudoConsole() is called, the + ConPTY host (conhost.exe) keeps the write end of the output pipe + open, so poll_io() would block forever waiting for an EOF that + never arrives. Closing here lets conhost release its pipe handle, + which in turn signals EOF to our reader. */ + if (handle->ptycon && processx__ClosePseudoConsole) { + processx__ClosePseudoConsole((HPCON) handle->ptycon); + handle->ptycon = NULL; + } } SEXP processx_wait(SEXP status, SEXP timeout, SEXP name) { From e06a8af08f864262bf6d3c3c1e12e462dc409dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Tue, 14 Apr 2026 23:09:02 +0200 Subject: [PATCH 04/24] Fix read freeze on Windows with pty = TRUE --- R/io.R | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/R/io.R b/R/io.R index 97456717..9bff1c6c 100644 --- a/R/io.R +++ b/R/io.R @@ -114,6 +114,11 @@ process_read_all_output <- function(self, private) { result <- "" while (self$is_incomplete_output()) { self$poll_io(-1) + # On Windows with pty=TRUE, calling is_alive() after poll_io() triggers + # ClosePseudoConsole() if the child has exited, which causes conhost.exe + # to release its write end of the stdout pipe and signal EOF. Without + # this the drain loop blocks forever waiting for an EOF that never arrives. + self$is_alive() result <- paste0(result, self$read_output()) } result @@ -123,6 +128,7 @@ process_read_all_error <- function(self, private) { result <- "" while (self$is_incomplete_error()) { self$poll_io(-1) + self$is_alive() result <- paste0(result, self$read_error()) } result @@ -132,6 +138,7 @@ process_read_all_output_lines <- function(self, private) { results <- character() while (self$is_incomplete_output()) { self$poll_io(-1) + self$is_alive() results <- c(results, self$read_output_lines()) } results @@ -141,6 +148,7 @@ process_read_all_error_lines <- function(self, private) { results <- character() while (self$is_incomplete_error()) { self$poll_io(-1) + self$is_alive() results <- c(results, self$read_error_lines()) } results From 1aa1234328e559fcc0e4fce1d2164efbc0fc0111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Tue, 14 Apr 2026 23:16:58 +0200 Subject: [PATCH 05/24] Update NEWS for Windows PTY support --- NEWS.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/NEWS.md b/NEWS.md index a70344d0..12558f43 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,12 +1,19 @@ # processx (development version) +* `process$new()` and `run()` now support `pty = TRUE` on Windows 10 version + 1809 and later, in addition to Unix. The Windows implementation uses the + ConPTY API (`CreatePseudoConsole`). The API is loaded dynamically so + processx continues to load on older Windows and emits a clear error if + `pty = TRUE` is requested on an unsupported version (#231). + * `run()` now supports `pty = TRUE` and `pty_options` to run a process in a - pseudo-terminal (PTY) on Unix. This causes the child to see a real terminal, - so programs that disable colour output or interactive behaviour when not - attached to a terminal will behave as if they are. `stderr` is merged into - `stdout` (the result's `$stderr` is always `NULL`). A file-based `stdin` - argument is also supported: its contents are fed to the process via the PTY - master, followed by an EOF signal (#230). + pseudo-terminal (PTY) on Unix and Windows (see above). This causes the + child to see a real terminal, so programs that disable colour output or + interactive behaviour when not attached to a terminal will behave as if + they are. `stderr` is merged into `stdout` (the result's `$stderr` is + always `NULL`). A file-based `stdin` argument is also supported: its + contents are fed to the process via the PTY master, followed by an EOF + signal (#230). * `process$new()` now supports `">>"` as a prefix for `stdout` and `stderr` file paths (e.g. `stdout = ">>output.log"`), which appends output to the From f06501c23e60e7f1735e867639b4e6601c62dfbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 00:05:20 +0200 Subject: [PATCH 06/24] More stable test snapshots --- tests/testthat/_snaps/Darwin/process.md | 4 +-- tests/testthat/_snaps/Darwin/run.md | 2 +- tests/testthat/_snaps/Linux/process.md | 4 +-- tests/testthat/_snaps/Linux/run.md | 2 +- tests/testthat/_snaps/Windows/process.md | 4 +-- tests/testthat/_snaps/Windows/run.md | 2 +- tests/testthat/helper.R | 4 +++ tests/testthat/test-process.R | 4 +-- tests/testthat/test-run.R | 41 ++++++++++++++++-------- 9 files changed, 43 insertions(+), 24 deletions(-) diff --git a/tests/testthat/_snaps/Darwin/process.md b/tests/testthat/_snaps/Darwin/process.md index c1932b36..623f68a1 100644 --- a/tests/testthat/_snaps/Darwin/process.md +++ b/tests/testthat/_snaps/Darwin/process.md @@ -5,7 +5,7 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:162:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! cannot start processx process '/' (system error 2, No such file or directory) @unix/processx.c:628 (processx_exec) # working directory does not exist @@ -15,6 +15,6 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:162:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! cannot start processx process '/px' (system error 2, No such file or directory) @unix/processx.c:628 (processx_exec) diff --git a/tests/testthat/_snaps/Darwin/run.md b/tests/testthat/_snaps/Darwin/run.md index 53882cd0..b0e068bc 100644 --- a/tests/testthat/_snaps/Darwin/run.md +++ b/tests/testthat/_snaps/Darwin/run.md @@ -5,6 +5,6 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:162:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! cannot start processx process '/px' (system error 2, No such file or directory) @unix/processx.c:628 (processx_exec) diff --git a/tests/testthat/_snaps/Linux/process.md b/tests/testthat/_snaps/Linux/process.md index c1932b36..623f68a1 100644 --- a/tests/testthat/_snaps/Linux/process.md +++ b/tests/testthat/_snaps/Linux/process.md @@ -5,7 +5,7 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:162:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! cannot start processx process '/' (system error 2, No such file or directory) @unix/processx.c:628 (processx_exec) # working directory does not exist @@ -15,6 +15,6 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:162:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! cannot start processx process '/px' (system error 2, No such file or directory) @unix/processx.c:628 (processx_exec) diff --git a/tests/testthat/_snaps/Linux/run.md b/tests/testthat/_snaps/Linux/run.md index 53882cd0..b0e068bc 100644 --- a/tests/testthat/_snaps/Linux/run.md +++ b/tests/testthat/_snaps/Linux/run.md @@ -5,6 +5,6 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:162:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! cannot start processx process '/px' (system error 2, No such file or directory) @unix/processx.c:628 (processx_exec) diff --git a/tests/testthat/_snaps/Windows/process.md b/tests/testthat/_snaps/Windows/process.md index 45402038..b5b290a6 100644 --- a/tests/testthat/_snaps/Windows/process.md +++ b/tests/testthat/_snaps/Windows/process.md @@ -5,7 +5,7 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:159:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! Command '/' not found @win/processx.c:982 (processx_exec) # working directory does not exist @@ -15,7 +15,7 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:159:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! create process '/px' (system error 267, The directory name is invalid. ) @win/processx.c:1040 (processx_exec) diff --git a/tests/testthat/_snaps/Windows/run.md b/tests/testthat/_snaps/Windows/run.md index ffd0f517..3a267d57 100644 --- a/tests/testthat/_snaps/Windows/run.md +++ b/tests/testthat/_snaps/Windows/run.md @@ -5,7 +5,7 @@ Condition Error: ! ! Native call to `processx_exec` failed - Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R:159:: + Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! create process '/px' (system error 267, The directory name is invalid. ) @win/processx.c:1040 (processx_exec) diff --git a/tests/testthat/helper.R b/tests/testthat/helper.R index f82ee493..b77c6260 100644 --- a/tests/testthat/helper.R +++ b/tests/testthat/helper.R @@ -203,6 +203,10 @@ transform_column_number <- function(x) { sub("([.]R:[0-9]+:)[0-9]+", "\\1", x) } +transform_line_number <- function(x) { + sub("([.]R:[0-9]+:)[0-9]+", ".R::", x) +} + sysname <- function() { Sys.info()[["sysname"]] } diff --git a/tests/testthat/test-process.R b/tests/testthat/test-process.R index c591026a..4af0b132 100644 --- a/tests/testthat/test-process.R +++ b/tests/testthat/test-process.R @@ -19,7 +19,7 @@ test_that("non existing process", { expect_snapshot( error = TRUE, process$new(tempfile()), - transform = function(x) transform_column_number(transform_tempdir(x)), + transform = function(x) transform_line_number(transform_tempdir(x)), variant = sysname() ) ## This closes connections in finalizers @@ -79,7 +79,7 @@ test_that("working directory does not exist", { expect_snapshot( error = TRUE, process$new(px, wd = tempfile()), - transform = function(x) transform_column_number(transform_px(x)), + transform = function(x) transform_line_number(transform_px(x)), variant = sysname() ) ## This closes connections in finalizers diff --git a/tests/testthat/test-run.R b/tests/testthat/test-run.R index af92ca20..ae7548d1 100644 --- a/tests/testthat/test-run.R +++ b/tests/testthat/test-run.R @@ -80,7 +80,7 @@ test_that("working directory does not exist", { expect_snapshot( error = TRUE, run(px, wd = tempfile()), - transform = function(x) transform_column_number(transform_px(x)), + transform = function(x) transform_line_number(transform_px(x)), variant = sysname() ) gc() @@ -230,11 +230,23 @@ test_that("binary=TRUE with stdout_callback receives raw chunks", { test_that("binary=TRUE errors with line callbacks", { px <- get_tool("px") - expect_snapshot(error = TRUE, - run(px, "out", encoding = "binary", stdout_line_callback = function(x, ...) x) + expect_snapshot( + error = TRUE, + run( + px, + "out", + encoding = "binary", + stdout_line_callback = function(x, ...) x + ) ) - expect_snapshot(error = TRUE, - run(px, "out", encoding = "binary", stderr_line_callback = function(x, ...) x) + expect_snapshot( + error = TRUE, + run( + px, + "out", + encoding = "binary", + stderr_line_callback = function(x, ...) x + ) ) }) @@ -264,7 +276,8 @@ test_that("pty=TRUE works with stdout_callback", { chunks <- character() res <- run( - "echo", "hello", + "echo", + "hello", pty = TRUE, stdout_callback = function(x, ...) chunks <<- c(chunks, x) ) @@ -278,7 +291,8 @@ test_that("pty=TRUE works with stdout_callback (windows)", { chunks <- character() res <- run( - "cmd.exe", c("/c", "echo", "hello"), + "cmd.exe", + c("/c", "echo", "hello"), pty = TRUE, stdout_callback = function(x, ...) chunks <<- c(chunks, x) ) @@ -290,18 +304,19 @@ test_that("pty=TRUE errors on incompatible arguments", { skip_on_cran() expect_snapshot(error = TRUE, run("echo", pty = TRUE, stdout = NULL)) expect_snapshot(error = TRUE, run("echo", pty = TRUE, stderr = NULL)) - expect_snapshot(error = TRUE, + expect_snapshot( + error = TRUE, run("echo", pty = TRUE, stderr_to_stdout = TRUE) ) - expect_snapshot(error = TRUE, + expect_snapshot( + error = TRUE, run("echo", pty = TRUE, stderr_callback = function(x, ...) x) ) - expect_snapshot(error = TRUE, + expect_snapshot( + error = TRUE, run("echo", pty = TRUE, stderr_line_callback = function(x, ...) x) ) - expect_snapshot(error = TRUE, - run("echo", pty = TRUE, stdin = "|") - ) + expect_snapshot(error = TRUE, run("echo", pty = TRUE, stdin = "|")) }) test_that("pty=TRUE with file stdin feeds content to the process", { From 36a3cd96f1c6c4b7e717efc2373f4a0803fe4a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 00:27:07 +0200 Subject: [PATCH 07/24] Fix Windows test snapshots --- tests/testthat/_snaps/Windows/process.md | 4 ++-- tests/testthat/_snaps/Windows/run.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/testthat/_snaps/Windows/process.md b/tests/testthat/_snaps/Windows/process.md index b5b290a6..73057ea0 100644 --- a/tests/testthat/_snaps/Windows/process.md +++ b/tests/testthat/_snaps/Windows/process.md @@ -6,7 +6,7 @@ Error: ! ! Native call to `processx_exec` failed Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: - ! Command '/' not found @win/processx.c:982 (processx_exec) + ! Command '/' not found @win/processx.c:1017 (processx_exec) # working directory does not exist @@ -17,5 +17,5 @@ ! ! Native call to `processx_exec` failed Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! create process '/px' (system error 267, The directory name is invalid. - ) @win/processx.c:1040 (processx_exec) + ) @win/processx.c:1262 (processx_exec) diff --git a/tests/testthat/_snaps/Windows/run.md b/tests/testthat/_snaps/Windows/run.md index 3a267d57..c1c1ebd2 100644 --- a/tests/testthat/_snaps/Windows/run.md +++ b/tests/testthat/_snaps/Windows/run.md @@ -7,5 +7,5 @@ ! ! Native call to `processx_exec` failed Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! create process '/px' (system error 267, The directory name is invalid. - ) @win/processx.c:1040 (processx_exec) + ) @win/processx.c:1262 (processx_exec) From c6fcc27ef863d1ed2ce1922f9569f3aca11b193f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 08:20:13 +0200 Subject: [PATCH 08/24] Fix reading stdout with pty=TRUE, on Windows --- R/io.R | 29 +++++++++++++++++++++-------- R/run.R | 18 ++++++++++++++++++ src/init.c | 1 + src/processx.h | 1 + src/unix/processx.c | 5 +++++ src/win/processx.c | 21 ++++++++++++++------- 6 files changed, 60 insertions(+), 15 deletions(-) diff --git a/R/io.R b/R/io.R index 9bff1c6c..2479a44e 100644 --- a/R/io.R +++ b/R/io.R @@ -114,11 +114,27 @@ process_read_all_output <- function(self, private) { result <- "" while (self$is_incomplete_output()) { self$poll_io(-1) - # On Windows with pty=TRUE, calling is_alive() after poll_io() triggers - # ClosePseudoConsole() if the child has exited, which causes conhost.exe - # to release its write end of the stdout pipe and signal EOF. Without - # this the drain loop blocks forever waiting for an EOF that never arrives. - self$is_alive() + # On Windows with pty=TRUE the IOCP loop forces timeout=0 once poll_pipe + # signals EOF (process exit), so poll_io(-1) returns immediately after the + # child exits regardless of the requested timeout. conhost.exe processes + # the child's final writes asynchronously; we must poll *only* the stdout + # connection (not the full process) to give conhost time to flush, and then + # call ClosePseudoConsole() explicitly so conhost closes its end of the pipe. + if (private$pty && .Platform$OS.type == "windows" && !self$is_alive()) { + con <- self$get_output_connection() + repeat { + p <- poll(list(con), 100L)[[1]] + if (!identical(p, "ready")) break + result <- paste0(result, self$read_output()) + } + chain_call(c_processx_pty_close, private$status, + private$get_short_name()) + while (self$is_incomplete_output()) { + poll(list(con), -1L) + result <- paste0(result, self$read_output()) + } + return(result) + } result <- paste0(result, self$read_output()) } result @@ -128,7 +144,6 @@ process_read_all_error <- function(self, private) { result <- "" while (self$is_incomplete_error()) { self$poll_io(-1) - self$is_alive() result <- paste0(result, self$read_error()) } result @@ -138,7 +153,6 @@ process_read_all_output_lines <- function(self, private) { results <- character() while (self$is_incomplete_output()) { self$poll_io(-1) - self$is_alive() results <- c(results, self$read_output_lines()) } results @@ -148,7 +162,6 @@ process_read_all_error_lines <- function(self, private) { results <- character() while (self$is_incomplete_error()) { self$poll_io(-1) - self$is_alive() results <- c(results, self$read_error_lines()) } results diff --git a/R/run.R b/R/run.R index 02442701..fb363f25 100644 --- a/R/run.R +++ b/R/run.R @@ -593,6 +593,24 @@ run_manage <- function( if (spinner) spin() } + ## Windows PTY: after process exit the IOCP timeout is forced to 0 (because + ## poll_pipe is at EOF), so proc$poll_io(-1) returns immediately regardless + ## of the timeout. conhost.exe processes the child's final writes + ## asynchronously; we must poll *only* the stdout connection (not the full + ## process) to give conhost time to flush. Then call ClosePseudoConsole() + ## so conhost closes its write end of the pipe, causing EOF on our read end. + ## Skip this if we already killed the process (timeout / forced kill). + if (pty && .Platform$OS.type == "windows" && has_stdout && !timeout_happened) { + con <- proc$get_output_connection() + repeat { + p <- poll(list(con), 100L)[[1]] + if (!identical(p, "ready")) break + do_output() + } + priv <- get_private(proc) + chain_call(c_processx_pty_close, priv$status, priv$get_short_name()) + } + ## Needed to get the exit status "!DEBUG run() waiting to get exit status, process `proc$get_pid()`" proc$wait() diff --git a/src/init.c b/src/init.c index 1b8214b7..5af3cee6 100644 --- a/src/init.c +++ b/src/init.c @@ -65,6 +65,7 @@ static const R_CallMethodDef callMethods[] = { { "processx_exec", (DL_FUNC) &processx_exec, 14 }, { "processx_wait", (DL_FUNC) &processx_wait, 3 }, { "processx_is_alive", (DL_FUNC) &processx_is_alive, 2 }, + { "processx_pty_close", (DL_FUNC) &processx_pty_close, 2 }, { "processx_get_exit_status", (DL_FUNC) &processx_get_exit_status, 2 }, { "processx_signal", (DL_FUNC) &processx_signal, 3 }, { "processx_interrupt", (DL_FUNC) &processx_interrupt, 2 }, diff --git a/src/processx.h b/src/processx.h index 711a4e15..72f5eae4 100644 --- a/src/processx.h +++ b/src/processx.h @@ -49,6 +49,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, SEXP tree_id); SEXP processx_wait(SEXP status, SEXP timeout, SEXP name); SEXP processx_is_alive(SEXP status, SEXP name); +SEXP processx_pty_close(SEXP status, SEXP name); SEXP processx_get_exit_status(SEXP status, SEXP name); SEXP processx_signal(SEXP status, SEXP signal, SEXP name); SEXP processx_interrupt(SEXP status, SEXP name); diff --git a/src/unix/processx.c b/src/unix/processx.c index 8434ca4c..38739d84 100644 --- a/src/unix/processx.c +++ b/src/unix/processx.c @@ -697,6 +697,11 @@ static void processx__wait_cleanup(void *ptr) { * 7. We keep polling until the timeout expires or the process finishes. */ +/* No-op on Unix: ConPTY is Windows-only. */ +SEXP processx_pty_close(SEXP status, SEXP name) { + return R_NilValue; +} + SEXP processx_wait(SEXP status, SEXP timeout, SEXP name) { processx_handle_t *handle = R_ExternalPtrAddr(status); const char *cname = isNull(name) ? "???" : CHAR(STRING_ELT(name, 0)); diff --git a/src/win/processx.c b/src/win/processx.c index 6eea9f79..c36bd04a 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -1310,17 +1310,24 @@ void processx__collect_exit_status(SEXP status, DWORD exitcode) { processx_handle_t *handle = R_ExternalPtrAddr(status); handle->exitcode = exitcode; handle->collected = 1; + /* Do NOT call ClosePseudoConsole() here. ConPTY processes the child's + writes asynchronously: by the time GetExitCodeProcess() reports that + the child has exited, conhost may not have finished writing the child's + last output to our pipe. Calling ClosePseudoConsole() too early + causes that buffered output to be silently discarded. + The caller is responsible for draining the stdout pipe first (via + processx_pty_close()) after polling until no more data arrives. */ +} - /* For ConPTY processes: close the pseudo-console as soon as we know - the child has exited. Until ClosePseudoConsole() is called, the - ConPTY host (conhost.exe) keeps the write end of the output pipe - open, so poll_io() would block forever waiting for an EOF that - never arrives. Closing here lets conhost release its pipe handle, - which in turn signals EOF to our reader. */ - if (handle->ptycon && processx__ClosePseudoConsole) { +/* Called from R after the stdout pipe has been drained: signals conhost + to flush any remaining terminal output and close the pipe (EOF). */ +SEXP processx_pty_close(SEXP status, SEXP name) { + processx_handle_t *handle = R_ExternalPtrAddr(status); + if (handle && handle->ptycon && processx__ClosePseudoConsole) { processx__ClosePseudoConsole((HPCON) handle->ptycon); handle->ptycon = NULL; } + return R_NilValue; } SEXP processx_wait(SEXP status, SEXP timeout, SEXP name) { From 147a34375f81323066d9b7fc68d61b8517903c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 08:33:29 +0200 Subject: [PATCH 09/24] Fix a pty test on windows --- tests/testthat/test-pty.R | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/testthat/test-pty.R b/tests/testthat/test-pty.R index 6b3559ae..0f6d0ccc 100644 --- a/tests/testthat/test-pty.R +++ b/tests/testthat/test-pty.R @@ -21,9 +21,13 @@ test_that("pty write_input works on windows", { on.exit(p$kill(), add = TRUE) expect_true(p$is_alive()) - # flush the initial prompt - p$poll_io(2000) - p$read_output() + # flush the initial prompt; cmd.exe banner arrives in multiple VTE chunks + # so loop until 500ms of silence (the prompt is waiting for input) + repeat { + pr <- p$poll_io(500) + if (pr[["output"]] != "ready") break + p$read_output() + } p$write_input("echo hello\r\n") pr <- p$poll_io(2000) From 48466cb9f4239f0851265801d33f08eee9c1ed65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 08:52:09 +0200 Subject: [PATCH 10/24] Fix compilation on older Windows --- src/win/processx.c | 61 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/src/win/processx.c b/src/win/processx.c index c36bd04a..7981a022 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -18,12 +18,44 @@ typedef VOID* HPCON; #define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE 0x00020016 #endif +/* Fallback type and constant definitions for older MinGW headers (rtools40). + These are Vista+ features so they may not be declared when _WIN32_WINNT + is set below 0x0600. */ +#ifndef LPPROC_THREAD_ATTRIBUTE_LIST +typedef VOID *PPROC_THREAD_ATTRIBUTE_LIST, *LPPROC_THREAD_ATTRIBUTE_LIST; +#endif + +#ifndef EXTENDED_STARTUPINFO_PRESENT +#define EXTENDED_STARTUPINFO_PRESENT 0x00080000 +#endif + +#ifndef STARTUPINFOEXW_DEFINED +#define STARTUPINFOEXW_DEFINED +typedef struct _STARTUPINFOEXW { + STARTUPINFOW StartupInfo; + LPPROC_THREAD_ATTRIBUTE_LIST lpAttributeList; +} STARTUPINFOEXW, *LPSTARTUPINFOEXW; +#endif + typedef HRESULT (WINAPI *pfn_CreatePseudoConsole)( COORD, HANDLE, HANDLE, DWORD, HPCON *); typedef void (WINAPI *pfn_ClosePseudoConsole)(HPCON); +typedef BOOL (WINAPI *pfn_InitializeProcThreadAttributeList)( + LPPROC_THREAD_ATTRIBUTE_LIST, DWORD, DWORD, PSIZE_T); +typedef BOOL (WINAPI *pfn_UpdateProcThreadAttribute)( + LPPROC_THREAD_ATTRIBUTE_LIST, DWORD, DWORD_PTR, PVOID, SIZE_T, + PVOID, PSIZE_T); +typedef VOID (WINAPI *pfn_DeleteProcThreadAttributeList)( + LPPROC_THREAD_ATTRIBUTE_LIST); static pfn_CreatePseudoConsole processx__CreatePseudoConsole = NULL; static pfn_ClosePseudoConsole processx__ClosePseudoConsole = NULL; +static pfn_InitializeProcThreadAttributeList + processx__InitializeProcThreadAttributeList = NULL; +static pfn_UpdateProcThreadAttribute + processx__UpdateProcThreadAttribute = NULL; +static pfn_DeleteProcThreadAttributeList + processx__DeleteProcThreadAttributeList = NULL; /* 0 = unchecked, 1 = available, -1 = not available */ static int processx__pty_api_state = 0; @@ -36,10 +68,22 @@ static int processx__load_pty_api(void) { (pfn_CreatePseudoConsole) GetProcAddress(hK32, "CreatePseudoConsole"); processx__ClosePseudoConsole = (pfn_ClosePseudoConsole) GetProcAddress(hK32, "ClosePseudoConsole"); + processx__InitializeProcThreadAttributeList = + (pfn_InitializeProcThreadAttributeList) + GetProcAddress(hK32, "InitializeProcThreadAttributeList"); + processx__UpdateProcThreadAttribute = + (pfn_UpdateProcThreadAttribute) + GetProcAddress(hK32, "UpdateProcThreadAttribute"); + processx__DeleteProcThreadAttributeList = + (pfn_DeleteProcThreadAttributeList) + GetProcAddress(hK32, "DeleteProcThreadAttributeList"); } processx__pty_api_state = - (processx__CreatePseudoConsole && processx__ClosePseudoConsole) ? 1 : -1; + (processx__CreatePseudoConsole && processx__ClosePseudoConsole && + processx__InitializeProcThreadAttributeList && + processx__UpdateProcThreadAttribute && + processx__DeleteProcThreadAttributeList) ? 1 : -1; return processx__pty_api_state == 1; } @@ -1084,7 +1128,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, /* Build the process-thread attribute list containing the ConPTY handle */ SIZE_T attrlist_size = 0; - InitializeProcThreadAttributeList(NULL, 1, 0, &attrlist_size); + processx__InitializeProcThreadAttributeList(NULL, 1, 0, &attrlist_size); LPPROC_THREAD_ATTRIBUTE_LIST lpAttrList = (LPPROC_THREAD_ATTRIBUTE_LIST) malloc(attrlist_size); if (!lpAttrList) { @@ -1095,7 +1139,8 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, free(handle); R_THROW_ERROR("Out of memory when creating subprocess '%s'", ccommand); } - if (!InitializeProcThreadAttributeList(lpAttrList, 1, 0, &attrlist_size)) { + if (!processx__InitializeProcThreadAttributeList( + lpAttrList, 1, 0, &attrlist_size)) { free(lpAttrList); processx__ClosePseudoConsole(hPC); CloseHandle(ptyin_write); @@ -1105,10 +1150,10 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, R_THROW_SYSTEM_ERROR( "InitializeProcThreadAttributeList for '%s'", ccommand); } - if (!UpdateProcThreadAttribute(lpAttrList, 0, - PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, - hPC, sizeof(HPCON), NULL, NULL)) { - DeleteProcThreadAttributeList(lpAttrList); + if (!processx__UpdateProcThreadAttribute( + lpAttrList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + hPC, sizeof(HPCON), NULL, NULL)) { + processx__DeleteProcThreadAttributeList(lpAttrList); free(lpAttrList); processx__ClosePseudoConsole(hPC); CloseHandle(ptyin_write); @@ -1147,7 +1192,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, /* lpStartupInfo = */ (LPSTARTUPINFOW) &siEx, /* lpProcessInformation = */ &info); - DeleteProcThreadAttributeList(lpAttrList); + processx__DeleteProcThreadAttributeList(lpAttrList); free(lpAttrList); if (!err) { From abb29353c777462c86ae5d1a4e5d601e464a992e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 08:55:22 +0200 Subject: [PATCH 11/24] Another fix for older R on Windows --- src/win/processx.c | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/win/processx.c b/src/win/processx.c index 7981a022..a6a4919a 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -19,18 +19,13 @@ typedef VOID* HPCON; #endif /* Fallback type and constant definitions for older MinGW headers (rtools40). - These are Vista+ features so they may not be declared when _WIN32_WINNT - is set below 0x0600. */ -#ifndef LPPROC_THREAD_ATTRIBUTE_LIST -typedef VOID *PPROC_THREAD_ATTRIBUTE_LIST, *LPPROC_THREAD_ATTRIBUTE_LIST; -#endif - -#ifndef EXTENDED_STARTUPINFO_PRESENT + These are Vista+ (0x0600) features; the 32-bit rtools40 toolchain sets + _WIN32_WINNT below that threshold so the declarations are absent. + We cannot use #ifndef because typedefs do not create preprocessor macros. */ +#if !defined(_WIN32_WINNT) || (_WIN32_WINNT < 0x0600) +typedef struct _PROC_THREAD_ATTRIBUTE_LIST *PPROC_THREAD_ATTRIBUTE_LIST, + *LPPROC_THREAD_ATTRIBUTE_LIST; #define EXTENDED_STARTUPINFO_PRESENT 0x00080000 -#endif - -#ifndef STARTUPINFOEXW_DEFINED -#define STARTUPINFOEXW_DEFINED typedef struct _STARTUPINFOEXW { STARTUPINFOW StartupInfo; LPPROC_THREAD_ATTRIBUTE_LIST lpAttributeList; From aa51982d369b4035a8f837c18aede360f9e45e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 08:57:13 +0200 Subject: [PATCH 12/24] Fix a compilation warning on older R on Windows --- src/win/processx.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/win/processx.c b/src/win/processx.c index a6a4919a..2415f0f5 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -22,10 +22,13 @@ typedef VOID* HPCON; These are Vista+ (0x0600) features; the 32-bit rtools40 toolchain sets _WIN32_WINNT below that threshold so the declarations are absent. We cannot use #ifndef because typedefs do not create preprocessor macros. */ +#ifndef EXTENDED_STARTUPINFO_PRESENT +#define EXTENDED_STARTUPINFO_PRESENT 0x00080000 +#endif + #if !defined(_WIN32_WINNT) || (_WIN32_WINNT < 0x0600) typedef struct _PROC_THREAD_ATTRIBUTE_LIST *PPROC_THREAD_ATTRIBUTE_LIST, *LPPROC_THREAD_ATTRIBUTE_LIST; -#define EXTENDED_STARTUPINFO_PRESENT 0x00080000 typedef struct _STARTUPINFOEXW { STARTUPINFOW StartupInfo; LPPROC_THREAD_ATTRIBUTE_LIST lpAttributeList; From c2147c6f6dd1871bcdefd7e8b3261ce91f7430b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 09:00:13 +0200 Subject: [PATCH 13/24] Update test snapshots on windows --- tests/testthat/_snaps/Windows/process.md | 4 ++-- tests/testthat/_snaps/Windows/run.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/testthat/_snaps/Windows/process.md b/tests/testthat/_snaps/Windows/process.md index 73057ea0..9094bc7f 100644 --- a/tests/testthat/_snaps/Windows/process.md +++ b/tests/testthat/_snaps/Windows/process.md @@ -6,7 +6,7 @@ Error: ! ! Native call to `processx_exec` failed Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: - ! Command '/' not found @win/processx.c:1017 (processx_exec) + ! Command '/' not found @win/processx.c:1059 (processx_exec) # working directory does not exist @@ -17,5 +17,5 @@ ! ! Native call to `processx_exec` failed Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! create process '/px' (system error 267, The directory name is invalid. - ) @win/processx.c:1262 (processx_exec) + ) @win/processx.c:1305 (processx_exec) diff --git a/tests/testthat/_snaps/Windows/run.md b/tests/testthat/_snaps/Windows/run.md index c1c1ebd2..42e64e37 100644 --- a/tests/testthat/_snaps/Windows/run.md +++ b/tests/testthat/_snaps/Windows/run.md @@ -7,5 +7,5 @@ ! ! Native call to `processx_exec` failed Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! create process '/px' (system error 267, The directory name is invalid. - ) @win/processx.c:1262 (processx_exec) + ) @win/processx.c:1305 (processx_exec) From e9a8cef0e3c729c52f6974589bc6a3fb8c18aa53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 09:02:21 +0200 Subject: [PATCH 14/24] Windows pty: adjust poll timeout for read_all_output and run This does not look very good. --- R/io.R | 2 +- R/run.R | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/R/io.R b/R/io.R index 2479a44e..e9091598 100644 --- a/R/io.R +++ b/R/io.R @@ -123,7 +123,7 @@ process_read_all_output <- function(self, private) { if (private$pty && .Platform$OS.type == "windows" && !self$is_alive()) { con <- self$get_output_connection() repeat { - p <- poll(list(con), 100L)[[1]] + p <- poll(list(con), 1000L)[[1]] if (!identical(p, "ready")) break result <- paste0(result, self$read_output()) } diff --git a/R/run.R b/R/run.R index fb363f25..00dd7bed 100644 --- a/R/run.R +++ b/R/run.R @@ -603,7 +603,7 @@ run_manage <- function( if (pty && .Platform$OS.type == "windows" && has_stdout && !timeout_happened) { con <- proc$get_output_connection() repeat { - p <- poll(list(con), 100L)[[1]] + p <- poll(list(con), 1000L)[[1]] if (!identical(p, "ready")) break do_output() } From fab67662126c677a0798368105bec8db46abb0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 09:03:02 +0200 Subject: [PATCH 15/24] Fix polling in a windows pty test case --- tests/testthat/test-pty.R | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/testthat/test-pty.R b/tests/testthat/test-pty.R index 0f6d0ccc..614eeb7d 100644 --- a/tests/testthat/test-pty.R +++ b/tests/testthat/test-pty.R @@ -21,17 +21,23 @@ test_that("pty write_input works on windows", { on.exit(p$kill(), add = TRUE) expect_true(p$is_alive()) - # flush the initial prompt; cmd.exe banner arrives in multiple VTE chunks - # so loop until 500ms of silence (the prompt is waiting for input) + # flush the initial prompt; cmd.exe banner arrives in multiple VTE chunks. + # Poll the stdout connection directly (not the full process) to avoid the + # poll_pipe-forces-timeout-0 effect: once poll_pipe has data (process event), + # the IOCP hasdata flag forces timeleft=0 in every subsequent poll_io() call, + # so poll_io() would return immediately with output="silent" regardless of + # the requested timeout, and the banner would never be fully drained. + con <- p$get_output_connection() repeat { - pr <- p$poll_io(500) - if (pr[["output"]] != "ready") break + pr <- poll(list(con), 1000L)[[1]] + if (pr != "ready") break p$read_output() } p$write_input("echo hello\r\n") - pr <- p$poll_io(2000) - expect_equal(pr[["output"]], "ready") + # Poll stdout directly here too, for the same reason + pr <- poll(list(con), 2000L)[[1]] + expect_equal(pr, "ready") out <- p$read_output() expect_match(out, "hello") }) From 04ac52bc2c636f251d825f982f4e0829aa0dfc82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 09:06:02 +0200 Subject: [PATCH 16/24] GHA: manual dispatch for check workflow [ci skip] --- .github/workflows/R-CMD-check.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 9ce0693c..89ab8a21 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -8,6 +8,7 @@ on: push: branches: [main, master] pull_request: + workflow_dispatch: name: R-CMD-check.yaml From f05079e6603ce223e0e4593e47dceb8d6655daf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 09:38:40 +0200 Subject: [PATCH 17/24] Windows pty tests: use px instead of cmd.exe Maybe this works better on GitHub actions. --- tests/testthat/test-pty.R | 29 ++++++++++++----------------- tests/testthat/test-run.R | 8 +++++--- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/tests/testthat/test-pty.R b/tests/testthat/test-pty.R index 614eeb7d..e8948bed 100644 --- a/tests/testthat/test-pty.R +++ b/tests/testthat/test-pty.R @@ -2,12 +2,14 @@ test_that("pty works on windows", { skip_other_platforms("windows") skip_on_cran() - p <- process$new("cmd.exe", pty = TRUE) + px <- get_tool("px") + p <- process$new(px, c("outln", "hello", "sleep", "10"), pty = TRUE) on.exit(p$kill(), add = TRUE) expect_true(p$is_alive()) - pr <- p$poll_io(2000) - expect_equal(pr[["output"]], "ready") + con <- p$get_output_connection() + pr <- poll(list(con), 2000L)[[1]] + expect_equal(pr, "ready") out <- p$read_output() expect_true(nchar(out) > 0) @@ -17,25 +19,18 @@ test_that("pty write_input works on windows", { skip_other_platforms("windows") skip_on_cran() - p <- process$new("cmd.exe", pty = TRUE) + # Use px cat instead of cmd.exe: px reads stdin and echoes to stdout, + # no banner to flush, no cmd.exe overhead or security-software interference. + px <- get_tool("px") + p <- process$new(px, c("cat", ""), pty = TRUE) on.exit(p$kill(), add = TRUE) expect_true(p$is_alive()) - # flush the initial prompt; cmd.exe banner arrives in multiple VTE chunks. - # Poll the stdout connection directly (not the full process) to avoid the - # poll_pipe-forces-timeout-0 effect: once poll_pipe has data (process event), - # the IOCP hasdata flag forces timeleft=0 in every subsequent poll_io() call, - # so poll_io() would return immediately with output="silent" regardless of - # the requested timeout, and the banner would never be fully drained. con <- p$get_output_connection() - repeat { - pr <- poll(list(con), 1000L)[[1]] - if (pr != "ready") break - p$read_output() - } - p$write_input("echo hello\r\n") - # Poll stdout directly here too, for the same reason + p$write_input("hello\r\n") + # Poll the stdout connection directly (not the full process) to avoid the + # poll_pipe-forces-timeout-0 effect when the process has already exited. pr <- poll(list(con), 2000L)[[1]] expect_equal(pr, "ready") out <- p$read_output() diff --git a/tests/testthat/test-run.R b/tests/testthat/test-run.R index ae7548d1..310380bb 100644 --- a/tests/testthat/test-run.R +++ b/tests/testthat/test-run.R @@ -264,7 +264,8 @@ test_that("pty=TRUE collects merged output in stdout (windows)", { skip_other_platforms("windows") skip_on_cran() - res <- run("cmd.exe", c("/c", "echo", "hello", "pty"), pty = TRUE) + px <- get_tool("px") + res <- run(px, c("outln", "hello pty"), pty = TRUE) expect_match(res$stdout, "hello pty") expect_null(res$stderr) }) @@ -289,10 +290,11 @@ test_that("pty=TRUE works with stdout_callback (windows)", { skip_other_platforms("windows") skip_on_cran() + px <- get_tool("px") chunks <- character() res <- run( - "cmd.exe", - c("/c", "echo", "hello"), + px, + c("outln", "hello"), pty = TRUE, stdout_callback = function(x, ...) chunks <<- c(chunks, x) ) From f949859cc526e7399a8deb437ce3f5950e17ca18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 09:43:23 +0200 Subject: [PATCH 18/24] Need to poll in windows pty test case --- tests/testthat/test-pty.R | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/testthat/test-pty.R b/tests/testthat/test-pty.R index e8948bed..00169551 100644 --- a/tests/testthat/test-pty.R +++ b/tests/testthat/test-pty.R @@ -8,11 +8,14 @@ test_that("pty works on windows", { expect_true(p$is_alive()) con <- p$get_output_connection() - pr <- poll(list(con), 2000L)[[1]] - expect_equal(pr, "ready") - - out <- p$read_output() - expect_true(nchar(out) > 0) + out <- "" + repeat { + pr <- poll(list(con), 2000L)[[1]] + if (!identical(pr, "ready")) break + out <- paste0(out, p$read_output()) + if (grepl("hello", out, fixed = TRUE)) break + } + expect_match(out, "hello") }) test_that("pty write_input works on windows", { @@ -31,9 +34,14 @@ test_that("pty write_input works on windows", { p$write_input("hello\r\n") # Poll the stdout connection directly (not the full process) to avoid the # poll_pipe-forces-timeout-0 effect when the process has already exited. - pr <- poll(list(con), 2000L)[[1]] - expect_equal(pr, "ready") - out <- p$read_output() + # Loop to skip VT init sequences that ConPTY writes before any child output. + out <- "" + repeat { + pr <- poll(list(con), 2000L)[[1]] + if (!identical(pr, "ready")) break + out <- paste0(out, p$read_output()) + if (grepl("hello", out, fixed = TRUE)) break + } expect_match(out, "hello") }) From 515103314ec3d980cfb9c16c808ad9117e7bf1c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 10:30:01 +0200 Subject: [PATCH 19/24] Make windows pty work in a background process w/o a console [ci skip] --- src/win/processx.c | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/win/processx.c b/src/win/processx.c index 2415f0f5..d359df2f 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -1105,13 +1105,24 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, R_THROW_SYSTEM_ERROR_CODE(err, "create PTY stdout pipe for '%s'", ccommand); } - /* Create the pseudo-console */ + /* Create the pseudo-console. + On Windows 10, CreatePseudoConsole internally spawns conhost.exe and + needs access to the console device (\Device\ConDrv). When the calling + process has no console at all (e.g. Rscript started without one by + R CMD check or a CI runner), that device is unreachable and the call + returns E_UNEXPECTED. The fix: temporarily allocate a hidden console + so the device is accessible, then free it once ConPTY is set up. + On Windows 11 the implementation is in-process and does not need this, + but the extra AllocConsole/FreeConsole is harmless there too. */ COORD pty_size; pty_size.X = (SHORT) pty_cols; pty_size.Y = (SHORT) pty_rows; HPCON hPC; + BOOL alloc_console = (GetConsoleWindow() == NULL); + if (alloc_console) AllocConsole(); HRESULT hr = processx__CreatePseudoConsole( pty_size, ptyin_read, ptyout_child, 0, &hPC); + if (alloc_console) FreeConsole(); /* ConPTY made its own internal copies; close the pipe ends we passed in */ CloseHandle(ptyin_read); CloseHandle(ptyout_child); From 260fa5821699b76aa957e992e47f450279d98f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 10:55:49 +0200 Subject: [PATCH 20/24] Fix windows pty in a background process [ci skip] --- src/win/processx.c | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/win/processx.c b/src/win/processx.c index d359df2f..7082a2d7 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -1106,27 +1106,38 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, } /* Create the pseudo-console. - On Windows 10, CreatePseudoConsole internally spawns conhost.exe and + On Windows 10, CreatePseudoConsole internally spawns conhost.exe which needs access to the console device (\Device\ConDrv). When the calling - process has no console at all (e.g. Rscript started without one by - R CMD check or a CI runner), that device is unreachable and the call - returns E_UNEXPECTED. The fix: temporarily allocate a hidden console - so the device is accessible, then free it once ConPTY is set up. - On Windows 11 the implementation is in-process and does not need this, - but the extra AllocConsole/FreeConsole is harmless there too. */ + process has no console at all (e.g. Rscript started by R CMD check or + a CI runner), that device is unreachable and the call returns + E_UNEXPECTED. Fix: temporarily allocate a hidden console so the + device is accessible. + Critically, keep the console alive through CreateProcessW and + ResumeThread: the child's ConPTY stdin/stdout/stderr attachment is + initialised during process startup, and it needs a valid console + context in the parent at that point. Freeing the console before the + child starts causes the child's I/O not to be wired up, so only the + init escape sequences written by conhost.exe arrive and the child's + own output is lost. + On Windows 11, CreatePseudoConsole is in-process and does not need + this, but the AllocConsole/FreeConsole pair is harmless there too. */ COORD pty_size; pty_size.X = (SHORT) pty_cols; pty_size.Y = (SHORT) pty_rows; HPCON hPC; BOOL alloc_console = (GetConsoleWindow() == NULL); - if (alloc_console) AllocConsole(); + if (alloc_console) { + AllocConsole(); + HWND ach = GetConsoleWindow(); + if (ach) ShowWindow(ach, SW_HIDE); + } HRESULT hr = processx__CreatePseudoConsole( pty_size, ptyin_read, ptyout_child, 0, &hPC); - if (alloc_console) FreeConsole(); /* ConPTY made its own internal copies; close the pipe ends we passed in */ CloseHandle(ptyin_read); CloseHandle(ptyout_child); if (FAILED(hr)) { + if (alloc_console) FreeConsole(); CloseHandle(ptyin_write); CloseHandle(ptyout_read); R_ClearExternalPtr(result); @@ -1141,6 +1152,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, LPPROC_THREAD_ATTRIBUTE_LIST lpAttrList = (LPPROC_THREAD_ATTRIBUTE_LIST) malloc(attrlist_size); if (!lpAttrList) { + if (alloc_console) FreeConsole(); processx__ClosePseudoConsole(hPC); CloseHandle(ptyin_write); CloseHandle(ptyout_read); @@ -1150,6 +1162,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, } if (!processx__InitializeProcThreadAttributeList( lpAttrList, 1, 0, &attrlist_size)) { + if (alloc_console) FreeConsole(); free(lpAttrList); processx__ClosePseudoConsole(hPC); CloseHandle(ptyin_write); @@ -1162,6 +1175,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, if (!processx__UpdateProcThreadAttribute( lpAttrList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, hPC, sizeof(HPCON), NULL, NULL)) { + if (alloc_console) FreeConsole(); processx__DeleteProcThreadAttributeList(lpAttrList); free(lpAttrList); processx__ClosePseudoConsole(hPC); @@ -1184,7 +1198,11 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, /* ConPTY requires EXTENDED_STARTUPINFO_PRESENT. Do NOT use CREATE_NO_WINDOW or DETACHED_PROCESS — either flag - prevents the child from attaching to the pseudo-console. */ + prevents the child from attaching to the pseudo-console. + bInheritHandles must be FALSE: the child's I/O is entirely through + ConPTY, so there are no handles to pass from the parent. Using TRUE + in a background R process (e.g. from callr) would inherit R's own + stdout pipe into the child, interfering with the ConPTY wiring. */ DWORD pty_flags = CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT; if (!ccleanup) pty_flags |= CREATE_NEW_PROCESS_GROUP; @@ -1194,7 +1212,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, /* lpCommandLine = */ arguments, /* lpProcessAttributes = */ NULL, /* lpThreadAttributes = */ NULL, - /* bInheritHandles = */ TRUE, + /* bInheritHandles = */ FALSE, /* dwCreationFlags = */ pty_flags, /* lpEnvironment = */ cenv, /* lpCurrentDirectory = */ cwd, @@ -1205,6 +1223,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, free(lpAttrList); if (!err) { + if (alloc_console) FreeConsole(); processx__ClosePseudoConsole(hPC); CloseHandle(ptyin_write); CloseHandle(ptyout_read); @@ -1225,6 +1244,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, info.hProcess)) { DWORD derr = GetLastError(); if (derr != ERROR_ACCESS_DENIED) { + if (alloc_console) FreeConsole(); R_THROW_SYSTEM_ERROR_CODE(derr, "Assign to job object '%s'", ccommand); } @@ -1232,6 +1252,10 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, } dwerr = ResumeThread(info.hThread); + /* Free the temporary console only after the child's main thread is + resumed: the child needs a valid console context in the parent + during its ConPTY initialisation. */ + if (alloc_console) FreeConsole(); if (dwerr == (DWORD) -1) { R_THROW_SYSTEM_ERROR("resume thread for '%s'", ccommand); } From b287306400caf9e5fb5a80b299d6475323f31e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 11:28:47 +0200 Subject: [PATCH 21/24] More background windows pty tries [ci skip] --- src/win/processx.c | 59 +++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/src/win/processx.c b/src/win/processx.c index 7082a2d7..a809450d 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -1110,26 +1110,41 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, needs access to the console device (\Device\ConDrv). When the calling process has no console at all (e.g. Rscript started by R CMD check or a CI runner), that device is unreachable and the call returns - E_UNEXPECTED. Fix: temporarily allocate a hidden console so the - device is accessible. - Critically, keep the console alive through CreateProcessW and - ResumeThread: the child's ConPTY stdin/stdout/stderr attachment is - initialised during process startup, and it needs a valid console - context in the parent at that point. Freeing the console before the - child starts causes the child's I/O not to be wired up, so only the - init escape sequences written by conhost.exe arrive and the child's - own output is lost. + E_UNEXPECTED. Fix: allocate a hidden console so the device is + accessible. + We do NOT call FreeConsole afterwards. Experimentation shows that + calling FreeConsole at any point — even after ResumeThread — causes + the child process's I/O to silently detach from the ConPTY: the + conhost.exe initialisation sequences (written before the child starts) + still arrive, but the child's own output is lost. The most likely + explanation is that the ConPTY conhost.exe is internally tied to the + console session created by AllocConsole; destroying that session via + FreeConsole severs the child→ConPTY connection. + Instead we save and restore R's original standard handles around + AllocConsole so that R's own I/O (e.g. callr's capture pipes) + continues to work correctly. The hidden console persists for the + lifetime of the R session, which is harmless. On Windows 11, CreatePseudoConsole is in-process and does not need - this, but the AllocConsole/FreeConsole pair is harmless there too. */ + a pre-existing console at all, so AllocConsole is a no-op there + (it returns FALSE when a console already exists). */ COORD pty_size; pty_size.X = (SHORT) pty_cols; pty_size.Y = (SHORT) pty_rows; HPCON hPC; - BOOL alloc_console = (GetConsoleWindow() == NULL); - if (alloc_console) { - AllocConsole(); - HWND ach = GetConsoleWindow(); - if (ach) ShowWindow(ach, SW_HIDE); + { + /* Save R's current standard handles (may be callr/Rscript pipes). */ + HANDLE saved_in = GetStdHandle(STD_INPUT_HANDLE); + HANDLE saved_out = GetStdHandle(STD_OUTPUT_HANDLE); + HANDLE saved_err = GetStdHandle(STD_ERROR_HANDLE); + if (AllocConsole()) { + /* AllocConsole replaced the standard handles with CONIN$/CONOUT$; + restore the originals so R's own I/O is unaffected. */ + HWND ach = GetConsoleWindow(); + if (ach) ShowWindow(ach, SW_HIDE); + SetStdHandle(STD_INPUT_HANDLE, saved_in); + SetStdHandle(STD_OUTPUT_HANDLE, saved_out); + SetStdHandle(STD_ERROR_HANDLE, saved_err); + } } HRESULT hr = processx__CreatePseudoConsole( pty_size, ptyin_read, ptyout_child, 0, &hPC); @@ -1137,7 +1152,6 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, CloseHandle(ptyin_read); CloseHandle(ptyout_child); if (FAILED(hr)) { - if (alloc_console) FreeConsole(); CloseHandle(ptyin_write); CloseHandle(ptyout_read); R_ClearExternalPtr(result); @@ -1152,7 +1166,6 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, LPPROC_THREAD_ATTRIBUTE_LIST lpAttrList = (LPPROC_THREAD_ATTRIBUTE_LIST) malloc(attrlist_size); if (!lpAttrList) { - if (alloc_console) FreeConsole(); processx__ClosePseudoConsole(hPC); CloseHandle(ptyin_write); CloseHandle(ptyout_read); @@ -1162,7 +1175,6 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, } if (!processx__InitializeProcThreadAttributeList( lpAttrList, 1, 0, &attrlist_size)) { - if (alloc_console) FreeConsole(); free(lpAttrList); processx__ClosePseudoConsole(hPC); CloseHandle(ptyin_write); @@ -1175,7 +1187,6 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, if (!processx__UpdateProcThreadAttribute( lpAttrList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, hPC, sizeof(HPCON), NULL, NULL)) { - if (alloc_console) FreeConsole(); processx__DeleteProcThreadAttributeList(lpAttrList); free(lpAttrList); processx__ClosePseudoConsole(hPC); @@ -1201,8 +1212,8 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, prevents the child from attaching to the pseudo-console. bInheritHandles must be FALSE: the child's I/O is entirely through ConPTY, so there are no handles to pass from the parent. Using TRUE - in a background R process (e.g. from callr) would inherit R's own - stdout pipe into the child, interfering with the ConPTY wiring. */ + in a background R process would inherit R's own stdout pipe into the + child, interfering with the ConPTY wiring. */ DWORD pty_flags = CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT; if (!ccleanup) pty_flags |= CREATE_NEW_PROCESS_GROUP; @@ -1223,7 +1234,6 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, free(lpAttrList); if (!err) { - if (alloc_console) FreeConsole(); processx__ClosePseudoConsole(hPC); CloseHandle(ptyin_write); CloseHandle(ptyout_read); @@ -1244,7 +1254,6 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, info.hProcess)) { DWORD derr = GetLastError(); if (derr != ERROR_ACCESS_DENIED) { - if (alloc_console) FreeConsole(); R_THROW_SYSTEM_ERROR_CODE(derr, "Assign to job object '%s'", ccommand); } @@ -1252,10 +1261,6 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, } dwerr = ResumeThread(info.hThread); - /* Free the temporary console only after the child's main thread is - resumed: the child needs a valid console context in the parent - during its ConPTY initialisation. */ - if (alloc_console) FreeConsole(); if (dwerr == (DWORD) -1) { R_THROW_SYSTEM_ERROR("resume thread for '%s'", ccommand); } From 00fb3770277e13485fb4f5068e9dda50559f1a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 12:07:56 +0200 Subject: [PATCH 22/24] Yet another try for windows pty in a background process [ci skip] --- src/win/processx.c | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/src/win/processx.c b/src/win/processx.c index a809450d..aebc7af5 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -1108,35 +1108,37 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, /* Create the pseudo-console. On Windows 10, CreatePseudoConsole internally spawns conhost.exe which needs access to the console device (\Device\ConDrv). When the calling - process has no console at all (e.g. Rscript started by R CMD check or - a CI runner), that device is unreachable and the call returns - E_UNEXPECTED. Fix: allocate a hidden console so the device is - accessible. - We do NOT call FreeConsole afterwards. Experimentation shows that - calling FreeConsole at any point — even after ResumeThread — causes - the child process's I/O to silently detach from the ConPTY: the - conhost.exe initialisation sequences (written before the child starts) - still arrive, but the child's own output is lost. The most likely - explanation is that the ConPTY conhost.exe is internally tied to the - console session created by AllocConsole; destroying that session via - FreeConsole severs the child→ConPTY connection. - Instead we save and restore R's original standard handles around - AllocConsole so that R's own I/O (e.g. callr's capture pipes) - continues to work correctly. The hidden console persists for the - lifetime of the R session, which is harmless. - On Windows 11, CreatePseudoConsole is in-process and does not need - a pre-existing console at all, so AllocConsole is a no-op there - (it returns FALSE when a console already exists). */ + process has no console at all (e.g. Rscript started by R CMD check, + callr, or processx::run with redirected stdout), that device is + unreachable and the call returns E_UNEXPECTED. Fix: allocate a hidden + console so the device is accessible, then FREE it again before calling + CreateProcessW. + The FreeConsole call must happen AFTER CreatePseudoConsole (which has + already taken its own internal copies of the pipe handles) but BEFORE + CreateProcessW. If the console is still alive when the child process is + created, the child attaches to it for its stdout rather than exclusively + using the ConPTY — all child output is then lost from ptyout_read (only + the conhost init sequences arrive, written before the child starts). + FreeConsole is safe at this point: on Windows 10 CreatePseudoConsole + already spawned conhost.exe as a separate process with its own console + reference; on Windows 11 the ConPTY is entirely in-process (HPCON). + Either way, the ConPTY does not depend on the parent's console + attachment after CreatePseudoConsole returns. + On Windows 11 (and interactive R sessions), AllocConsole returns FALSE + because the process already has a console, so allocated_console stays + FALSE and FreeConsole is not called. */ COORD pty_size; pty_size.X = (SHORT) pty_cols; pty_size.Y = (SHORT) pty_rows; HPCON hPC; + BOOL allocated_console = FALSE; { /* Save R's current standard handles (may be callr/Rscript pipes). */ HANDLE saved_in = GetStdHandle(STD_INPUT_HANDLE); HANDLE saved_out = GetStdHandle(STD_OUTPUT_HANDLE); HANDLE saved_err = GetStdHandle(STD_ERROR_HANDLE); if (AllocConsole()) { + allocated_console = TRUE; /* AllocConsole replaced the standard handles with CONIN$/CONOUT$; restore the originals so R's own I/O is unaffected. */ HWND ach = GetConsoleWindow(); @@ -1151,6 +1153,10 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, /* ConPTY made its own internal copies; close the pipe ends we passed in */ CloseHandle(ptyin_read); CloseHandle(ptyout_child); + /* Free the temporary console before spawning the child. */ + if (allocated_console) { + FreeConsole(); + } if (FAILED(hr)) { CloseHandle(ptyin_write); CloseHandle(ptyout_read); From 1f6501fea0322bdcd5aafac27e2f78858a34a297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 13:03:08 +0200 Subject: [PATCH 23/24] Yet another fix for windows pty in background processes [ci skip] --- src/win/processx.c | 111 ++++++++++++++++++++++++++------------------- 1 file changed, 65 insertions(+), 46 deletions(-) diff --git a/src/win/processx.c b/src/win/processx.c index aebc7af5..bade696c 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -1106,58 +1106,72 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, } /* Create the pseudo-console. - On Windows 10, CreatePseudoConsole internally spawns conhost.exe which - needs access to the console device (\Device\ConDrv). When the calling - process has no console at all (e.g. Rscript started by R CMD check, - callr, or processx::run with redirected stdout), that device is - unreachable and the call returns E_UNEXPECTED. Fix: allocate a hidden - console so the device is accessible, then FREE it again before calling - CreateProcessW. - The FreeConsole call must happen AFTER CreatePseudoConsole (which has - already taken its own internal copies of the pipe handles) but BEFORE - CreateProcessW. If the console is still alive when the child process is - created, the child attaches to it for its stdout rather than exclusively - using the ConPTY — all child output is then lost from ptyout_read (only - the conhost init sequences arrive, written before the child starts). - FreeConsole is safe at this point: on Windows 10 CreatePseudoConsole - already spawned conhost.exe as a separate process with its own console - reference; on Windows 11 the ConPTY is entirely in-process (HPCON). - Either way, the ConPTY does not depend on the parent's console - attachment after CreatePseudoConsole returns. - On Windows 11 (and interactive R sessions), AllocConsole returns FALSE - because the process already has a console, so allocated_console stays - FALSE and FreeConsole is not called. */ + Background R processes (callr, R CMD check, processx::run with captured + stdout) have their standard handles set to PIPE handles, not to the + console handles CONIN$/CONOUT$. PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE + can only intercept writes that go to CONSOLE handles; writes to pipe + handles bypass the ConPTY entirely, causing child output to be lost. + Fix: ensure the parent's standard handles are CONSOLE handles + (CONIN$/CONOUT$) at the point CreateProcessW is called. The kernel + copies the parent's standard handles into the child; PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE + then intercepts those console writes and routes them through the ConPTY. + We restore the original pipe handles immediately after CreateProcessW so + that R's own I/O is unaffected. + AllocConsole (if the process has no console yet) both makes + CreatePseudoConsole work on Windows 10 (which needs \Device\ConDrv) and + sets the standard handles to CONIN$/CONOUT$ in one step. When the + process already has a console, AllocConsole returns FALSE and we open + CONIN$/CONOUT$ explicitly to set the standard handles. The hidden + console from AllocConsole persists for the process lifetime (harmless; + subsequent AllocConsole calls return FALSE). */ COORD pty_size; pty_size.X = (SHORT) pty_cols; pty_size.Y = (SHORT) pty_rows; HPCON hPC; - BOOL allocated_console = FALSE; - { - /* Save R's current standard handles (may be callr/Rscript pipes). */ - HANDLE saved_in = GetStdHandle(STD_INPUT_HANDLE); - HANDLE saved_out = GetStdHandle(STD_OUTPUT_HANDLE); - HANDLE saved_err = GetStdHandle(STD_ERROR_HANDLE); - if (AllocConsole()) { - allocated_console = TRUE; - /* AllocConsole replaced the standard handles with CONIN$/CONOUT$; - restore the originals so R's own I/O is unaffected. */ - HWND ach = GetConsoleWindow(); - if (ach) ShowWindow(ach, SW_HIDE); - SetStdHandle(STD_INPUT_HANDLE, saved_in); - SetStdHandle(STD_OUTPUT_HANDLE, saved_out); - SetStdHandle(STD_ERROR_HANDLE, saved_err); + /* Save originals and open/set console handles for CreateProcessW. */ + HANDLE saved_in = GetStdHandle(STD_INPUT_HANDLE); + HANDLE saved_out = GetStdHandle(STD_OUTPUT_HANDLE); + HANDLE saved_err = GetStdHandle(STD_ERROR_HANDLE); + HANDLE hConIn = INVALID_HANDLE_VALUE; + HANDLE hConOut = INVALID_HANDLE_VALUE; + if (AllocConsole()) { + /* AllocConsole already set std handles to CONIN$/CONOUT$. */ + HWND ach = GetConsoleWindow(); + if (ach) ShowWindow(ach, SW_HIDE); + } else { + /* Process already has a console (interactive session or a previous + AllocConsole call). Std handles may still be pipe handles; open + CONIN$/CONOUT$ explicitly and install them as std handles. */ + hConIn = CreateFileA("CONIN$", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, NULL); + hConOut = CreateFileA("CONOUT$", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, NULL); + if (hConIn != INVALID_HANDLE_VALUE) SetStdHandle(STD_INPUT_HANDLE, hConIn); + if (hConOut != INVALID_HANDLE_VALUE) { + SetStdHandle(STD_OUTPUT_HANDLE, hConOut); + SetStdHandle(STD_ERROR_HANDLE, hConOut); } } + + /* Helper: restores original std handles and closes any console handles + we opened. Called on every error path and after CreateProcessW. */ +#define PROCESSX_PTY_RESTORE_STD_HANDLES() do { \ + SetStdHandle(STD_INPUT_HANDLE, saved_in); \ + SetStdHandle(STD_OUTPUT_HANDLE, saved_out); \ + SetStdHandle(STD_ERROR_HANDLE, saved_err); \ + if (hConIn != INVALID_HANDLE_VALUE) CloseHandle(hConIn); \ + if (hConOut != INVALID_HANDLE_VALUE) CloseHandle(hConOut); \ + } while (0) + HRESULT hr = processx__CreatePseudoConsole( pty_size, ptyin_read, ptyout_child, 0, &hPC); /* ConPTY made its own internal copies; close the pipe ends we passed in */ CloseHandle(ptyin_read); CloseHandle(ptyout_child); - /* Free the temporary console before spawning the child. */ - if (allocated_console) { - FreeConsole(); - } if (FAILED(hr)) { + PROCESSX_PTY_RESTORE_STD_HANDLES(); CloseHandle(ptyin_write); CloseHandle(ptyout_read); R_ClearExternalPtr(result); @@ -1172,6 +1186,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, LPPROC_THREAD_ATTRIBUTE_LIST lpAttrList = (LPPROC_THREAD_ATTRIBUTE_LIST) malloc(attrlist_size); if (!lpAttrList) { + PROCESSX_PTY_RESTORE_STD_HANDLES(); processx__ClosePseudoConsole(hPC); CloseHandle(ptyin_write); CloseHandle(ptyout_read); @@ -1181,6 +1196,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, } if (!processx__InitializeProcThreadAttributeList( lpAttrList, 1, 0, &attrlist_size)) { + PROCESSX_PTY_RESTORE_STD_HANDLES(); free(lpAttrList); processx__ClosePseudoConsole(hPC); CloseHandle(ptyin_write); @@ -1193,6 +1209,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, if (!processx__UpdateProcThreadAttribute( lpAttrList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, hPC, sizeof(HPCON), NULL, NULL)) { + PROCESSX_PTY_RESTORE_STD_HANDLES(); processx__DeleteProcThreadAttributeList(lpAttrList); free(lpAttrList); processx__ClosePseudoConsole(hPC); @@ -1207,7 +1224,9 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, STARTUPINFOEXW siEx; memset(&siEx, 0, sizeof(siEx)); siEx.StartupInfo.cb = sizeof(STARTUPINFOEXW); - /* Do NOT set STARTF_USESTDHANDLES: ConPTY wires up stdin/stdout/stderr */ + /* Do NOT set STARTF_USESTDHANDLES: the parent's current std handles + (CONIN$/CONOUT$, set above) are copied into the child by CreateProcessW + and then intercepted by PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE. */ siEx.StartupInfo.dwFlags = STARTF_USESHOWWINDOW; siEx.StartupInfo.wShowWindow = options.windows_hide ? SW_HIDE : SW_SHOWDEFAULT; @@ -1215,11 +1234,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, /* ConPTY requires EXTENDED_STARTUPINFO_PRESENT. Do NOT use CREATE_NO_WINDOW or DETACHED_PROCESS — either flag - prevents the child from attaching to the pseudo-console. - bInheritHandles must be FALSE: the child's I/O is entirely through - ConPTY, so there are no handles to pass from the parent. Using TRUE - in a background R process would inherit R's own stdout pipe into the - child, interfering with the ConPTY wiring. */ + prevents the child from attaching to the pseudo-console. */ DWORD pty_flags = CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT; if (!ccleanup) pty_flags |= CREATE_NEW_PROCESS_GROUP; @@ -1239,6 +1254,10 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, processx__DeleteProcThreadAttributeList(lpAttrList); free(lpAttrList); + /* Restore R's original standard handles now that the child exists. */ + PROCESSX_PTY_RESTORE_STD_HANDLES(); +#undef PROCESSX_PTY_RESTORE_STD_HANDLES + if (!err) { processx__ClosePseudoConsole(hPC); CloseHandle(ptyin_write); From b22d0851abffc4ce6c79ce11a07bc4153a30f109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Wed, 15 Apr 2026 14:46:07 +0200 Subject: [PATCH 24/24] Update windows test snapshots --- tests/testthat/_snaps/Windows/process.md | 2 +- tests/testthat/_snaps/Windows/run.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/testthat/_snaps/Windows/process.md b/tests/testthat/_snaps/Windows/process.md index 9094bc7f..2aec025e 100644 --- a/tests/testthat/_snaps/Windows/process.md +++ b/tests/testthat/_snaps/Windows/process.md @@ -17,5 +17,5 @@ ! ! Native call to `processx_exec` failed Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! create process '/px' (system error 267, The directory name is invalid. - ) @win/processx.c:1305 (processx_exec) + ) @win/processx.c:1370 (processx_exec) diff --git a/tests/testthat/_snaps/Windows/run.md b/tests/testthat/_snaps/Windows/run.md index 42e64e37..b8e1ef31 100644 --- a/tests/testthat/_snaps/Windows/run.md +++ b/tests/testthat/_snaps/Windows/run.md @@ -7,5 +7,5 @@ ! ! Native call to `processx_exec` failed Caused by error in `chain_call(c_processx_exec, command, c(command, args), pty, pty_options, ...` at initialize.R::: ! create process '/px' (system error 267, The directory name is invalid. - ) @win/processx.c:1305 (processx_exec) + ) @win/processx.c:1370 (processx_exec)