Skip to content

Commit 5e7abd8

Browse files
committed
added Process
1 parent 8093a5b commit 5e7abd8

11 files changed

Lines changed: 1026 additions & 0 deletions

File tree

src/Utils/Process.php

Lines changed: 509 additions & 0 deletions
Large diffs are not rendered by default.

src/Utils/exceptions.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,19 @@ class RegexpException extends \Exception
4646
class AssertionException extends \Exception
4747
{
4848
}
49+
50+
51+
/**
52+
* The process failed to run successfully.
53+
*/
54+
class ProcessFailedException extends \RuntimeException
55+
{
56+
}
57+
58+
59+
/**
60+
* The process execution exceeded its timeout limit.
61+
*/
62+
class ProcessTimeoutException extends \RuntimeException
63+
{
64+
}

tests/Utils/Process.basic.phpt

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Nette\Utils\Helpers;
6+
use Nette\Utils\Process;
7+
use Nette\Utils\ProcessFailedException;
8+
use Nette\Utils\ProcessTimeoutException;
9+
use Tester\Assert;
10+
11+
require __DIR__ . '/../bootstrap.php';
12+
13+
14+
// Process execution - success
15+
16+
test('run executable successfully', function () {
17+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo "hello";']);
18+
Assert::true($process->isSuccess());
19+
Assert::same(0, $process->getExitCode());
20+
Assert::same('hello', $process->getStdOutput());
21+
Assert::same('', $process->getStdError());
22+
});
23+
24+
test('run command successfully', function () {
25+
$process = Process::runCommand('echo hello');
26+
Assert::true($process->isSuccess());
27+
Assert::same(0, $process->getExitCode());
28+
Assert::same('hello' . PHP_EOL, $process->getStdOutput());
29+
Assert::same('', $process->getStdError());
30+
});
31+
32+
33+
// Process execution - errors
34+
35+
test('run executable with error', function () {
36+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'exit(1);']);
37+
Assert::false($process->isSuccess());
38+
Assert::same(1, $process->getExitCode());
39+
});
40+
41+
test('run executable ensure success throws exception on error', function () {
42+
Assert::exception(
43+
fn() => Process::runExecutable(PHP_BINARY, ['-r', 'exit(1);'])->ensureSuccess(),
44+
ProcessFailedException::class,
45+
'Process failed with non-zero exit code: 1',
46+
);
47+
});
48+
49+
test('ensureSuccess() does not throw on success', function () {
50+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo "ok";']);
51+
$process->ensureSuccess();
52+
Assert::same('ok', $process->getStdOutput());
53+
});
54+
55+
test('run command with error', function () {
56+
$process = Process::runCommand('"' . PHP_BINARY . '" -r "exit(1);"');
57+
Assert::false($process->isSuccess());
58+
Assert::same(1, $process->getExitCode());
59+
});
60+
61+
test('run command ensure success throws exception on error', function () {
62+
Assert::exception(
63+
fn() => Process::runCommand('"' . PHP_BINARY . '" -r "exit(1);"')->ensureSuccess(),
64+
ProcessFailedException::class,
65+
'Process failed with non-zero exit code: 1',
66+
);
67+
});
68+
69+
70+
// Process state monitoring
71+
72+
test('is running', function () {
73+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'sleep(1);']);
74+
Assert::true($process->isRunning());
75+
$process->wait();
76+
Assert::false($process->isRunning());
77+
});
78+
79+
test('get pid', function () {
80+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'sleep(1);']);
81+
Assert::type('int', $process->getPid());
82+
$process->wait();
83+
Assert::null($process->getPid());
84+
});
85+
86+
87+
// Waiting for process
88+
89+
test('wait', function () {
90+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo "hello";']);
91+
$process->wait();
92+
$process->wait();
93+
Assert::false($process->isRunning());
94+
Assert::same(0, $process->getExitCode());
95+
Assert::same('hello', $process->getStdOutput());
96+
});
97+
98+
test('wait with callback', function () {
99+
$output = '';
100+
$error = '';
101+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo "hello"; fwrite(STDERR, "error");']);
102+
$process->wait(function ($stdOut, $stdErr) use (&$output, &$error) {
103+
$output .= $stdOut;
104+
$error .= $stdErr;
105+
});
106+
Assert::same('hello', $output);
107+
Assert::same('error', $error);
108+
});
109+
110+
111+
// Automatically call wait()
112+
113+
test('getStdOutput() automatically call wait()', function () {
114+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo "hello";']);
115+
Assert::same('hello', $process->getStdOutput());
116+
Assert::false($process->isRunning());
117+
});
118+
119+
test('getExitCode() automatically call wait()', function () {
120+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'exit(2);']);
121+
Assert::same(2, $process->getExitCode());
122+
Assert::false($process->isRunning());
123+
});
124+
125+
test('reads large output without deadlocking', function () {
126+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo str_repeat("a", 1_000_000);']);
127+
Assert::same(1_000_000, strlen($process->getStdOutput()));
128+
});
129+
130+
131+
// Terminating process
132+
133+
test('terminate', function () {
134+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'sleep(5);']);
135+
$process->terminate();
136+
Assert::false($process->isRunning());
137+
});
138+
139+
test('terminate() and then wait()', function () {
140+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'sleep(5);']);
141+
$process->terminate();
142+
$process->wait();
143+
Assert::false($process->isRunning());
144+
});
145+
146+
test('getExitCode() after terminate()', function () {
147+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'sleep(5);']);
148+
$process->terminate();
149+
Assert::type('int', $process->getExitCode());
150+
Assert::false($process->isSuccess());
151+
});
152+
153+
test('terminate() does not hang on a process that ignores SIGTERM', function () {
154+
if (!function_exists('pcntl_signal')) {
155+
Tester\Environment::skip('Requires the pcntl extension.');
156+
}
157+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'pcntl_async_signals(true); pcntl_signal(SIGTERM, fn() => null); while (true) sleep(1);']);
158+
usleep(100_000); // let the child install the handler
159+
$process->terminate(); // would hang in proc_close() if only SIGTERM were sent
160+
Assert::false($process->isRunning());
161+
});
162+
163+
164+
// Timeout
165+
166+
test('timeout', function () {
167+
Assert::exception(
168+
fn() => Process::runExecutable(PHP_BINARY, ['-r', 'sleep(5);'], timeout: 0.1)->wait(),
169+
ProcessTimeoutException::class,
170+
'Process exceeded the time limit of 0.1 seconds',
171+
);
172+
});
173+
174+
175+
// bypass_shell
176+
177+
if (Helpers::IsWindows) {
178+
test('bypass_shell = false', function () {
179+
$process = Process::runCommand('"' . PHP_BINARY . '" -r "echo 123;"', options: ['bypass_shell' => false]);
180+
Assert::same('123', $process->getStdOutput());
181+
});
182+
}

