更新
This commit is contained in:
@@ -18,6 +18,56 @@ namespace app\common\service {
|
||||
return [['ip' => '198.18.0.1']];
|
||||
}
|
||||
|
||||
/** Weak keys observe lifetime without keeping a connection pool alive in the test. */
|
||||
function attachmentTransportId(object $handle): int
|
||||
{
|
||||
$handles = $GLOBALS['attachmentTransportHandles'];
|
||||
if (!isset($handles[$handle])) $handles[$handle] = ++$GLOBALS['attachmentTransportSequence'];
|
||||
return $handles[$handle];
|
||||
}
|
||||
|
||||
function curl_init()
|
||||
{
|
||||
$handle = \curl_init();
|
||||
if ($handle !== false) $GLOBALS['attachmentTransportEvents'][] = ['kind' => 'easy_init', 'easy' => attachmentTransportId($handle)];
|
||||
return $handle;
|
||||
}
|
||||
|
||||
function curl_close($handle): void
|
||||
{
|
||||
$GLOBALS['attachmentTransportEvents'][] = ['kind' => 'easy_close', 'easy' => attachmentTransportId($handle)];
|
||||
\curl_close($handle);
|
||||
}
|
||||
|
||||
function curl_multi_init()
|
||||
{
|
||||
$multi = \curl_multi_init();
|
||||
if ($multi !== false) $GLOBALS['attachmentTransportEvents'][] = ['kind' => 'pool_init', 'pool' => attachmentTransportId($multi)];
|
||||
return $multi;
|
||||
}
|
||||
|
||||
function curl_multi_add_handle($multi, $handle): int
|
||||
{
|
||||
$result = !empty($GLOBALS['attachmentFailMultiAdd']) ? CURLM_BAD_HANDLE : \curl_multi_add_handle($multi, $handle);
|
||||
$GLOBALS['attachmentTransportEvents'][] = ['kind' => 'add', 'pool' => attachmentTransportId($multi),
|
||||
'easy' => attachmentTransportId($handle), 'result' => $result];
|
||||
return $result;
|
||||
}
|
||||
|
||||
function curl_multi_remove_handle($multi, $handle): int
|
||||
{
|
||||
$result = \curl_multi_remove_handle($multi, $handle);
|
||||
$GLOBALS['attachmentTransportEvents'][] = ['kind' => 'remove', 'pool' => attachmentTransportId($multi),
|
||||
'easy' => attachmentTransportId($handle), 'result' => $result];
|
||||
return $result;
|
||||
}
|
||||
|
||||
function curl_multi_close($multi): void
|
||||
{
|
||||
$GLOBALS['attachmentTransportEvents'][] = ['kind' => 'pool_close', 'pool' => attachmentTransportId($multi)];
|
||||
\curl_multi_close($multi);
|
||||
}
|
||||
|
||||
/** Route synthetic public storage to loopback AFTER recording real security options. */
|
||||
function curl_setopt_array($handle, array $options): bool
|
||||
{
|
||||
@@ -41,6 +91,58 @@ namespace {
|
||||
return $GLOBALS['attachmentSyntheticEnv'][$name] ?? $default;
|
||||
}
|
||||
|
||||
// PHP's built-in HTTP server closes connections. This second fixture keeps a
|
||||
// real HTTP/1.1 socket alive so tests verify libcurl reuse, not just pool counts.
|
||||
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === '--keepalive-fixture') {
|
||||
$fixtureRoot = (string) getenv('DIFY_ATTACHMENT_FIXTURE_ROOT');
|
||||
$fixturePort = (int) ($argv[2] ?? 0);
|
||||
$server = stream_socket_server('tcp://127.0.0.1:' . $fixturePort, $errno, $error);
|
||||
if (!is_resource($server)) exit(2);
|
||||
$connection = 0;
|
||||
while (true) {
|
||||
$client = @stream_socket_accept($server, 10);
|
||||
if (!is_resource($client)) continue;
|
||||
++$connection;
|
||||
stream_set_timeout($client, 3);
|
||||
while (!feof($client)) {
|
||||
$requestLine = fgets($client);
|
||||
if ($requestLine === false || trim($requestLine) === '') break;
|
||||
$headers = [];
|
||||
while (($line = fgets($client)) !== false && trim($line) !== '') {
|
||||
$split = strpos($line, ':');
|
||||
if ($split !== false) $headers[strtolower(substr($line, 0, $split))] = trim(substr($line, $split + 1));
|
||||
}
|
||||
$length = (int) ($headers['content-length'] ?? 0);
|
||||
if ($length < 0 || $length > 1024 * 1024 || isset($headers['transfer-encoding'])) break;
|
||||
$received = 0;
|
||||
while ($received < $length && !feof($client)) {
|
||||
$chunk = fread($client, min(65536, $length - $received));
|
||||
if ($chunk === false || $chunk === '') break;
|
||||
$received += strlen($chunk);
|
||||
}
|
||||
if ($received !== $length) break;
|
||||
$upload = str_starts_with($requestLine, 'POST ');
|
||||
file_put_contents($fixtureRoot . '/keepalive-requests.jsonl', json_encode([
|
||||
'kind' => $upload ? 'upload' : 'download', 'connection' => $connection,
|
||||
'authorization' => $headers['authorization'] ?? '',
|
||||
]) . "\n", FILE_APPEND | LOCK_EX);
|
||||
$body = $upload ? json_encode(['id' => bin2hex(random_bytes(16))])
|
||||
: file_get_contents($fixtureRoot . '/public/uploads/first.png');
|
||||
$response = "HTTP/1.1 " . ($upload ? '201 Created' : '200 OK') . "\r\n"
|
||||
. 'Content-Type: ' . ($upload ? 'application/json' : 'image/png') . "\r\n"
|
||||
. 'Content-Length: ' . strlen($body) . "\r\nConnection: keep-alive\r\n\r\n" . $body;
|
||||
$sent = 0;
|
||||
while ($sent < strlen($response)) {
|
||||
$written = fwrite($client, substr($response, $sent));
|
||||
if ($written === false || $written === 0) break;
|
||||
$sent += $written;
|
||||
}
|
||||
if ($sent !== strlen($response)) break;
|
||||
}
|
||||
fclose($client);
|
||||
}
|
||||
}
|
||||
|
||||
// This same file is a loopback-only fixture router. It never loads application config.
|
||||
if (PHP_SAPI === 'cli-server') {
|
||||
$root = (string) getenv('DIFY_ATTACHMENT_FIXTURE_ROOT');
|
||||
@@ -102,6 +204,35 @@ namespace {
|
||||
$message . ' (' . ($result['error_code'] ?? 'no error') . ')');
|
||||
attachmentExpect(!str_contains(json_encode($result), 'fixture-private-path-url-key-body'), 'upstream body never escapes');
|
||||
}
|
||||
function attachmentTransportClosed(array $events): void
|
||||
{
|
||||
$pools = []; $closedPools = []; $created = []; $closed = []; $added = []; $active = [];
|
||||
foreach ($events as $event) {
|
||||
$kind = $event['kind'];
|
||||
if ($kind === 'pool_init') { $pools[] = $event['pool']; $active[$event['pool']] = []; }
|
||||
if ($kind === 'easy_init') $created[] = $event['easy'];
|
||||
if ($kind === 'easy_close') {
|
||||
foreach ($active as $handles) attachmentExpect(!isset($handles[$event['easy']]), 'easy handle is removed before close');
|
||||
$closed[] = $event['easy'];
|
||||
}
|
||||
if ($kind === 'add' && $event['result'] === CURLM_OK) {
|
||||
attachmentExpect(!isset($added[$event['easy']]), 'every transfer uses a fresh easy handle');
|
||||
$added[$event['easy']] = true;
|
||||
$active[$event['pool']][$event['easy']] = true;
|
||||
attachmentExpect(count($active[$event['pool']]) === 1, 'attachment transfers remain sequential');
|
||||
}
|
||||
if ($kind === 'remove') {
|
||||
attachmentExpect($event['result'] === CURLM_OK && isset($active[$event['pool']][$event['easy']]), 'remove exactly the added easy handle');
|
||||
unset($active[$event['pool']][$event['easy']]);
|
||||
}
|
||||
if ($kind === 'pool_close') {
|
||||
attachmentExpect(($active[$event['pool']] ?? null) === [], 'pool closes after every easy handle is detached');
|
||||
$closedPools[] = $event['pool'];
|
||||
}
|
||||
}
|
||||
attachmentExpect(count($pools) <= 1 && $pools === $closedPools, 'one request-local pool is closed on every terminal path');
|
||||
attachmentExpect($created === $closed, 'all transfer easy handles close on every terminal path');
|
||||
}
|
||||
function attachmentRemoveTree(string $path, string $workspace): void
|
||||
{
|
||||
$resolved = realpath($path); $root = realpath($workspace);
|
||||
@@ -139,6 +270,10 @@ namespace {
|
||||
$attachmentFixturePort = $port;
|
||||
$attachmentCurlOptions = [];
|
||||
$attachmentNativeDnsCalls = [];
|
||||
$attachmentTransportHandles = new WeakMap();
|
||||
$attachmentTransportSequence = 0;
|
||||
$attachmentTransportEvents = [];
|
||||
$attachmentFailMultiAdd = false;
|
||||
$process = proc_open([PHP_BINARY, '-S', '127.0.0.1:' . $port, __FILE__],
|
||||
[['pipe', 'r'], ['file', $root . '/fixture-output.log', 'ab'], ['file', $root . '/fixture-error.log', 'ab']],
|
||||
$pipes, $root, ['DIFY_ATTACHMENT_FIXTURE_ROOT' => $root,
|
||||
@@ -173,8 +308,14 @@ namespace {
|
||||
$clean = static function () use ($root): void {
|
||||
attachmentExpect((glob($root . '/private/uploads/required-*') ?: []) === [], 'temporary files are removed after every terminal outcome');
|
||||
};
|
||||
$run = static function (array $input, array $extra = [], ?callable $abort = null, string $user = 'synthetic-user', ?string $target = null, int $timeout = 5) use ($endpoint, $options): array {
|
||||
return DifyRequiredAttachmentUpload::prepare($target ?? $endpoint, 'fixture-only-key', $user, $input, $timeout, $abort, array_replace($options, $extra));
|
||||
$run = static function (array $input, array $extra = [], ?callable $abort = null, string $user = 'synthetic-user', ?string $target = null, int $timeout = 5) use ($endpoint, $options, &$attachmentTransportEvents, $attachmentTransportHandles): array {
|
||||
$eventOffset = count($attachmentTransportEvents);
|
||||
try {
|
||||
return DifyRequiredAttachmentUpload::prepare($target ?? $endpoint, 'fixture-only-key', $user, $input, $timeout, $abort, array_replace($options, $extra));
|
||||
} finally {
|
||||
attachmentTransportClosed(array_slice($attachmentTransportEvents, $eventOffset));
|
||||
attachmentExpect(count($attachmentTransportHandles) === 0, 'request retains no easy handles or pools after prepare returns');
|
||||
}
|
||||
};
|
||||
|
||||
$clear(); $events = [];
|
||||
@@ -199,6 +340,66 @@ namespace {
|
||||
}
|
||||
$clean();
|
||||
|
||||
$keepaliveProcess = null;
|
||||
$originalFixturePort = $attachmentFixturePort;
|
||||
try {
|
||||
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error);
|
||||
attachmentExpect(is_resource($socket), 'allocate keep-alive fixture port');
|
||||
$address = stream_socket_get_name($socket, false);
|
||||
fclose($socket);
|
||||
$keepalivePort = (int) substr($address, strrpos($address, ':') + 1);
|
||||
$keepaliveProcess = proc_open([PHP_BINARY, __FILE__, '--keepalive-fixture', (string) $keepalivePort],
|
||||
[['pipe', 'r'], ['file', $root . '/keepalive-output.log', 'ab'], ['file', $root . '/keepalive-error.log', 'ab']],
|
||||
$keepalivePipes, $root, ['DIFY_ATTACHMENT_FIXTURE_ROOT' => $root,
|
||||
'SystemRoot' => (string) getenv('SystemRoot'), 'TEMP' => (string) getenv('TEMP'), 'TMP' => (string) getenv('TMP')], ['bypass_shell' => true]);
|
||||
attachmentExpect(is_resource($keepaliveProcess), 'start keep-alive fixture');
|
||||
if (isset($keepalivePipes[0]) && is_resource($keepalivePipes[0])) fclose($keepalivePipes[0]);
|
||||
$ready = false;
|
||||
for ($attempt = 0; $attempt < 150; ++$attempt) {
|
||||
$probe = @fsockopen('127.0.0.1', $keepalivePort, $errno, $error, 0.02);
|
||||
if (is_resource($probe)) { fclose($probe); $ready = true; break; }
|
||||
usleep(20000);
|
||||
}
|
||||
attachmentExpect($ready, 'keep-alive fixture becomes ready');
|
||||
$attachmentFixturePort = $keepalivePort;
|
||||
$keepaliveEndpoint = 'http://127.0.0.1:' . $keepalivePort . '/v1/chat-messages';
|
||||
$eventOffset = count($attachmentTransportEvents);
|
||||
$reused = $run([
|
||||
['type' => 'image', 'url' => 'https://storage.synthetic.test/first.png'],
|
||||
['type' => 'image', 'url' => 'https://storage.synthetic.test/second.png'],
|
||||
], [], null, 'synthetic-user', $keepaliveEndpoint);
|
||||
attachmentExpect(!empty($reused['ok']) && count($reused['files']) === 2, 'keep-alive path retains both complete image uploads');
|
||||
$reuseEvents = array_slice($attachmentTransportEvents, $eventOffset);
|
||||
$reusePools = array_values(array_filter($reuseEvents, static fn (array $event): bool => $event['kind'] === 'pool_init'));
|
||||
attachmentExpect(count($reusePools) === 1
|
||||
&& count(array_filter($reuseEvents, static fn (array $event): bool => $event['kind'] === 'easy_init')) === 4,
|
||||
'two sequential downloads and uploads share one pool with four fresh easy handles');
|
||||
$keepaliveRequests = array_map(static fn (string $line): array => json_decode($line, true),
|
||||
file($root . '/keepalive-requests.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
|
||||
attachmentExpect(array_column($keepaliveRequests, 'kind') === ['download', 'upload', 'download', 'upload']
|
||||
&& count(array_unique(array_column($keepaliveRequests, 'connection'))) === 1,
|
||||
'libcurl really reuses one HTTP connection across all four transfers');
|
||||
attachmentExpect(array_column($keepaliveRequests, 'authorization') === ['', 'Bearer fixture-only-key', '', 'Bearer fixture-only-key'],
|
||||
'fresh download handles never inherit upload authorization on a reused connection');
|
||||
|
||||
$eventOffset = count($attachmentTransportEvents);
|
||||
$nextPreparation = $run(array_slice($files, 0, 2), [], null, 'synthetic-user', $keepaliveEndpoint);
|
||||
attachmentExpect(!empty($nextPreparation['ok']), 'next preparation uploads independently');
|
||||
$nextPools = array_values(array_filter(array_slice($attachmentTransportEvents, $eventOffset),
|
||||
static fn (array $event): bool => $event['kind'] === 'pool_init'));
|
||||
$keepaliveRequests = array_map(static fn (string $line): array => json_decode($line, true),
|
||||
file($root . '/keepalive-requests.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
|
||||
attachmentExpect(count($nextPools) === 1 && $nextPools[0]['pool'] !== $reusePools[0]['pool']
|
||||
&& count($keepaliveRequests) === 6
|
||||
&& $keepaliveRequests[4]['connection'] !== $keepaliveRequests[0]['connection']
|
||||
&& $keepaliveRequests[4]['connection'] === $keepaliveRequests[5]['connection'],
|
||||
'even same-user next preparation gets a new pool and connection, then reuses it locally');
|
||||
$clean();
|
||||
} finally {
|
||||
$attachmentFixturePort = $originalFixturePort;
|
||||
if (is_resource($keepaliveProcess)) { proc_terminate($keepaliveProcess); proc_close($keepaliveProcess); }
|
||||
}
|
||||
|
||||
$clear();
|
||||
$duplicates = $run([$files[0], $files[0]]);
|
||||
attachmentExpect(!empty($duplicates['ok']) && count($duplicates['files']) === 2 && count($requests()) === 1
|
||||
@@ -284,11 +485,14 @@ namespace {
|
||||
&& array_column($requests(), 'kind') === ['upload', 'upload'], 'local missing-first and missing-middle sources preserve every remaining source without downloads');
|
||||
$clean();
|
||||
$clear();
|
||||
$eventOffset = count($attachmentTransportEvents);
|
||||
$allLocalMissing = $run([$source('https://site.synthetic.test/uploads/missing.png')[0],
|
||||
$source('https://site.synthetic.test/uploads/nested/missing.png')[0]]);
|
||||
attachmentExpect(!empty($allLocalMissing['ok']) && $allLocalMissing['files'] === []
|
||||
&& $allLocalMissing['uploaded_indices'] === [] && array_column($allLocalMissing['skipped'], 'index') === [1, 2]
|
||||
&& $requests() === [], 'all missing local files return a safe empty upload list for text-only chat');
|
||||
attachmentExpect(array_column(array_slice($attachmentTransportEvents, $eventOffset), 'kind') === ['pool_init', 'pool_close'],
|
||||
'all missing local sources close the request pool without creating any easy handle');
|
||||
$videoEvents = [];
|
||||
$videoMixed = $run([['type' => 'video', 'url' => 'https://untrusted.invalid/record.mp4'],
|
||||
$source('https://site.synthetic.test/uploads/missing.png')[0], $files[0]],
|
||||
@@ -577,11 +781,28 @@ namespace {
|
||||
attachmentExpect(microtime(true) - $start < 1.4 && count($requests()) === 2, 'upload does not receive a fresh budget after downloading');
|
||||
$clean(); usleep(700000);
|
||||
$clear();
|
||||
$eventOffset = count($attachmentTransportEvents);
|
||||
$emptyPreparation = $run([]);
|
||||
attachmentExpect(!empty($emptyPreparation['ok']) && $emptyPreparation['files'] === []
|
||||
&& count($attachmentTransportEvents) === $eventOffset, 'empty manifest needs no connection pool or transfer handles');
|
||||
$attachmentFailMultiAdd = true;
|
||||
try {
|
||||
attachmentError($run([$files[0]]), 'REQUIRED_FILES_PREPARE_FAILED', 'failed pool attachment still closes its fresh easy handle and pool');
|
||||
attachmentExpect($requests() === [], 'failed multi add opens no HTTP request');
|
||||
} finally { $attachmentFailMultiAdd = false; }
|
||||
$clean();
|
||||
$clear();
|
||||
attachmentError($run([$files[0]], ['on_upload' => static function (array $event): void {
|
||||
if ($event['status'] === 'uploaded') throw new RuntimeException('fixture-private-path-url-key-body');
|
||||
}]), 'REQUIRED_FILES_PREPARE_FAILED', 'observer failure after upload still closes idle pooled connections and exposes no partial IDs');
|
||||
attachmentExpect(array_column($requests(), 'kind') === ['upload'], 'observer failure does not retry or continue uploads');
|
||||
$clean();
|
||||
$clear();
|
||||
attachmentError($run([$files[0]], ['temp_root' => $root . '/public/unsafe-temp']), 'CONFIG_INVALID', 'temporary files cannot be created in public');
|
||||
attachmentExpect($requests() === [], 'unsafe temp configuration opens no connection');
|
||||
attachmentError($run([['type' => 'image', 'transfer_method' => 'local_file', 'url' => $files[0]['url']]]), 'REQUIRED_FILES_INVALID', 'callers cannot inject upload IDs from other requests');
|
||||
$clean();
|
||||
echo "Dify required attachment upload: binary multipart, confirmed-missing skips and original indices, origins/paths/DNS/pinning/redirects/limits, isolation, cancellation/deadline, cleanup: OK\n";
|
||||
echo "Dify required attachment upload: binary multipart, confirmed-missing skips and original indices, origins/paths/DNS/pinning/redirects/limits, request-local connection reuse/isolation, cancellation/deadline, cleanup: OK\n";
|
||||
} finally {
|
||||
if (is_resource($process)) { proc_terminate($process); proc_close($process); }
|
||||
attachmentRemoveTree($root, $root);
|
||||
|
||||
Reference in New Issue
Block a user