Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7722081
PTY support on Windows
gaborcsardi Apr 14, 2026
370b4eb
Fix compilation on Windows
gaborcsardi Apr 14, 2026
a7aa606
Fix run(pty = TRUE) on Windows
gaborcsardi Apr 14, 2026
e06a8af
Fix read freeze on Windows with pty = TRUE
gaborcsardi Apr 14, 2026
1aa1234
Update NEWS for Windows PTY support
gaborcsardi Apr 14, 2026
f06501c
More stable test snapshots
gaborcsardi Apr 14, 2026
36a3cd9
Fix Windows test snapshots
gaborcsardi Apr 14, 2026
c6fcc27
Fix reading stdout with pty=TRUE, on Windows
gaborcsardi Apr 15, 2026
147a343
Fix a pty test on windows
gaborcsardi Apr 15, 2026
48466cb
Fix compilation on older Windows
gaborcsardi Apr 15, 2026
abb2935
Another fix for older R on Windows
gaborcsardi Apr 15, 2026
aa51982
Fix a compilation warning on older R on Windows
gaborcsardi Apr 15, 2026
c2147c6
Update test snapshots on windows
gaborcsardi Apr 15, 2026
e9a8cef
Windows pty: adjust poll timeout for read_all_output and run
gaborcsardi Apr 15, 2026
fab6766
Fix polling in a windows pty test case
gaborcsardi Apr 15, 2026
04ac52b
GHA: manual dispatch for check workflow
gaborcsardi Apr 15, 2026
f05079e
Windows pty tests: use px instead of cmd.exe
gaborcsardi Apr 15, 2026
f949859
Need to poll in windows pty test case
gaborcsardi Apr 15, 2026
5151033
Make windows pty work in a background process w/o a console
gaborcsardi Apr 15, 2026
260fa58
Fix windows pty in a background process
gaborcsardi Apr 15, 2026
b287306
More background windows pty tries
gaborcsardi Apr 15, 2026
00fb377
Yet another try for windows pty in a background process
gaborcsardi Apr 15, 2026
1f6501f
Yet another fix for windows pty in background processes
gaborcsardi Apr 15, 2026
b22d085
Update windows test snapshots
gaborcsardi Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/R-CMD-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
push:
branches: [main, master]
pull_request:
workflow_dispatch:

name: R-CMD-check.yaml

Expand Down
19 changes: 13 additions & 6 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 0 additions & 3 deletions R/initialize.R
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down
21 changes: 21 additions & 0 deletions R/io.R
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,27 @@ process_read_all_output <- function(self, private) {
result <- ""
while (self$is_incomplete_output()) {
self$poll_io(-1)
# 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), 1000L)[[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
Expand Down
45 changes: 37 additions & 8 deletions R/run.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand All @@ -582,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), 1000L)[[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()
Expand Down
15 changes: 8 additions & 7 deletions man/run.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/init.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions src/processx.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/unix/processx.c
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
5 changes: 5 additions & 0 deletions src/win/processx-win.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -30,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);
Expand Down
Loading
Loading