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

168 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
// Deterministic in-process transport: none of these functions opens a socket.
function hrtime(bool $asNumber = false): int|array {
$nanoseconds = (int) ($GLOBALS['stopTimingNow'] * 1000000);
return $asNumber ? $nanoseconds : [intdiv($nanoseconds, 1000000000), $nanoseconds % 1000000000];
}
function curl_init(): \stdClass { return (object) ['errno' => 0]; }
function curl_setopt_array(\stdClass $handle, array $options): bool {
$handle->options = $options; $handle->url = $options[CURLOPT_URL]; return true;
}
function curl_exec(\stdClass $handle): string|bool {
if (str_ends_with($handle->url, '/stop')) {
\stopTimingExpect(!$GLOBALS['stopTimingClosed'], 'actual stop I/O precedes generation handle close');
++$GLOBALS['stopTimingIoCount'];
$GLOBALS['stopTimingNow'] += 7;
return $GLOBALS['stopTimingMode'] === 'rejected_stop' ? '{"result":"failed"}' : '{"result":"success"}';
}
$header = $handle->options[CURLOPT_HEADERFUNCTION];
$write = $handle->options[CURLOPT_WRITEFUNCTION];
$progressOption = defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION;
$progress = $handle->options[$progressOption];
$GLOBALS['stopTimingNow'] += 5;
$header($handle, "HTTP/1.1 200 OK\r\n");
$GLOBALS['stopTimingNow'] += 5;
if ($write($handle, "data: {\"event\":\"workflow_started\",\"task_id\":\"private-numeric-task\"}\n\n") === 0) {
$handle->errno = CURLE_WRITE_ERROR; return false;
}
if ($GLOBALS['stopTimingMode'] === 'finished_deadline') {
$GLOBALS['stopTimingNow'] += 10;
$write($handle, "data: {\"event\":\"message_end\"}\n\n");
$GLOBALS['stopTimingNow'] += 5000;
\stopTimingExpect($progress($handle, 0, 0, 1, 1) !== 0, 'completed message still wins its deadline race');
$handle->errno = CURLE_ABORTED_BY_CALLBACK; return false;
}
if ($GLOBALS['stopTimingMode'] === 'timeout') {
$GLOBALS['stopTimingNow'] += 10;
$handle->errno = CURLE_OPERATION_TIMEDOUT; return false;
}
if ($GLOBALS['stopTimingMode'] === 'validation') {
$GLOBALS['stopTimingNow'] += 10;
$write($handle, "data: {\"event\":\"message\",\"answer\":\"private-clinical-content\"}\n\n");
} else {
$GLOBALS['stopTimingCancelled'] = true;
}
for ($index = 0; $index < 4; ++$index) {
$GLOBALS['stopTimingNow'] += 10;
if ($progress($handle, 0, 0, 1, 1) !== 0) {
$handle->errno = CURLE_ABORTED_BY_CALLBACK; return false;
}
}
if ($GLOBALS['stopTimingMode'] === 'local_abort') {
$GLOBALS['stopTimingNow'] += 2600;
\stopTimingExpect($progress($handle, 0, 0, 1, 1) !== 0, 'original absolute drain budget still aborts');
$handle->errno = CURLE_ABORTED_BY_CALLBACK; return false;
}
$GLOBALS['stopTimingNow'] += 10;
$write($handle, "data: {\"event\":\"workflow_finished\",\"task_id\":\"private-numeric-task\",\"data\":{\"status\":\"cancelled\",\"error\":\"private-provider-error\"}}\n\n");
$GLOBALS['stopTimingNow'] += 10;
$write($handle, "data: {\"event\":\"message_end\"}\n\n");
$GLOBALS['stopTimingNow'] += 10;
return true;
}
function curl_errno(\stdClass $handle): int { return $handle->errno; }
function curl_getinfo(\stdClass $handle, int $option): int|float {
return $option === CURLINFO_HTTP_CODE ? 200 : 0.001;
}
function curl_close(\stdClass $handle): void {
if (!str_ends_with($handle->url, '/stop')) {
$GLOBALS['stopTimingClosed'] = true; ++$GLOBALS['stopTimingNow'];
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\DifyChatService;
use app\common\service\DifyStreamTiming;
function stopTimingExpect(bool $ok, string $message): void {
if (!$ok) throw new RuntimeException($message);
}
$send = (new ReflectionClass(DifyChatService::class))->getMethod('sendStreamRequest');
$run = static function (string $mode) use ($send): array {
$GLOBALS['stopTimingNow'] = 1000;
$GLOBALS['stopTimingMode'] = $mode;
$GLOBALS['stopTimingCancelled'] = false;
$GLOBALS['stopTimingClosed'] = false;
$GLOBALS['stopTimingIoCount'] = 0;
$snapshot = []; $coordinatorCalls = 0; $accepted = false; $delivered = []; $terminals = [];
$result = $send->invoke(null, 'dify', 'https://private-numeric.invalid/chat-messages',
['user' => 'private-numeric-user', 'query' => 'private-clinical-prompt'], 'private-api-key', 5,
static function (string $delta) use (&$delivered, $mode): bool {
if ($mode === 'validation') { $GLOBALS['stopTimingCancelled'] = true; return false; }
$delivered[] = $delta; return true;
}, static fn (): bool => $GLOBALS['stopTimingCancelled'], [
'on_task' => static function () use ($mode): void {
if ($mode === 'callback_error') throw new RuntimeException('private-observer-error');
},
'on_cancel_task' => static function (string $task, callable $stop) use (&$coordinatorCalls, &$accepted, $mode): array {
++$coordinatorCalls;
if ($mode === 'peer_accepted') return ['accepted' => true];
// Pending lease polls must not become fictitious HTTP stop attempts.
if ($coordinatorCalls < 3 && $mode !== 'callback_error' && $mode !== 'timeout') return ['accepted' => false];
if (!$accepted && $GLOBALS['stopTimingIoCount'] === 0) $accepted = $stop();
return ['accepted' => $accepted];
},
'on_terminal' => static function (string $task, string $status) use (&$terminals): void { $terminals[] = $status; },
'on_upstream_timing' => static function (DifyStreamTiming $timing) use (&$snapshot): void { $snapshot = $timing->snapshot(); },
]);
$encoded = json_encode($snapshot);
stopTimingExpect($GLOBALS['stopTimingClosed'] && isset($snapshot['transport_closed_ms']), 'timing is exported after handle close');
stopTimingExpect(!str_contains($encoded, 'private-'), 'transport telemetry leaks no task, URL, clinical, key or observer data');
return compact('result', 'snapshot', 'coordinatorCalls', 'delivered', 'terminals');
};
$test = $run('cancelled'); $timing = $test['snapshot'];
stopTimingExpect($test['coordinatorCalls'] > 3 && $GLOBALS['stopTimingIoCount'] === 1
&& $timing['stop_io_attempt_count'] === 1 && $timing['stop_io_completed_count'] === 1
&& $timing['stop_io_accepted_count'] === 1, 'only actual performStop I/O increments counters');
stopTimingExpect($timing['interruption_trigger'] === 'abort_signal' && $timing['stop_accepted_source'] === 'local_io'
&& $timing['workflow_terminal_status'] === 'stopped' && $test['terminals'] === ['stopped'], 'real abort and terminal gates expose fixed safe enums');
$sequence = array_map(static fn (string $key): int => $timing[$key], ['interruption_detected_ms', 'stop_io_begin_ms',
'stop_io_end_ms', 'stop_accepted_ms', 'workflow_terminal_ms', 'natural_eof_ms', 'transport_closed_ms']);
$ordered = $sequence; sort($ordered);
stopTimingExpect($sequence === $ordered && $timing['stop_io_end_ms'] - $timing['stop_io_begin_ms'] === 7
&& $timing['transport_exit'] === 'natural_eof' && $test['delivered'] === [], 'stop I/O, accepted, terminal, natural EOF and close stay distinct');
$test = $run('peer_accepted'); $timing = $test['snapshot'];
stopTimingExpect($GLOBALS['stopTimingIoCount'] === 0 && !isset($timing['stop_io_attempt_count'])
&& $timing['stop_accepted_source'] === 'coordinator' && isset($timing['stop_accepted_ms']), 'peer acceptance never fabricates local stop I/O');
$test = $run('rejected_stop'); $timing = $test['snapshot'];
stopTimingExpect($timing['stop_io_attempt_count'] === 1 && $timing['stop_io_completed_count'] === 1
&& !isset($timing['stop_accepted_ms']) && !isset($timing['stop_io_accepted_count']), 'failed stop response is completed I/O without accepted proof');
$test = $run('local_abort'); $timing = $test['snapshot'];
stopTimingExpect($timing['transport_exit'] === 'local_abort' && !isset($timing['natural_eof_ms']) && !isset($timing['workflow_terminal_ms'])
&& $timing['stop_io_attempt_count'] === 1 && $test['result']['errno'] === CURLE_ABORTED_BY_CALLBACK,
'budget exit closes locally without inventing natural EOF or workflow terminal');
$test = $run('validation');
stopTimingExpect($test['snapshot']['interruption_trigger'] === 'abort_signal' && $test['delivered'] === []
&& $test['result']['client_aborted'] && !$test['result']['callback_error'], 'validation-driven abort is labelled as signal, not doctor cancellation');
$test = $run('callback_error');
stopTimingExpect($test['snapshot']['interruption_trigger'] === 'callback_error' && $test['result']['callback_error']
&& $test['snapshot']['transport_exit'] === 'local_abort' && $test['delivered'] === []
&& $GLOBALS['stopTimingIoCount'] === 1, 'observer exception remains fail-closed and stops the known task');
$test = $run('timeout');
stopTimingExpect($test['snapshot']['interruption_trigger'] === 'timeout' && $test['result']['timed_out']
&& $test['snapshot']['transport_exit'] === 'timeout' && !isset($test['snapshot']['natural_eof_ms'])
&& $GLOBALS['stopTimingIoCount'] === 1, 'hard cURL timeout preserves timeout semantics and real cleanup timing');
$test = $run('finished_deadline');
stopTimingExpect($test['result']['finished'] && $test['result']['errno'] === 0 && !$test['result']['timed_out']
&& $test['snapshot']['transport_exit'] === 'local_abort' && !isset($test['snapshot']['natural_eof_ms'])
&& !isset($test['snapshot']['interruption_detected_ms']) && $GLOBALS['stopTimingIoCount'] === 0,
'deadline success normalization never fabricates natural EOF or stop I/O');
echo "Offline actual stop I/O / peer lease / terminal gate / EOF vs local abort timing: OK\n";
}