tests/Utils/Process.consume.phpt

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Nette\Utils\Process;
6+
use Tester\Assert;
7+
8+
require __DIR__ . '/../bootstrap.php';
9+
10+
11+
/**
12+
* Reads the output incrementally: polls while the process runs, then reads the final chunk.
13+
* The fixture flushes one byte every 50 ms and we poll every 20 ms, so the data reliably
14+
* arrives in several chunks rather than all at once.
15+
* @return string[] the non-empty chunks in the order they were received
16+
*/
17+
$drain = function (Process $process, callable $consume): array {
18+
$chunks = [];
19+
do {
20+
usleep(20_000);
21+
if (($chunk = $consume($process)) !== '') {
22+
$chunks[] = $chunk;
23+
}
24+
} while ($process->isRunning());
25+
26+
if (($chunk = $consume($process)) !== '') { // the part produced after the process finished
27+
$chunks[] = $chunk;
28+
}
29+
return $chunks;
30+
};
31+
32+
33+
test('incremental output consumption', function () use ($drain) {
34+
$process = Process::runExecutable(PHP_BINARY, ['-f', __DIR__ . '/fixtures.process/incremental.php', 'stdout']);
35+
$chunks = $drain($process, fn(Process $p) => $p->consumeStdOutput());
36+
37+
Assert::same('helloworld', implode($chunks));
38+
Assert::true(count($chunks) > 1, 'output should arrive in several chunks');
39+
Assert::same('', $process->consumeStdOutput());
40+
Assert::same('helloworld', $process->getStdOutput());
41+
});
42+
43+
test('incremental error output consumption', function () use ($drain) {
44+
$process = Process::runExecutable(PHP_BINARY, ['-f', __DIR__ . '/fixtures.process/incremental.php', 'stderr']);
45+
$chunks = $drain($process, fn(Process $p) => $p->consumeStdError());
46+
47+
Assert::same('hello' . PHP_EOL . 'world' . PHP_EOL, implode($chunks));
48+
Assert::true(count($chunks) > 1, 'error output should arrive in several chunks');
49+
Assert::same('', $process->consumeStdError());
50+
Assert::same('hello' . PHP_EOL . 'world' . PHP_EOL, $process->getStdError());
51+
});
52+
53+
54+
// TODO: Process::run() and Process::ensure() convenience methods
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Nette\Utils\Process;
6+
use Tester\Assert;
7+
8+
require __DIR__ . '/../bootstrap.php';
9+
10+
11+
// Environment variables
12+
13+
test('environment variables', function () {
14+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo getenv("TEST_VAR");'], env: ['TEST_VAR' => '123']);
15+
Assert::same('123', $process->getStdOutput());
16+
});
17+
18+
test('no environment variables', function () {
19+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo !getenv("PATH") ? "ok" : "no";'], env: []);
20+
Assert::same('ok', $process->getStdOutput());
21+
});
22+
23+
test('parent environment variables', function () {
24+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo getenv("PATH") ? "ok" : "no";']);
25+
Assert::same('ok', $process->getStdOutput());
26+
});

