stream_socket_get_name($listener, false)]) . "\n"; flush(); $clients = []; $generation = null; $started = null; $terminalAt = null; $taskSent = false; $naturalSent = false; $done = false; $metrics = ['stops' => [], 'open_on_stop' => true, 'generation_eof' => false, 'requests' => 0, 'terminal_sent' => false, 'user_matches' => true]; $deadline = microtime(true) + 8; while (!$done && microtime(true) < $deadline) { while (($socket = @stream_socket_accept($listener, 0)) !== false) { stream_set_blocking($socket, false); $clients[(int) $socket] = ['socket' => $socket, 'buffer' => '', 'handled' => false]; } foreach (array_keys($clients) as $key) { $socket = $clients[$key]['socket']; $chunk = @fread($socket, 65536); if ($chunk === '' && feof($socket)) { if ($key === $generation) { $metrics['generation_eof'] = true; $done = true; } fclose($socket); unset($clients[$key]); continue; } if ($clients[$key]['handled']) continue; $clients[$key]['buffer'] .= is_string($chunk) ? $chunk : ''; $parts = explode("\r\n\r\n", $clients[$key]['buffer'], 2); if (count($parts) !== 2) continue; preg_match('/Content-Length:\s*(\d+)/i', $parts[0], $length); if (strlen($parts[1]) < (int) ($length[1] ?? 0)) continue; $clients[$key]['handled'] = true; $payload = json_decode($parts[1], true); if (preg_match('#^POST /v1/chat-messages/([^/ ]+)/stop #', $parts[0], $match)) { $metrics['stops'][] = $match[1]; $metrics['open_on_stop'] = $metrics['open_on_stop'] && isset($clients[$generation]); $metrics['user_matches'] = $metrics['user_matches'] && ($payload['user'] ?? '') === 'timeout-original-user'; $body = $scenario === 'refused' ? '{"result":"failed"}' : '{"result":"success"}'; fwrite($socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\nConnection: close\r\n\r\n" . $body); fclose($socket); unset($clients[$key]); if ($scenario === 'refused') { // Refusal plus EOF must retain the task for a later bounded retry. fclose($clients[$generation]['socket']); unset($clients[$generation]); $done = true; } elseif ($scenario !== 'no_terminal') { $terminalAt = microtime(true) + ($scenario === 'cancel_race' ? 0.20 : 0.06); } continue; } ++$metrics['requests']; $generation = $key; $started = microtime(true); $metrics['stream_mode'] = ($payload['response_mode'] ?? '') === 'streaming'; if ($scenario !== 'silent_headers') { fwrite($socket, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"); } if (!in_array($scenario, ['silent_headers', 'silent_body', 'late_metadata', 'openai'], true)) { fwrite($socket, "data: {\"event\":\"workflow_started\",\"task_id\":\"timeout-original-task\"}\n\n"); $taskSent = true; } if (in_array($scenario, ['partial', 'foreign_metadata', 'natural_terminal'], true)) { fwrite($socket, "data: {\"event\":\"message\",\"task_id\":\"timeout-original-task\",\"answer\":\"before-deadline\"}\n\n"); } if ($scenario === 'foreign_metadata') { fwrite($socket, "data: {\"event\":\"workflow_started\",\"task_id\":\"foreign-task-must-not-stop\"}\n\n"); } } if ($generation !== null && isset($clients[$generation])) { $socket = $clients[$generation]['socket']; if ($scenario === 'late_metadata' && !$taskSent && microtime(true) - $started >= 1.12) { fwrite($socket, "data: {\"event\":\"message\",\"answer\":\"late-before-task\"}\n\n" . "data: {\"event\":\"workflow_started\",\"task_id\":\"timeout-original-task\"}\n\n"); $taskSent = true; } if ($scenario === 'natural_terminal' && !$naturalSent && microtime(true) - $started >= 0.85) { fwrite($socket, "data: {\"event\":\"workflow_finished\",\"task_id\":\"timeout-original-task\",\"data\":{\"status\":\"succeeded\"}}\n\n" . "data: {\"event\":\"message_end\"}\n\n"); $metrics['terminal_sent'] = true; $naturalSent = true; } if ($terminalAt !== null && microtime(true) >= $terminalAt) { fwrite($socket, "data: {\"event\":\"message\",\"task_id\":\"timeout-original-task\",\"answer\":\"late-after-timeout\"}\n\n" . "data: {\"event\":\"workflow_finished\",\"task_id\":\"timeout-original-task\",\"data\":{\"status\":\"stopped\"}}\n\n" . "data: {\"event\":\"message_end\"}\n\n"); $metrics['terminal_sent'] = true; fclose($socket); unset($clients[$generation]); $done = true; } } usleep(1000); } foreach ($clients as $client) fclose($client['socket']); fclose($listener); echo json_encode($metrics) . "\n"; exit($done ? 0 : 3); } $directory = sys_get_temp_dir() . '/zyt-timeout-cleanup-' . bin2hex(random_bytes(8)); $rows = []; try { foreach (['known_no_answer', 'partial', 'late_metadata', 'no_terminal', 'refused', 'silent_headers', 'silent_body', 'foreign_metadata', 'natural_terminal', 'cancel_race', 'observer_error', 'openai', 'legacy_timeout'] as $index => $scenario) { $process = proc_open([PHP_BINARY, __FILE__, 'server', $scenario], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); timeoutExpect(is_resource($process), 'local peer starts'); fclose($pipes[0]); stream_set_timeout($pipes[1], 10); $ready = json_decode((string) fgets($pipes[1]), true); timeoutExpect(isset($ready['address']), 'local peer returns its address'); $timeoutCleanupConfig = ['enable' => true, 'base_url' => 'http://' . $ready['address'] . '/v1/' . ($scenario === 'openai' ? 'chat/completions' : 'chat-messages'), 'timeout' => 1, 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'loopback-only']]]; $requestId = sprintf('abcdefab-1234-4234-8234-%012d', $index); $control = new AssistantRequestControl(7, 11, $requestId, $directory); timeoutExpect($control->begin(), 'fresh local control begins'); $started = hrtime(true) / 1e9; $deadlineObserved = null; $deltas = []; $terminals = []; $abort = static function () use ($control, $scenario, $started): bool { if ($scenario === 'cancel_race' && hrtime(true) / 1e9 - $started >= 1.06) $control->signalCancel(); return $control->shouldAbort(); }; $options = ['knowledge_mode' => 'evidence', 'on_task' => static fn (string $taskId) => $control->bindTask('qwen', 'timeout-original-user', $taskId), 'on_timeout' => static function () use ($control, $scenario, &$deadlineObserved, $started): void { $deadlineObserved = (hrtime(true) / 1e9 - $started) * 1000; $control->signalTimeout(); if ($scenario === 'observer_error') throw new RuntimeException('synthetic observer failure'); }, 'on_cancel_task' => static function (string $taskId, callable $stop, string $reason) use ($control): array { if ($reason === 'timeout') $control->signalTimeout(); else $control->signalCancel(); return $control->stopBoundTask($stop, $taskId); }, 'on_terminal' => static function (string $taskId, string $status) use ($control, &$terminals): void { $terminals[] = $status; $control->markTerminal($taskId, $status); }, 'on_stop' => static fn (bool $accepted, string $taskId) => $control->markStop($accepted, $taskId), 'on_transfer_end' => static fn (string $taskId) => $control->clearTask($taskId !== '' ? $taskId : null), ]; $result = DifyChatService::streamChat('qwen', [], '合成超时测试,无真实患者', 'timeout-original-user', static function (string $text) use (&$deltas): bool { $deltas[] = $text; return true; }, $scenario === 'legacy_timeout' ? null : $abort, [], $options); $elapsed = (hrtime(true) / 1e9 - $started) * 1000; $control->finish($scenario === 'natural_terminal' ? 'done' : 'error'); $state = $control->publicState(); $path = $directory . '/' . hash('sha256', '7:11:' . $requestId) . '.json'; $private = json_decode((string) file_get_contents($path), true); $peer = json_decode((string) fgets($pipes[1]), true); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); timeoutExpect(proc_close($process) === 0 && $stderr === '', $scenario . ': local peer exits cleanly'); timeoutExpect($peer['requests'] === 1, $scenario . ': no extra generation or alternate channel'); timeoutExpect($elapsed < 3750, $scenario . ': deadline plus cleanup remains bounded'); if ($scenario === 'natural_terminal') { timeoutExpect(!empty($result['ok']) && $peer['stops'] === [] && $deadlineObserved === null, 'natural terminal before deadline remains successful without a stop'); timeoutExpect($state['upstream_terminal_status'] === 'succeeded' && !isset($private['upstream']), 'a real successful workflow terminal releases its identity'); } elseif ($scenario === 'cancel_race') { timeoutExpect(($result['error_code'] ?? '') === 'AI_ASSISTANT_CANCELLED' && $state['cancel_requested'], 'actual user cancellation during timeout cleanup keeps cancellation semantics'); timeoutExpect(count($peer['stops']) === 1, 'timeout/cancel race shares one accepted stop'); } else { timeoutExpect(($result['error_code'] ?? '') === 'UPSTREAM_TIMEOUT' && !$state['cancel_requested'] && $state['status'] === 'error' && $state['timeout_reached'], $scenario . ': ordinary timeout never becomes a user cancellation'); timeoutExpect($deadlineObserved >= 950 && $deadlineObserved < 1250, $scenario . ': cleanup starts at the configured generation deadline'); if (in_array($scenario, ['silent_headers', 'silent_body', 'openai'], true)) { timeoutExpect($peer['stops'] === [] && !$state['upstream_stop_confirmed'] && !$state['upstream_terminal_confirmed'], 'unknown task / compatible protocol never invents a Dify stop'); timeoutExpect($elapsed < ($scenario === 'openai' ? 1250 : 3250), 'unknown-task cleanup uses its shorter bound'); } else { timeoutExpect($peer['stops'] === ['timeout-original-task'] && $peer['open_on_stop'] && $peer['user_matches'], $scenario . ': exactly the original task and original user stop before transport closes'); timeoutExpect($peer['stream_mode'], 'Dify generation retains the original streaming channel'); if (in_array($scenario, ['no_terminal', 'refused', 'observer_error', 'legacy_timeout'], true)) { timeoutExpect(!$state['upstream_terminal_confirmed'] && isset($private['upstream']) && $private['upstream']['task_id'] === 'timeout-original-task', 'unconfirmed terminal retains the exact task metadata after transport finish'); timeoutExpect($state['upstream_stop_confirmed'] === ($scenario !== 'refused'), 'stop acceptance and actual terminal remain separate facts'); } else { timeoutExpect($terminals === ['stopped'] && $state['upstream_terminal_confirmed'] && !isset($private['upstream']), 'real terminal metadata releases the timed-out task'); } } } timeoutExpect($deltas === (in_array($scenario, ['partial', 'foreign_metadata', 'natural_terminal'], true) ? ['before-deadline'] : []), $scenario . ': all post-deadline text is discarded'); if ($scenario === 'refused') { usleep(260000); $attempts = 0; $control->stopBoundTask(static function (array $task) use (&$attempts): bool { ++$attempts; timeoutExpect($task['profile'] === 'qwen' && $task['user'] === 'timeout-original-user' && $task['task_id'] === 'timeout-original-task', 'compensation preserves original identity'); return true; }); $control->stopBoundTask(static function () use (&$attempts): bool { ++$attempts; return true; }); timeoutExpect($attempts === 1 && $control->publicState()['upstream_stop_confirmed'] && !$control->shouldAbort(), 'one bounded compensation is retained without cancelling the timed-out request'); } $rows[] = ['scenario' => $scenario, 'deadline_ms' => $deadlineObserved === null ? null : round($deadlineObserved, 1), 'elapsed_ms' => round($elapsed, 1), 'stops' => count($peer['stops'])]; } echo 'Diagnosis AI timeout cleanup / task retention / terminal distinction / local sockets: OK ' . json_encode($rows) . "\n"; } finally { foreach (glob($directory . '/*') ?: [] as $path) unlink($path); if (is_file($directory . '/.gc.lock')) unlink($directory . '/.gc.lock'); if (is_dir($directory)) rmdir($directory); } }