Extremely rare OS termination failures are never passed to blocking proc_close. */ private static array $unreaped = []; public static function read(array $images, string $allowedRoot, float $deadline, ?callable $shouldAbort, array $config): array { if (empty($config['enabled'])) return ['status' => 'disabled', 'images' => []]; $indices = self::indices($images); if ($indices === null) return self::failure([], 'invalid_request'); $fallback = self::failure($indices, 'unavailable'); $handles = []; $paths = []; $directory = ''; $process = null; $closed = false; $reuse = null; try { self::reap(); if ($shouldAbort !== null && $shouldAbort()) return self::failure($indices, 'cancelled'); $timeout = (float) ($config['timeout_seconds'] ?? 10); if (!is_finite($deadline) || !is_finite($timeout) || $timeout <= 0) return self::failure($indices, 'invalid_config'); $end = min($deadline, microtime(true) + min(60.0, $timeout)); if ($end <= microtime(true)) return self::failure($indices, 'deadline'); $threads = $config['threads'] ?? 1; $detection = $config['detection_limit'] ?? 640; $textLimit = $config['max_text_bytes'] ?? 131072; if (!in_array($threads, [1, 2], true) || !in_array($detection, [640, 736], true) || !is_int($textLimit) || $textLimit < 1 || $textLimit > 262144) return self::failure($indices, 'invalid_config'); if (!self::localPath($allowedRoot) || !self::localPath((string) ($config['python'] ?? ''))) return self::failure($indices, 'invalid_source'); $python = realpath((string) ($config['python'] ?? '')); $script = realpath(dirname(__DIR__, 3) . '/scripts/clinical_image_ocr.py'); $root = realpath($allowedRoot); if ($root === false || !is_dir($root)) return self::failure($indices, 'invalid_source'); if ($python === false || !is_file($python) || $script === false || (PHP_OS_FAMILY !== 'Windows' && !is_executable($python))) return self::failure($indices, 'runtime_unavailable'); if (!function_exists('proc_open')) return self::failure($indices, 'runtime_unavailable'); $sources = []; foreach ($images as $image) { $value = $image['path'] ?? null; if (!is_string($value) || !self::localPath($value)) return self::failure($indices, 'invalid_source'); $path = realpath($value); if ($path === false || !self::inside($path, $root) || !is_file($path)) return self::failure($indices, 'invalid_source'); $size = filesize($path); $geometry = @getimagesize($path); if ($size === false || $size < 1 || $size > self::FILE_LIMIT || $geometry === false || $geometry[0] < 1 || $geometry[1] < 1 || $geometry[0] > intdiv(self::PIXEL_LIMIT, $geometry[1])) { return self::failure($indices, 'image_limits'); } $sources[] = ['index' => $image['index'], 'path' => $path]; } if ($sources === []) return ['status' => 'completed', 'images' => []]; $reuse = ClinicalImageTextCache::acquire($root, $sources, $config, $python, $script, $end, $shouldAbort); if (isset($config['_cache_scope']) && $shouldAbort !== null && $shouldAbort()) return self::failure($indices, 'cancelled'); if ($reuse !== null) { $candidate = $reuse->cached($sources, $end, $shouldAbort); $cached = self::validateOutput($candidate, $indices, $textLimit); if ($shouldAbort !== null && $shouldAbort()) return self::failure($indices, 'cancelled'); if (microtime(true) >= $end) return self::failure($indices, 'deadline'); if ($cached !== null && array_column($candidate['images'], 'index') === $indices && ($cached['status'] === 'completed' || ($cached['reason'] ?? '') === 'unreadable_sources')) return $cached; } if (microtime(true) >= $end) return self::failure($indices, 'deadline'); $directory = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'clinical-ocr-' . bin2hex(random_bytes(16)); if (!@mkdir($directory, 0700)) return self::failure($indices, 'runtime_unavailable'); foreach (['input', 'output', 'error'] as $name) { $path = $directory . DIRECTORY_SEPARATOR . $name; $handle = @fopen($path, 'x+b'); if ($handle === false) return self::failure($indices, 'runtime_unavailable'); @chmod($path, 0600); $paths[] = $path; $handles[] = $handle; } $request = json_encode(['allowed_root' => $root, 'images' => $sources, 'deadline_ms' => max(0, (int) floor(($end - microtime(true)) * 1000)), 'threads' => $threads, 'detection_limit' => $detection, 'max_text_bytes' => $textLimit], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); if (strlen($request) > 1048576 || fwrite($handles[0], $request) !== strlen($request)) return self::failure($indices, 'invalid_request'); fflush($handles[0]); rewind($handles[0]); $environment = ['PYTHONNOUSERSITE' => '1', 'PYTHONDONTWRITEBYTECODE' => '1', 'PYTHONIOENCODING' => 'utf-8']; foreach (['SystemRoot', 'WINDIR', 'PATH', 'TEMP', 'TMP'] as $key) { $value = getenv($key); if (is_string($value)) $environment[$key] = $value; } foreach (['OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMEXPR_NUM_THREADS'] as $key) $environment[$key] = (string) $threads; $process = @proc_open([$python, $script], [0 => $handles[0], 1 => $handles[1], 2 => $handles[2]], $pipes, null, $environment, ['bypass_shell' => true, 'suppress_errors' => true]); if (!is_resource($process)) return self::failure($indices, 'spawn_failed'); $forced = ''; $exitCode = -1; while (true) { $state = proc_get_status($process); if (self::size($handles[1]) > self::OUTPUT_LIMIT || self::size($handles[2]) > self::STDERR_LIMIT) $forced = 'output_limit'; elseif ($shouldAbort !== null && $shouldAbort()) $forced = 'cancelled'; elseif (microtime(true) >= $end) $forced = 'deadline'; if (!$state['running']) { $exitCode = (int) $state['exitcode']; break; } if ($forced !== '') break; usleep(50000); } if ($forced !== '') { $closed = self::terminate($process); if (!$closed) { self::$unreaped[] = $process; $process = null; return self::failure($indices, 'termination_failed'); } } else { // This call only reaps a child already observed stopped; never wait on a live native inference. @proc_close($process); $closed = true; } if ($forced === 'output_limit') return self::failure($indices, 'output_limit'); if ($forced === '' && $exitCode !== 0) return self::failure($indices, 'process_failed'); if (self::size($handles[1]) > self::OUTPUT_LIMIT) return self::failure($indices, 'output_limit'); rewind($handles[1]); $raw = stream_get_contents($handles[1], self::OUTPUT_LIMIT + 1); try { $data = is_string($raw) ? json_decode($raw, true, 64, JSON_THROW_ON_ERROR) : null; } catch (\JsonException $error) { return self::failure($indices, $forced !== '' ? $forced : 'invalid_output'); } $validated = self::validateOutput($data, $indices, $textLimit); if ($validated === null) return self::failure($indices, $forced !== '' ? $forced : 'invalid_output'); if ($forced !== '') { $validated['status'] = count(array_filter($validated['images'], static fn (array $row): bool => $row['status'] === 'processed')) > 0 ? 'partial' : 'unavailable'; $validated['reason'] = $forced; } if ($forced === '' && self::cacheableRecognition($data, $indices)) $reuse?->remember($validated, $sources, $end, $shouldAbort); return $validated; } catch (\Throwable $error) { return $fallback; } finally { $reuse?->close(); if (is_resource($process) && !$closed && !self::terminate($process)) self::$unreaped[] = $process; foreach ($handles as $handle) if (is_resource($handle)) fclose($handle); // Windows may defer inherited handle release briefly after termination. $cleanupEnd = microtime(true) + 0.25; do { foreach ($paths as $path) @unlink($path); if ($directory === '' || @rmdir($directory) || !is_dir($directory)) break; usleep(10000); } while (microtime(true) < $cleanupEnd); } } private static function indices(array $images): ?array { if (count($images) > 256 || !array_is_list($images)) return null; $result = []; foreach ($images as $image) { if (!is_array($image) || !is_int($image['index'] ?? null) || $image['index'] < 1 || $image['index'] > 1000000000 || isset($result[$image['index']])) return null; $result[$image['index']] = true; } return array_keys($result); } private static function inside(string $path, string $root): bool { $prefix = rtrim($root, '/\\') . DIRECTORY_SEPARATOR; return PHP_OS_FAMILY === 'Windows' ? str_starts_with(strtolower($path), strtolower($prefix)) : str_starts_with($path, $prefix); } private static function localPath(string $path): bool { return $path !== '' && !str_contains($path, "\0") && !str_contains($path, '://') && !str_starts_with($path, '//') && !str_starts_with($path, '\\\\') && preg_match('~^(?:[A-Za-z]:[\\\\/]|/)~', $path) === 1; } private static function failure(array $indices, string $reason): array { return ['status' => 'unavailable', 'reason' => $reason, 'images' => array_map(static fn (int $index): array => ['index' => $index, 'status' => 'unreadable', 'lines' => []], $indices)]; } private static function size($handle): int { return (int) ((fstat($handle) ?: [])['size'] ?? 0); } private static function terminate($process): bool { @proc_terminate($process, 9); $stop = microtime(true) + 0.5; do { $state = @proc_get_status($process); if (is_array($state) && !$state['running']) { @proc_close($process); return true; } usleep(10000); } while (microtime(true) < $stop); return false; } private static function reap(): void { foreach (self::$unreaped as $key => $process) { $state = @proc_get_status($process); if (is_array($state) && !$state['running']) { @proc_close($process); unset(self::$unreaped[$key]); } else @proc_terminate($process, 9); } } /** The worker's aggregate partial/unavailable status also covers a normal image with no text. * Check raw per-row terminal reasons before validation discards them; incomplete/error rows * must never become reusable just because the subprocess itself exited successfully. */ private static function cacheableRecognition(array $data, array $indices): bool { if (array_column($data['images'], 'index') !== $indices) return false; $processed = 0; foreach ($data['images'] as $row) { if ($row['status'] === 'processed' && !isset($row['reason'])) { $processed++; continue; } if ($row['status'] !== 'unreadable' || ($row['reason'] ?? '') !== 'no_text') return false; } $status = $processed === count($indices) ? 'completed' : ($processed > 0 ? 'partial' : 'unavailable'); return $data['status'] === $status && ($status === 'completed' ? !isset($data['reason']) : ($data['reason'] ?? '') === 'unreadable_sources'); } private static function validateOutput($data, array $indices, int $textLimit): ?array { if (!is_array($data) || !in_array($data['status'] ?? '', ['completed', 'partial', 'unavailable'], true) || !is_array($data['images'] ?? null) || !array_is_list($data['images'])) return null; $byIndex = []; $used = 0; foreach ($data['images'] as $row) { $index = $row['index'] ?? null; if (!is_int($index) || !in_array($index, $indices, true) || isset($byIndex[$index]) || !in_array($row['status'] ?? '', ['processed', 'unreadable'], true) || !is_array($row['lines'] ?? null) || !array_is_list($row['lines']) || count($row['lines']) > 1024) return null; $lines = []; foreach ($row['lines'] as $line) { $text = $line['text'] ?? null; $confidence = $line['confidence'] ?? null; $box = $line['box'] ?? null; if (!is_string($text) || trim($text) === '' || preg_match('//u', $text) !== 1 || preg_match('/[\x00-\x08\x0B-\x1F]/', $text) || strlen($text) > 8192 || (!is_float($confidence) && !is_int($confidence)) || !is_finite((float) $confidence) || $confidence < 0 || $confidence > 1 || !is_array($box) || !array_is_list($box) || count($box) !== 4) return null; foreach ($box as $point) { if (!is_array($point) || !array_is_list($point) || count($point) !== 2) return null; foreach ($point as $coordinate) if ((!is_int($coordinate) && !is_float($coordinate)) || !is_finite((float) $coordinate) || abs($coordinate) > 1000000) return null; } $used += strlen($text); if ($used > $textLimit) return null; $lines[] = ['text' => $text, 'confidence' => (float) $confidence, 'box' => $box]; } if (($row['status'] === 'unreadable' && $lines !== []) || ($row['status'] === 'processed' && $lines === [])) return null; $byIndex[$index] = ['index' => $index, 'status' => $row['status'], 'lines' => $lines]; } // Missing checkpoint rows are explicit unreadable sources; never infer success from overall status. $rows = []; foreach ($indices as $index) $rows[] = $byIndex[$index] ?? ['index' => $index, 'status' => 'unreadable', 'lines' => []]; $processed = count(array_filter($rows, static fn (array $row): bool => $row['status'] === 'processed')); $status = $processed === count($rows) && $data['status'] === 'completed' ? 'completed' : ($processed > 0 ? 'partial' : 'unavailable'); $result = ['status' => $status, 'images' => $rows]; if ($status !== 'completed') { $reason = $data['reason'] ?? ''; $result['reason'] = in_array($reason, ['invalid_request', 'invalid_source', 'dependency_unavailable', 'model_unavailable', 'deadline', 'unreadable_sources', 'not_processed'], true) ? $reason : 'unreadable_sources'; } return $result; } }