227 lines
15 KiB
PHP
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);
|
|
}
|
|
}
|