Skip to content

Commit 7da6c39

Browse files
committed
added Process
1 parent f233611 commit 7da6c39

11 files changed

Lines changed: 1006 additions & 0 deletions

File tree

src/Utils/Process.php

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

tests/Utils/Process.consume.phpt

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

tests/Utils/Process.input.phpt

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

0 commit comments

Comments
 (0)