Files
zyt/server/tests/DiagnosisAiAssistantTimeoutCleanupTest.php
2026-10-27 14:03:35 +08:00

227 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service {
function config(string $name): array { return $GLOBALS['timeoutCleanupConfig']; }
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\AssistantRequestControl;
use app\common\service\DifyChatService;
function timeoutExpect(bool $ok, string $message): void {
if (!$ok) throw new RuntimeException($message);
}
// A real local socket peer keeps generation and stop connections independent.
// No provider, database, production configuration or patient data is used.
if (($argv[1] ?? '') === 'server') {
$scenario = $argv[2];
$listener = stream_socket_server('tcp://127.0.0.1:0', $error, $message);
if ($listener === false) exit(2);
stream_set_blocking($listener, false);
echo json_encode(['address' => 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);
}
}