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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* Keep parsable outputs (`--format=xml`, completion script, ...) free of the update reminder, deprecations and remote packages progress
* Ignore `--no-remote`, `--update-remotes` and `--format` when given to a task after `--`
* Accept `--context` with `castor list`
* Give the terminal size to the PTY of the processes, so they no longer fall back on 80 columns (only the size at the start of the command)

## 1.9.0 (2026-10-20)

Expand Down
5 changes: 5 additions & 0 deletions doc/docs/getting-started/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,11 @@ function foo(): void
}
```

> [!NOTE]
> The PTY gets the size of the terminal of Castor when the command starts, but
> does not follow its resizing: prefer a TTY for a full screen command (`vim`,
> `top`, ...).

### Trapping signals

By default, a signal sent to Castor - like the `SIGINT` triggered by a `CTRL+C` -
Expand Down
5 changes: 5 additions & 0 deletions doc/docs/getting-started/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ the process:
{{ php("examples/basic/run/pty.php") }}
```

> [!NOTE]
> The PTY gets the size of the terminal of Castor when the command starts, but
> does not follow its resizing: prefer a TTY for a full screen command (`vim`,
> `top`, ...).

## Providing input to a process

You can provide input to a process via stdin using the `input` option on the
Expand Down
3 changes: 3 additions & 0 deletions src/Runner/ProcessRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ public function run(
$process->setTty(true);
$process->setInput(\STDIN);
} elseif ($context->pty) {
if (SizedPtyProcess::hasTerminal()) {
$process = SizedPtyProcess::create($command, $context);
}
$process->setPty(true);
$process->setInput(\STDIN);
}
Expand Down
68 changes: 68 additions & 0 deletions src/Runner/SizedPtyProcess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace Castor\Runner;

use Castor\Context;
use Symfony\Component\Process\Process;

/**
* PHP creates the PTY of a process without a size (0x0): the commands fall back
* on 80 columns, and a full screen one (top, vim, ...) is unusable. This process
* gives the PTY the size of the terminal of Castor first, through a shell which
* then runs the command in its place: same PID, signals and exit code.
*
* Only the initial size: a resizing of the terminal does not reach the PTY.
*
* @internal
*/
final class SizedPtyProcess extends Process
{
// /dev/tty is the terminal of Castor: the PTY, on the standard input, keeps
// no size if it can not be read anymore (closed meanwhile, for example). The
// errors are silenced before opening it, as the shell reports its failure
private const SET_SIZE = 'castor_pty_size=$(stty size 2>/dev/null </dev/tty) && stty rows "${castor_pty_size% *}" cols "${castor_pty_size#* }" 2>/dev/null; unset castor_pty_size';

private static ?bool $hasTerminal = null;

private string $commandLine;

/**
* Without terminal (in a CI, for example), there is no size to give: the
* command runs as is.
*/
public static function hasTerminal(): bool
{
if (null === self::$hasTerminal) {
$tty = @fopen('/dev/tty', 'r');
self::$hasTerminal = false !== $tty;

if (false !== $tty) {
fclose($tty);
}
}

return self::$hasTerminal;
}

/**
* @param string|string[] $command
*/
public static function create(string|array $command, Context $context): self
{
if (\is_array($command)) {
$process = new self(['sh', '-c', self::SET_SIZE . '; exec "$@"', 'sh', ...$command], $context->workingDirectory, $context->environment, null, $context->timeout);
$process->commandLine = new Process($command)->getCommandLine();
} else {
$process = self::fromShellCommandline(self::SET_SIZE . '; ' . $command, $context->workingDirectory, $context->environment, null, $context->timeout);
$process->commandLine = $command;
}

return $process;
}

// The command as asked, without the shell which sizes the PTY
public function getCommandLine(): string
{
return $this->commandLine;
}
}
90 changes: 90 additions & 0 deletions tests/PtySizeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace Castor\Tests;

use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;

/**
* PHP creates the PTY of a process without a size: Castor gives it the size of
* its terminal.
*/
class PtySizeTest extends TaskTestCase
{
public function testACommandGetsTheSizeOfTheTerminal(): void
{
$this->assertSame('40 150', $this->runInTerminal(['size']));
}

public function testAShellCommandGetsTheSizeOfTheTerminal(): void
{
$this->assertSame('40 150', $this->runInTerminal(['size-shell']));
}

public function testTheCommandLineIsTheOneAsked(): void
{
$this->assertSame("Command line: 'true'", $this->runInTerminal(['command-line']));
}

public function testTheExitCodeIsTheOneOfTheCommand(): void
{
$this->assertSame('Exit code: 3', $this->runInTerminal(['exit-code']));
}

public function testAFailureShowsTheCommandAsked(): void
{
$output = $this->runInTerminal(['failure'], 1);

$this->assertMatchesRegularExpression("{did not finish successfully \\(exit code 1\\): *\n\n'false'\n}", $output);
$this->assertStringNotContainsString('stty', $output);
}

public function testThePtyKeepsNoSizeWithoutTerminal(): void
{
if (null === $setsid = new ExecutableFinder()->find('setsid')) {
$this->markTestSkipped('The "setsid" command of util-linux is needed to run Castor without terminal.');
}

// In a new session, Castor has no terminal, even when the tests run in one
$process = $this->createCastorProcess([$setsid, '--wait', self::$castorBin, '--no-ansi', 'size']);
$process->run();

$this->assertSame(0, $process->getExitCode(), $process->getErrorOutput());
$this->assertSame('0 0', trim($process->getOutput()));
}

/**
* @param list<string> $args
*/
private function runInTerminal(array $args, int $expectedExitCode = 0): string
{
if ('Linux' !== \PHP_OS_FAMILY || null === $script = new ExecutableFinder()->find('script')) {
$this->markTestSkipped('The "script" command of util-linux is needed to run Castor in a pseudo terminal.');
}

$castor = implode(' ', array_map(escapeshellarg(...), [self::$castorBin, '--no-ansi', ...$args]));
$process = $this->createCastorProcess([$script, '--quiet', '--return', '--command', "stty rows 40 cols 150; {$castor}", '/dev/null']);
$process->run();

$this->assertSame($expectedExitCode, $process->getExitCode(), $process->getOutput());

return trim(str_replace("\r\n", "\n", $process->getOutput()));
}

/**
* @param list<string> $command
*/
private function createCastorProcess(array $command): Process
{
return new Process(
$command,
cwd: __DIR__ . '/fixtures/valid/pty-size',
env: [
'CASTOR_CACHE_DIR' => self::$castorCacheDir,
'CASTOR_DISABLE_AGENT_DETECTION' => 'true',
'CASTOR_NO_REMOTE' => 1,
'CASTOR_TEST' => 'true',
],
);
}
}
39 changes: 39 additions & 0 deletions tests/fixtures/valid/pty-size/castor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

\defined('CASTOR_USE_CHDIR') || \define('CASTOR_USE_CHDIR', true);

use Castor\Attribute\AsTask;

use function Castor\context;
use function Castor\io;
use function Castor\run;

#[AsTask]
function size(): void
{
run(['stty', 'size']);
}

#[AsTask]
function size_shell(): void
{
run('stty size');
}

#[AsTask]
function command_line(): void
{
io()->writeln('Command line: ' . run(['true'])->getCommandLine());
}

#[AsTask]
function exit_code(): void
{
io()->writeln('Exit code: ' . run(['sh', '-c', 'exit 3'], context: context()->withAllowFailure())->getExitCode());
}

#[AsTask]
function failure(): void
{
run(['false']);
}
Loading