168 lines
10 KiB
PHP
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";
|
|
}
|