This commit is contained in:
Your Name
2026-10-23 11:39:56 +08:00
parent 86476920cf
commit ff98014fa2
11 changed files with 766 additions and 20 deletions
@@ -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);