tests/Utils/Process.input.phpt

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Nette\Utils\Process;
6+
use Tester\Assert;
7+
8+
require __DIR__ . '/../bootstrap.php';
9+
10+
11+
// Different input types
12+
13+
test('string as input', function () {
14+
$input = 'Hello Input';
15+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo fgets(STDIN);'], stdin: $input);
16+
Assert::same('Hello Input', $process->getStdOutput());
17+
});
18+
19+
test('stream as input', function () {
20+
$input = fopen('php://memory', 'r+');
21+
fwrite($input, 'Hello Input');
22+
rewind($input);
23+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo fgets(STDIN);'], stdin: $input);
24+
Assert::same('Hello Input', $process->getStdOutput());
25+
});
26+
27+
test('large string input', function () {
28+
$input = str_repeat('x', 200_000); // larger than a typical OS pipe buffer
29+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo strlen(stream_get_contents(STDIN));'], stdin: $input);
30+
Assert::same('200000', $process->getStdOutput());
31+
});
32+
33+
test('invalid input type is rejected before the process starts', function () {
34+
Assert::exception(
35+
fn() => Process::runExecutable(PHP_BINARY, ['-r', 'sleep(10);'], stdin: false),
36+
Nette\InvalidArgumentException::class,
37+
'Input must be string, resource, Process or null, bool given.',
38+
);
39+
});
40+
41+
42+
// Writing input
43+
44+
test('write input', function () {
45+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo fgets(STDIN);'], stdin: null);
46+
$process->writeStdInput('hello' . PHP_EOL);
47+
$process->writeStdInput('world' . PHP_EOL);
48+
$process->closeStdInput();
49+
Assert::same('hello' . PHP_EOL, $process->getStdOutput());
50+
});
51+
52+
test('writeStdInput() after closeStdInput() throws exception', function () {
53+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo fgets(STDIN);'], stdin: null);
54+
$process->writeStdInput('hello' . PHP_EOL);
55+
$process->closeStdInput();
56+
Assert::exception(
57+
fn() => $process->writeStdInput('world' . PHP_EOL),
58+
Nette\InvalidStateException::class,
59+
'Cannot write to process: STDIN pipe is closed',
60+
);
61+
});
62+
63+
test('writeStdInput() throws exception when stdin is not null', function () {
64+
$process = Process::runExecutable(PHP_BINARY, ['-r', 'echo fgets(STDIN);']);
65+
Assert::exception(
66+
fn() => $process->writeStdInput('hello' . PHP_EOL),
67+
Nette\InvalidStateException::class,
68+
'Cannot write to process: STDIN pipe is closed',
69+
);
70+
});

0 commit comments

Comments
 (0)