|
20 | 20 |
|
21 | 21 | namespace MikoPBX\PBXCoreREST\Workers;
|
22 | 22 |
|
| 23 | +use InvalidArgumentException; |
| 24 | +use JsonException; |
23 | 25 | use MikoPBX\Common\Handlers\CriticalErrorsHandler;
|
24 | 26 | use MikoPBX\Common\Providers\BeanstalkConnectionWorkerApiProvider;
|
25 | 27 | use MikoPBX\Core\System\{BeanstalkClient, Configs\BeanstalkConf, Directories, Processes, SystemMessages};
|
|
28 | 30 | use MikoPBX\PBXCoreREST\Lib\PBXApiResult;
|
29 | 31 | use MikoPBX\PBXCoreREST\Lib\PbxExtensionsProcessor;
|
30 | 32 | use MikoPBX\PBXCoreREST\Lib\SystemManagementProcessor;
|
| 33 | +use RuntimeException; |
31 | 34 | use Throwable;
|
32 |
| - |
33 | 35 | use function xdebug_break;
|
34 | 36 |
|
35 | 37 | require_once 'Globals.php';
|
|
47 | 49 | class WorkerApiCommands extends WorkerBase
|
48 | 50 | {
|
49 | 51 | /**
|
50 |
| - * The maximum parallel worker processes |
51 |
| - * |
52 |
| - * @var int |
| 52 | + * Maximum time to wait for child process (seconds) |
53 | 53 | */
|
54 |
| - public int $maxProc = 4; |
| 54 | + private const int CHILD_PROCESS_TIMEOUT = 60; |
55 | 55 |
|
56 | 56 |
|
57 | 57 | /**
|
@@ -92,77 +92,265 @@ public function prepareAnswer(BeanstalkClient $message): void
|
92 | 92 | // Fork failed
|
93 | 93 | throw new \RuntimeException("Failed to fork a new process.");
|
94 | 94 | }
|
| 95 | + |
95 | 96 | if ($pid === 0) {
|
96 |
| - $res = new PBXApiResult(); |
97 |
| - $res->processor = __METHOD__; |
98 |
| - $async = false; |
99 | 97 | try {
|
100 |
| - $request = json_decode($message->getBody(), true, 512, JSON_THROW_ON_ERROR); |
101 |
| - $async = ($request['async'] ?? false) === true; |
102 |
| - $processor = $request['processor']; |
103 |
| - $res->processor = $processor; |
104 |
| - // Old style, we can remove it in 2025 |
105 |
| - if ($processor === 'modules') { |
106 |
| - $processor = PbxExtensionsProcessor::class; |
107 |
| - } |
| 98 | + // Child process |
| 99 | + $this->setForked(); |
| 100 | + $this->processRequest($message); |
| 101 | + } catch (Throwable $e) { |
| 102 | + CriticalErrorsHandler::handleExceptionWithSyslog($e); |
| 103 | + exit(1); // Exit with error |
| 104 | + } |
| 105 | + exit(0); |
| 106 | + } |
108 | 107 |
|
109 |
| - $this->breakpointHere($request); |
110 |
| - |
111 |
| - // This is the child process |
112 |
| - if (method_exists($processor, 'callback')) { |
113 |
| - cli_set_process_title(__CLASS__ . '-' . $request['action']); |
114 |
| - // Execute async job |
115 |
| - if ($async) { |
116 |
| - $res->success = true; |
117 |
| - $res->messages['info'][] = "The async job {$request['action']} starts in background, you will receive answer on {$request['asyncChannelId']} nchan channel"; |
118 |
| - $encodedResult = json_encode($res->getResult()); |
119 |
| - $message->reply($encodedResult); |
120 |
| - $processor::callback($request); |
121 |
| - } else { |
122 |
| - $res = $processor::callback($request); |
123 |
| - } |
124 |
| - } else { |
125 |
| - $res->success = false; |
126 |
| - $res->messages['error'][] = "Unknown processor - $processor in prepareAnswer"; |
127 |
| - } |
128 |
| - } catch (Throwable $exception) { |
129 |
| - $request = []; |
130 |
| - $this->needRestart = true; |
131 |
| - // Prepare answer with pretty error description |
132 |
| - $res->messages['error'][] = CriticalErrorsHandler::handleExceptionWithSyslog($exception); |
133 |
| - } finally { |
134 |
| - if ($async === false) { |
135 |
| - $encodedResult = json_encode($res->getResult()); |
136 |
| - if ($encodedResult === false) { |
137 |
| - $res->data = []; |
138 |
| - $res->messages['error'][] = 'It is impossible to encode to json current processor answer'; |
139 |
| - $encodedResult = json_encode($res->getResult()); |
140 |
| - } |
| 108 | + // Parent process |
| 109 | + $startTime = time(); |
| 110 | + $status = 0; |
141 | 111 |
|
142 |
| - // Check the response size and write in on file if it bigger than Beanstalk can digest |
143 |
| - if (strlen($encodedResult) > BeanstalkConf::JOB_DATA_SIZE_LIMIT) { |
144 |
| - $downloadCacheDir = Directories::getDir(Directories::WWW_DOWNLOAD_CACHE_DIR); |
145 |
| - $filenameTmp = $downloadCacheDir . '/temp-' . __FUNCTION__ . '_' . microtime() . '.data'; |
146 |
| - if (file_put_contents($filenameTmp, serialize($res->getResult()))) { |
147 |
| - $encodedResult = json_encode([BeanstalkClient::RESPONSE_IN_FILE => $filenameTmp]); |
148 |
| - } else { |
149 |
| - $res->data = []; |
150 |
| - $res->messages['error'][] = 'It is impossible to write answer into file ' . $filenameTmp; |
151 |
| - $encodedResult = json_encode($res->getResult()); |
152 |
| - } |
| 112 | + // Wait for child with timeout |
| 113 | + while (time() - $startTime < self::CHILD_PROCESS_TIMEOUT) { |
| 114 | + $res = pcntl_waitpid($pid, $status, WNOHANG); |
| 115 | + if ($res === -1) { |
| 116 | + throw new RuntimeException("Failed to wait for child process"); |
| 117 | + } |
| 118 | + if ($res > 0) { |
| 119 | + // Child process completed |
| 120 | + if (pcntl_wifexited($status)) { |
| 121 | + $exitStatus = pcntl_wexitstatus($status); |
| 122 | + if ($exitStatus !== 0) { |
| 123 | + throw new RuntimeException("Child process failed with status: $exitStatus"); |
153 | 124 | }
|
154 |
| - $message->reply($encodedResult); |
| 125 | + return; |
155 | 126 | }
|
156 |
| - if ($res->success) { |
157 |
| - $this->checkNeedReload($request); |
| 127 | + if (pcntl_wifsignaled($status)) { |
| 128 | + $signal = pcntl_wtermsig($status); |
| 129 | + throw new RuntimeException("Child process terminated by signal: $signal"); |
158 | 130 | }
|
| 131 | + return; |
| 132 | + } |
| 133 | + usleep(100000); // Sleep 100ms |
| 134 | + } |
| 135 | + |
| 136 | + // Timeout reached |
| 137 | + posix_kill($pid, SIGTERM); |
| 138 | + throw new RuntimeException("Child process timed out"); |
| 139 | + } |
| 140 | + |
| 141 | + /** |
| 142 | + * Process individual API request |
| 143 | + * |
| 144 | + * @param BeanstalkClient $message The message from beanstalk queue |
| 145 | + * |
| 146 | + * @throws JsonException If JSON parsing fails |
| 147 | + * @throws RuntimeException|Throwable If processor execution fails |
| 148 | + */ |
| 149 | + private function processRequest(BeanstalkClient $message): void |
| 150 | + { |
| 151 | + $res = new PBXApiResult(); |
| 152 | + $res->processor = __METHOD__; |
| 153 | + try { |
| 154 | + // Parse request JSON |
| 155 | + $request = $this->parseRequestJson($message); |
| 156 | + |
| 157 | + // Setup basic request parameters |
| 158 | + $async = (bool)($request['async'] ?? false); |
| 159 | + $processor = $this->resolveProcessor($request); |
| 160 | + |
| 161 | + $res->processor = $processor; |
| 162 | + |
| 163 | + // Old style, we can remove it in 2025 |
| 164 | + if ($processor === 'modules') { |
| 165 | + $processor = PbxExtensionsProcessor::class; |
| 166 | + } |
| 167 | + |
| 168 | + // Handle debug mode if needed |
| 169 | + $this->handleDebugMode($request); |
| 170 | + |
| 171 | + // Process the request |
| 172 | + if (!method_exists($processor, 'callback')) { |
| 173 | + throw new RuntimeException("Unknown processor - {$processor}"); |
| 174 | + } |
| 175 | + |
| 176 | + cli_set_process_title(__CLASS__ . '-' . $request['action']); |
| 177 | + |
| 178 | + // Execute request based on async flag |
| 179 | + if ($async) { |
| 180 | + $this->handleAsyncRequest($message, $request, $res); |
| 181 | + } else { |
| 182 | + $res = $processor::callback($request); |
| 183 | + $this->sendResponse($message, $res); |
| 184 | + } |
| 185 | + |
| 186 | + // Check if reload is needed after successful execution |
| 187 | + if ($res->success) { |
| 188 | + $this->checkNeedReload($request); |
| 189 | + } |
| 190 | + |
| 191 | + } catch (JsonException $e) { |
| 192 | + $this->handleError($res, "Invalid JSON in request: {$e->getMessage()}"); |
| 193 | + $this->sendResponse($message, $res); |
| 194 | + } catch (InvalidArgumentException $e) { |
| 195 | + $this->handleError($res, "Invalid request parameters: {$e->getMessage()}"); |
| 196 | + $this->sendResponse($message, $res); |
| 197 | + } catch (Throwable $e) { |
| 198 | + $this->handleError($res, CriticalErrorsHandler::handleExceptionWithSyslog($e)); |
| 199 | + $this->sendResponse($message, $res); |
| 200 | + throw $e; // Re-throw for parent process to handle |
| 201 | + } |
| 202 | + } |
| 203 | + |
| 204 | + /** |
| 205 | + * Parse and validate request JSON |
| 206 | + * |
| 207 | + * @param BeanstalkClient $message |
| 208 | + * @return array |
| 209 | + * @throws JsonException |
| 210 | + */ |
| 211 | + private function parseRequestJson(BeanstalkClient $message): array |
| 212 | + { |
| 213 | + $request = json_decode( |
| 214 | + $message->getBody(), |
| 215 | + true, |
| 216 | + 512, |
| 217 | + JSON_THROW_ON_ERROR |
| 218 | + ); |
| 219 | + |
| 220 | + if (!is_array($request)) { |
| 221 | + throw new InvalidArgumentException('Request must be a JSON object'); |
| 222 | + } |
| 223 | + |
| 224 | + return $request; |
| 225 | + } |
| 226 | + |
| 227 | + /** |
| 228 | + * Resolve processor class name |
| 229 | + * |
| 230 | + * @param array $request |
| 231 | + * @return string |
| 232 | + * @throws InvalidArgumentException |
| 233 | + */ |
| 234 | + private function resolveProcessor(array $request): string |
| 235 | + { |
| 236 | + $processor = $request['processor'] ?? ''; |
| 237 | + |
| 238 | + // Handle legacy 'modules' processor name |
| 239 | + if ($processor === 'modules') { |
| 240 | + return PbxExtensionsProcessor::class; |
| 241 | + } |
| 242 | + |
| 243 | + if (empty($processor)) { |
| 244 | + throw new InvalidArgumentException('Processor name is required'); |
| 245 | + } |
| 246 | + |
| 247 | + return $processor; |
| 248 | + } |
| 249 | + |
| 250 | + /** |
| 251 | + * Handle asynchronous request execution |
| 252 | + * |
| 253 | + * @param BeanstalkClient $message |
| 254 | + * @param array $request |
| 255 | + * @param PBXApiResult $res |
| 256 | + */ |
| 257 | + private function handleAsyncRequest( |
| 258 | + BeanstalkClient $message, |
| 259 | + array $request, |
| 260 | + PBXApiResult $res |
| 261 | + ): void |
| 262 | + { |
| 263 | + $res->success = true; |
| 264 | + $res->messages['info'][] = sprintf( |
| 265 | + 'The async job %s starts in background, you will receive answer on %s nchan channel', |
| 266 | + $request['action'], |
| 267 | + $request['asyncChannelId'] |
| 268 | + ); |
| 269 | + |
| 270 | + $this->sendResponse($message, $res); |
| 271 | + $request['processor']::callback($request); |
| 272 | + } |
| 273 | + |
| 274 | + /** |
| 275 | + * Send response back through beanstalk |
| 276 | + * |
| 277 | + * @param BeanstalkClient $message |
| 278 | + * @param PBXApiResult $res |
| 279 | + * @throws RuntimeException |
| 280 | + */ |
| 281 | + private function sendResponse(BeanstalkClient $message, PBXApiResult $res): void |
| 282 | + { |
| 283 | + try { |
| 284 | + $result = $res->getResult(); |
| 285 | + $encodedResult = json_encode($result); |
| 286 | + |
| 287 | + if ($encodedResult === false) { |
| 288 | + $res->data = []; |
| 289 | + $res->messages['error'][] = 'Failed to encode response to JSON'; |
| 290 | + $encodedResult = json_encode($res->getResult()); |
| 291 | + } |
| 292 | + |
| 293 | + // Handle large responses |
| 294 | + if (strlen($encodedResult) > BeanstalkConf::JOB_DATA_SIZE_LIMIT) { |
| 295 | + $encodedResult = $this->handleLargeResponse($result); |
159 | 296 | }
|
160 |
| - exit(0); // Exit the child process |
| 297 | + |
| 298 | + $message->reply($encodedResult); |
| 299 | + |
| 300 | + } catch (Throwable $e) { |
| 301 | + throw new RuntimeException( |
| 302 | + "Failed to send response: {$e->getMessage()}", |
| 303 | + 0, |
| 304 | + $e |
| 305 | + ); |
| 306 | + } |
| 307 | + } |
| 308 | + |
| 309 | + /** |
| 310 | + * Handle large response by storing in temporary file |
| 311 | + * |
| 312 | + * @param array $result |
| 313 | + * @return string JSON encoded response with file reference |
| 314 | + * @throws RuntimeException |
| 315 | + */ |
| 316 | + private function handleLargeResponse(array $result): string |
| 317 | + { |
| 318 | + $downloadCacheDir = Directories::getDir(Directories::WWW_DOWNLOAD_CACHE_DIR); |
| 319 | + |
| 320 | + // Generate unique filename using uniqid() |
| 321 | + $filenameTmp = sprintf( |
| 322 | + '%s/temp-%s_%s.data', |
| 323 | + $downloadCacheDir, |
| 324 | + __FUNCTION__, |
| 325 | + uniqid('', true) |
| 326 | + ); |
| 327 | + |
| 328 | + // Check available disk space |
| 329 | + if (disk_free_space($downloadCacheDir) < strlen(serialize($result))) { |
| 330 | + throw new RuntimeException('Insufficient disk space for temporary file'); |
| 331 | + } |
| 332 | + |
| 333 | + if (!file_put_contents($filenameTmp, serialize($result))) { |
| 334 | + throw new RuntimeException("Failed to write response to temporary file"); |
161 | 335 | }
|
162 |
| - // This is the parent process |
163 |
| - pcntl_wait($status); // Wait for the child process to complete |
| 336 | + |
| 337 | + return json_encode([BeanstalkClient::RESPONSE_IN_FILE => $filenameTmp]); |
| 338 | + } |
| 339 | + |
| 340 | + /** |
| 341 | + * Handle error cases |
| 342 | + * |
| 343 | + * @param PBXApiResult $res |
| 344 | + * @param string $message |
| 345 | + */ |
| 346 | + private function handleError(PBXApiResult $res, string $message): void |
| 347 | + { |
| 348 | + $res->success = false; |
| 349 | + $res->messages['error'][] = $message; |
| 350 | + $res->data = []; |
164 | 351 | }
|
165 | 352 |
|
| 353 | + |
166 | 354 | /**
|
167 | 355 | * Checks if the module or worker needs to be reloaded.
|
168 | 356 | *
|
@@ -230,7 +418,7 @@ private function getNeedRestartActions(): array
|
230 | 418 | * ...
|
231 | 419 | * });
|
232 | 420 | */
|
233 |
| - private function breakpointHere(array $request): void |
| 421 | + private function handleDebugMode(array $request): void |
234 | 422 | {
|
235 | 423 | if (isset($request['debug']) && $request['debug'] === true && extension_loaded('xdebug')) {
|
236 | 424 | if (function_exists('xdebug_connect_to_client')) {
|
|
0 commit comments