1
+ <?php
2
+ declare (strict_types = 1 );
3
+ namespace Greendrake \AsyncProcess ;
4
+ use Ds \Set ;
5
+ use function React \Async \await ;
6
+ use Psr \Http \Message \RequestInterface as Request ;
7
+ use React \Http \Browser ;
8
+ use React \Http \Server ;
9
+ use React \Promise as BasePromise ;
10
+ use React \Socket \ConnectionInterface ;
11
+ use React \Socket \Server as SocketServer ;
12
+
13
+ class Promise {
14
+
15
+ protected BasePromise \Deferred $ deferred ;
16
+ protected BasePromise \Promise $ promise ;
17
+ protected ?int $ pid = null ;
18
+
19
+ private static $ sigHoldHandlerSetup = false ;
20
+ private static $ sigHoldDefaultHandler ;
21
+
22
+ public function __construct (protected string $ command ) {
23
+ $ this ->deferred = new BasePromise \Deferred ;
24
+ $ this ->promise = $ this ->deferred ->promise ();
25
+ if (!self ::$ sigHoldHandlerSetup ) {
26
+ self ::$ sigHoldDefaultHandler = pcntl_signal_get_handler (SIGCHLD );
27
+ // When the forked process exits, it sends the parent process SIGCHLD signal.
28
+ // Handling it properly is required to avoid the exited children becoming zombies.
29
+ // (For whatever reason it doesn't actually work for me, so there is a posix_kill call underneath, but trying it anyway).
30
+ pcntl_signal (SIGCHLD , SIG_IGN );
31
+ self ::$ sigHoldHandlerSetup = true ;
32
+ }
33
+ $ httpAddress = '127.0.0.1: ' . self ::findUnusedPort ();
34
+ $ pid = pcntl_fork ();
35
+ if ($ pid == -1 ) {
36
+ throw new \RuntimeException ('Could not fork process ' );
37
+ } else if ($ pid ) {
38
+ // The original, now parent process. Setup an HTTP server and wait for the forked process to post the result to it:
39
+ $ this ->pid = $ pid ; // The PID of the forked process, not the parent.
40
+ $ result = null ;
41
+ $ socket = new SocketServer ($ httpAddress );
42
+ // This is a one-off request/response HTTP server.
43
+ // After the connection is closed, close the socket and pass the response over to the deferred promise.
44
+ $ socket ->on ('connection ' , function (ConnectionInterface $ conn ) use ($ socket , &$ result ) {
45
+ $ conn ->on ('close ' , function () use ($ socket , &$ result ) {
46
+ $ socket ->close ();
47
+ // We've made the forked process session leader, we've set SIGCHLD handler to SIG_IGN above,
48
+ // and yet it may remain a zombie process even after calling exit in itself. Fuck knows why.
49
+ // Kill the zombie in case it is still walking:
50
+ posix_kill ($ this ->pid , SIGKILL );
51
+ // Now that the job is done, cleanup our mess — bring the original handler back:
52
+ pcntl_signal (SIGCHLD , self ::$ sigHoldDefaultHandler );
53
+ // Report the results (whatever they are) as per our promise:
54
+ if ($ result ['success ' ]) {
55
+ $ m = $ result ['code ' ] === 0 ? 'resolve ' : 'reject ' ;
56
+ $ output = implode (PHP_EOL , $ result ['result ' ]);
57
+ if ($ m === 'reject ' ) {
58
+ $ output = new NonZeroExitException (sprintf ('Exit code %s: %s ' , $ result ['code ' ], $ output ));
59
+ }
60
+ $ this ->deferred ->$ m ($ output );
61
+ } else {
62
+ $ this ->deferred ->reject ($ result ['error ' ]);
63
+ }
64
+ });
65
+ });
66
+ // Actually run the one-off HTTP server to wait for what the forked process has to say:
67
+ $ server = new Server (function (Request $ request ) use (&$ result ) {
68
+ $ result = unserialize ((string ) $ request ->getBody ());
69
+ });
70
+ $ server ->listen ($ socket );
71
+ } else {
72
+ // The forked process.
73
+ // Re-instate the default handler (otherwise the reported exit code will be -1, see https://stackoverflow.com/questions/77288724):
74
+ pcntl_signal (SIGCHLD , self ::$ sigHoldDefaultHandler );
75
+ $ browser = new Browser ;
76
+ // Define the function that will report results back to the parent:
77
+ $ reportBack = function (int $ forkExitCode = 0 , ?int $ jobExitCode = null , ?array $ result = null , ?\Throwable $ error = null ) use ($ browser , $ httpAddress ) {
78
+ await ($ browser ->post ('http:// ' . $ httpAddress , [], serialize ([
79
+ 'success ' => $ error === null ,
80
+ 'result ' => $ result ,
81
+ 'code ' => $ jobExitCode ,
82
+ 'error ' => $ error ,
83
+ ]))->catch (function () {
84
+ // Don't give a fuck. This is the forked background process, and if anything is wrong, no one is gonna hear anyway.
85
+ }));
86
+ // This forked process will probably be killed by the parent before it reaches this point,
87
+ // but, just in case, we put an explicit exit here to make sure it does not do anything else:
88
+ exit ($ forkExitCode );
89
+ };
90
+ if (false === ($ pid = getmypid ())) {
91
+ // This is very unlikely, but let's handle it.
92
+ $ reportBack (
93
+ forkExitCode : 1 ,
94
+ error: new \RuntimeException ('Could not get child process PID within itself ' )
95
+ );
96
+ } else {
97
+ try {
98
+ $ this ->pid = $ pid ;
99
+ // Make this process the session leader so that it does not depend on the parent anymore:
100
+ if (posix_setsid () < 0 ) {
101
+ $ reportBack (
102
+ forkExitCode: 1 ,
103
+ error: new \RuntimeException ('Could not make background process ' . $ pid . ' session leader ' )
104
+ );
105
+ } else {
106
+ // Do the actual job however long it takes, suppress STDERR (otherwise it'll make its way to the parent's shell):
107
+ exec ($ this ->command . ' 2> /dev/null ' , $ output , $ resultCode );
108
+ if ($ output === false ) {
109
+ $ reportBack (
110
+ forkExitCode: 1 ,
111
+ error: new \RuntimeException (sprintf ('Could not run the command "%s" (PID %s) ' , $ this ->command , $ this ->pid ))
112
+ );
113
+ } else {
114
+ $ reportBack (
115
+ jobExitCode: $ resultCode ,
116
+ result: $ output
117
+ );
118
+ }
119
+ }
120
+ } catch (\Throwable $ e ) {
121
+ $ reportBack (
122
+ forkExitCode: 1 ,
123
+ error: $ e
124
+ );
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ public function getPid (): int {
131
+ return $ this ->pid ;
132
+ }
133
+
134
+ public function get (): BasePromise \PromiseInterface
135
+ {
136
+ return $ this ->promise ;
137
+ }
138
+
139
+ private static function findUnusedPort (): int {
140
+ $ tried = new Set ;
141
+ $ add = function (int $ port ) use ($ tried ) {
142
+ $ tried ->add ($ port );
143
+ return true ;
144
+ };
145
+ do {
146
+ $ port = mt_rand (1024 , 65535 );
147
+ } while ($ tried ->contains ($ port ) || (self ::isPortOpen ($ port ) && $ add ($ port )));
148
+ return $ port ;
149
+ }
150
+
151
+ private static function isPortOpen (int $ port ): bool {
152
+ $ result = false ;
153
+ try {
154
+ if ($ pf = fsockopen ('127.0.0.1 ' , $ port )) {
155
+ $ result = true ;
156
+ fclose ($ pf );
157
+ }
158
+ } catch (\ErrorException $ e ) {
159
+ if (!str_contains ($ e ->getMessage (), 'Connection refused ' )) {
160
+ throw $ e ; // Unexpected exception
161
+ }
162
+ }
163
+ return $ result ;
164
+ }
165
+
166
+ }
0 commit comments