diff --git a/Dockerfile b/Dockerfile index 9886cf2..6a1b47d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,18 +2,17 @@ # Mirrors php-builds approach: compiles PHP from source then builds extension # # Build args: -# VERSION - PHP version (83, 84, 85) - defaults to 85 (PHP 8.5) +# PHP_VERSION - PHP version (8.3, 8.4, 8.5) - defaults to 8.5 # -ARG VERSION=85 +ARG PHP_VERSION=8.5 FROM ubuntu:24.04 -ARG VERSION +ARG PHP_VERSION ENV DEBIAN_FRONTEND=noninteractive -ENV PHP_VERSION=${VERSION} LABEL maintainer="Signalforge Team" -LABEL description="PHP with signalforge_http extension" +LABEL description="PHP ${PHP_VERSION} with signalforge_http extension" LABEL php.version="${PHP_VERSION}" # Install build dependencies @@ -45,8 +44,8 @@ RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \ # Clone and build PHP from source WORKDIR /tmp -RUN MAJOR=$(echo ${PHP_VERSION} | cut -c1); \ - MINOR=$(echo ${PHP_VERSION} | cut -c2); \ +RUN MAJOR=$(echo ${PHP_VERSION} | cut -d. -f1); \ + MINOR=$(echo ${PHP_VERSION} | cut -d. -f2); \ PHP_BRANCH="PHP-${MAJOR}.${MINOR}"; \ git clone --depth 1 --branch ${PHP_BRANCH} --quiet https://github.com/php/php-src.git @@ -71,8 +70,8 @@ RUN ./configure --quiet \ --enable-bcmath \ --with-readline -RUN make -j$(nproc) -s -RUN make install -s +RUN make -j$(nproc) +RUN make install # Create extension config directory RUN mkdir -p /usr/local/etc/php/conf.d diff --git a/Dockerfile.valgrind b/Dockerfile.valgrind index aceaa75..fa2eb00 100644 --- a/Dockerfile.valgrind +++ b/Dockerfile.valgrind @@ -1,32 +1,99 @@ # Dockerfile.valgrind - For memory leak detection # Build with debug symbols and no optimization for meaningful Valgrind output +# Mirrors CI environment: Ubuntu 24.04 + PHP built from source -ARG PHP_VERSION=8.4 +ARG PHP_VERSION=8.5 -FROM php:${PHP_VERSION}-cli-alpine +FROM ubuntu:24.04 + +ARG PHP_VERSION +ENV DEBIAN_FRONTEND=noninteractive + +LABEL maintainer="Signalforge Team" +LABEL description="PHP ${PHP_VERSION} with signalforge_http extension (Debug + Valgrind)" +LABEL php.version="${PHP_VERSION}" # Install build dependencies AND valgrind -RUN apk add --no-cache \ - autoconf g++ gcc make pkgconf re2c linux-headers valgrind ${PHPIZE_DEPS} +RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \ + git \ + gcc \ + g++ \ + make \ + autoconf \ + automake \ + libtool \ + pkg-config \ + re2c \ + bison \ + wget \ + valgrind \ + libxml2-dev \ + libssl-dev \ + libcurl4-openssl-dev \ + libzip-dev \ + libonig-dev \ + libsqlite3-dev \ + libpq-dev \ + libreadline-dev \ + libpcre2-dev \ + libsodium-dev \ + zlib1g-dev \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* -WORKDIR /build +# Clone and build PHP from source (with debug symbols) +WORKDIR /tmp +RUN MAJOR=$(echo ${PHP_VERSION} | cut -d. -f1); \ + MINOR=$(echo ${PHP_VERSION} | cut -d. -f2); \ + PHP_BRANCH="PHP-${MAJOR}.${MINOR}"; \ + git clone --depth 1 --branch ${PHP_BRANCH} --quiet https://github.com/php/php-src.git + +WORKDIR /tmp/php-src +RUN ./buildconf --force > /dev/null + +# Configure PHP (same as Dockerfile, debug symbols will come from CFLAGS) +RUN CFLAGS="-g" ./configure --quiet \ + --prefix=/usr/local \ + --with-config-file-path=/usr/local/etc/php \ + --with-config-file-scan-dir=/usr/local/etc/php/conf.d \ + --with-curl \ + --with-openssl \ + --with-zip \ + --with-zlib \ + --enable-mbstring \ + --enable-opcache \ + --with-pdo-mysql \ + --with-pdo-pgsql \ + --with-mysqli \ + --enable-sockets \ + --enable-pcntl \ + --enable-bcmath \ + --with-readline + +RUN make -j$(nproc) +RUN make install + +# Create extension config directory +RUN mkdir -p /usr/local/etc/php/conf.d -# Copy extension source +# Copy and build http extension with debug symbols (no optimization for accurate valgrind) +WORKDIR /build COPY . /build -# Build the extension with debug symbols (critical for Valgrind) -RUN phpize \ +RUN /usr/local/bin/phpize \ && CFLAGS="-g -O0" ./configure --enable-signalforge_http \ - && make \ - && make install \ - && docker-php-ext-enable signalforge_http + && make -j$(nproc) \ + && make install -# Get run-tests.php from PHP source -RUN wget -q -O /opt/run-tests.php https://raw.githubusercontent.com/php/php-src/master/run-tests.php +# Enable extension +RUN echo "extension=signalforge_http.so" > /usr/local/etc/php/conf.d/signalforge_http.ini # Configure PHP for Valgrind RUN echo "zend.assertions=1" >> /usr/local/etc/php/conf.d/valgrind.ini +# Get run-tests.php from PHP source +RUN wget -q -O /opt/run-tests.php https://raw.githubusercontent.com/php/php-src/master/run-tests.php + # Verify extension is loaded RUN php -m | grep signalforge_http diff --git a/Makefile b/Makefile index 25532bc..9de4fdf 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Signalforge HTTP Extension - Docker-based Build IMAGE_NAME = signalforge-http -VERSION ?= 85 +PHP_VERSION ?= 8.5 .PHONY: docker-build docker-test docker-example docker-shell docker-clean valgrind-test valgrind-docker ci-test-all test-version build-push help @@ -8,30 +8,30 @@ help: @echo "Signalforge HTTP Extension" @echo "" @echo "Usage:" - @echo " make docker-build - Build Docker image with extension (default: PHP 8.5, VERSION=85)" - @echo " make docker-test - Run tests in Docker (default: PHP 8.5, VERSION=85)" + @echo " make docker-build - Build Docker image with extension (default: PHP 8.5)" + @echo " make docker-test - Run tests in Docker (default: PHP 8.5)" @echo " make docker-example - Run example in Docker" @echo " make docker-shell - Interactive shell in Docker" @echo " make docker-clean - Remove Docker images" @echo " make ci-test-all - Build and test PHP 8.3, 8.4, and 8.5" - @echo " make test-version - Run tests using ghcr.io image (VERSION=85)" + @echo " make test-version - Run tests using locally built extension" @echo " make valgrind-docker - Run Valgrind memory check in Docker" @echo " make valgrind-test - Run tests with local Valgrind" @echo "" - @echo "Supported versions: VERSION=83 (PHP 8.3), VERSION=84 (PHP 8.4), VERSION=85 (PHP 8.5)" - @echo "Example: make docker-build VERSION=84" + @echo "Supported versions: PHP_VERSION=8.3, PHP_VERSION=8.4, PHP_VERSION=8.5 (default)" + @echo "Example: make docker-build PHP_VERSION=8.4" docker-build: - docker build --build-arg VERSION=$(VERSION) -t $(IMAGE_NAME):$(VERSION) -t $(IMAGE_NAME):latest . + docker build --platform linux/amd64 --build-arg PHP_VERSION=$(PHP_VERSION) -t $(IMAGE_NAME):$(PHP_VERSION) -t $(IMAGE_NAME):latest . docker-test: - docker run --rm -v $(PWD)/tests:/ext/tests $(IMAGE_NAME):$(VERSION) php /opt/run-tests.php /ext/tests/ + docker run --rm --platform linux/amd64 -v $(PWD)/tests:/ext/tests $(IMAGE_NAME):$(PHP_VERSION) php /opt/run-tests.php /ext/tests/ docker-example: - docker run --rm -v $(PWD)/examples:/ext/examples $(IMAGE_NAME):$(VERSION) php /ext/examples/basic.php + docker run --rm --platform linux/amd64 -v $(PWD)/examples:/ext/examples $(IMAGE_NAME):$(PHP_VERSION) php /ext/examples/basic.php docker-shell: - docker run --rm -it -v $(PWD):/ext $(IMAGE_NAME):$(VERSION) sh + docker run --rm --platform linux/amd64 -it -v $(PWD):/ext $(IMAGE_NAME):$(PHP_VERSION) bash docker-clean: docker rmi $(IMAGE_NAME) 2>/dev/null || true @@ -55,33 +55,33 @@ valgrind-test: # CI: Build and test all PHP versions ci-test-all: @echo "Building and testing PHP 8.3..." - docker build --build-arg VERSION=83 -t $(IMAGE_NAME):83 . - docker run --rm -v $(PWD)/tests:/ext/tests $(IMAGE_NAME):83 php /opt/run-tests.php -q /ext/tests/ + docker build --platform linux/amd64 --build-arg PHP_VERSION=8.3 -t $(IMAGE_NAME):8.3 . + docker run --rm --platform linux/amd64 -v $(PWD)/tests:/ext/tests $(IMAGE_NAME):8.3 php /opt/run-tests.php -q /ext/tests/ @echo "" @echo "Building and testing PHP 8.4..." - docker build --build-arg VERSION=84 -t $(IMAGE_NAME):84 . - docker run --rm -v $(PWD)/tests:/ext/tests $(IMAGE_NAME):84 php /opt/run-tests.php -q /ext/tests/ + docker build --platform linux/amd64 --build-arg PHP_VERSION=8.4 -t $(IMAGE_NAME):8.4 . + docker run --rm --platform linux/amd64 -v $(PWD)/tests:/ext/tests $(IMAGE_NAME):8.4 php /opt/run-tests.php -q /ext/tests/ @echo "" @echo "Building and testing PHP 8.5..." - docker build --build-arg VERSION=85 -t $(IMAGE_NAME):85 . - docker run --rm -v $(PWD)/tests:/ext/tests $(IMAGE_NAME):85 php /opt/run-tests.php -q /ext/tests/ + docker build --platform linux/amd64 --build-arg PHP_VERSION=8.5 -t $(IMAGE_NAME):8.5 . + docker run --rm --platform linux/amd64 -v $(PWD)/tests:/ext/tests $(IMAGE_NAME):8.5 php /opt/run-tests.php -q /ext/tests/ @echo "" @echo "All PHP versions tested successfully!" # Valgrind: Run memory check in Docker valgrind-docker: @echo "Building Valgrind Docker image..." - docker build -f Dockerfile.valgrind -t $(IMAGE_NAME):valgrind . + docker build --platform linux/amd64 --build-arg PHP_VERSION=$(PHP_VERSION) -f Dockerfile.valgrind -t $(IMAGE_NAME):valgrind . @echo "" @echo "Running Valgrind memory check..." - docker run --rm -v $(PWD)/tests:/ext/tests -v $(PWD):/output $(IMAGE_NAME):valgrind \ - sh -c 'valgrind --leak-check=full --show-leak-kinds=all --error-exitcode=1 \ + docker run --rm --platform linux/amd64 -v $(PWD)/tests:/ext/tests -v $(PWD):/output $(IMAGE_NAME):valgrind \ + bash -c 'valgrind --leak-check=full --show-leak-kinds=all --error-exitcode=1 \ --track-origins=yes --log-file=/output/valgrind-output.txt \ php /opt/run-tests.php -q /ext/tests/ && \ cat /output/valgrind-output.txt | grep -E "ERROR SUMMARY|LEAK SUMMARY" -A 5' -# Run tests using pre-built ghcr.io image with configurable PHP version +# Run tests using locally built extension with configurable PHP version test-version: - @echo "Running tests with PHP 8.$(VERSION) from ghcr.io..." - docker run --rm -v $(PWD):/ext ghcr.io/thesignalforge/signalforge:php$(VERSION) \ - php /usr/local/lib/php/build/run-tests.php /ext/tests/ + @echo "Running tests with locally built PHP $(PHP_VERSION) extension (linux/amd64)..." + docker run --rm --platform linux/amd64 -v $(PWD)/tests:/ext/tests $(IMAGE_NAME):$(PHP_VERSION) \ + php /opt/run-tests.php /ext/tests/ diff --git a/src/request.c b/src/request.c index 1e17f36..fac85b2 100644 --- a/src/request.c +++ b/src/request.c @@ -831,56 +831,6 @@ PHP_METHOD(Signalforge_Http_Request, create) RETURN_THROWS(); } - /* Extract URI string from parameter */ - if (Z_TYPE_P(uri_param) == IS_STRING) { - uri_str = Z_STR_P(uri_param); - } else if (Z_TYPE_P(uri_param) == IS_OBJECT && instanceof_function(Z_OBJCE_P(uri_param), signalforge_uri_ce)) { - /* Uri object - convert to string */ - signalforge_uri_object *uri_obj = Z_SIGNALFORGE_URI_P(uri_param); - smart_str buf = {0}; - - if (uri_obj->scheme && ZSTR_LEN(uri_obj->scheme) > 0) { - smart_str_append(&buf, uri_obj->scheme); - smart_str_appendl(&buf, "://", 3); - } - if (uri_obj->host && ZSTR_LEN(uri_obj->host) > 0) { - if (uri_obj->user && ZSTR_LEN(uri_obj->user) > 0) { - smart_str_append(&buf, uri_obj->user); - if (uri_obj->pass && ZSTR_LEN(uri_obj->pass) > 0) { - smart_str_appendc(&buf, ':'); - smart_str_append(&buf, uri_obj->pass); - } - smart_str_appendc(&buf, '@'); - } - smart_str_append(&buf, uri_obj->host); - if (uri_obj->port != SIGNALFORGE_PORT_UNSET && - !signalforge_is_standard_port(uri_obj->scheme, uri_obj->port)) { - smart_str_appendc(&buf, ':'); - smart_str_append_long(&buf, uri_obj->port); - } - } - if (uri_obj->path && ZSTR_LEN(uri_obj->path) > 0) { - smart_str_append(&buf, uri_obj->path); - } else if (!uri_obj->host || ZSTR_LEN(uri_obj->host) == 0) { - smart_str_appendc(&buf, '/'); - } - if (uri_obj->query && ZSTR_LEN(uri_obj->query) > 0) { - smart_str_appendc(&buf, '?'); - smart_str_append(&buf, uri_obj->query); - } - if (uri_obj->fragment && ZSTR_LEN(uri_obj->fragment) > 0) { - smart_str_appendc(&buf, '#'); - smart_str_append(&buf, uri_obj->fragment); - } - - smart_str_0(&buf); - uri_str = buf.s ? buf.s : zend_empty_string; - } else { - zend_throw_exception(spl_ce_InvalidArgumentException, - "URI must be a string or Uri object", 0); - RETURN_THROWS(); - } - /* Create new instance */ object_init_ex(return_value, signalforge_request_ce); intern = Z_SIGNALFORGE_REQUEST_P(return_value); @@ -902,15 +852,37 @@ PHP_METHOD(Signalforge_Http_Request, create) ALLOC_HASHTABLE(intern->ht_attributes); zend_hash_init(intern->ht_attributes, 8, NULL, ZVAL_PTR_DTOR, 0); - /* Set the URI */ - ZVAL_STR_COPY(&intern->zv_uri, uri_str); - intern->request_uri = Z_STRVAL(intern->zv_uri); - intern->request_uri_len = Z_STRLEN(intern->zv_uri); + /* Set the URI - handle ownership correctly */ + if (Z_TYPE_P(uri_param) == IS_STRING) { + /* Borrowed from parameter - must copy */ + ZVAL_STR_COPY(&intern->zv_uri, Z_STR_P(uri_param)); + } else if (Z_TYPE_P(uri_param) == IS_OBJECT && instanceof_function(Z_OBJCE_P(uri_param), signalforge_uri_ce)) { + /* Uri object - call __toString() and transfer ownership */ + zval uri_string_zv; + zend_call_method_with_0_params(Z_OBJ_P(uri_param), Z_OBJCE_P(uri_param), + NULL, "__tostring", &uri_string_zv); + + if (EG(exception)) { + RETURN_THROWS(); + } + + if (Z_TYPE(uri_string_zv) != IS_STRING) { + zval_ptr_dtor(&uri_string_zv); + zend_throw_exception(spl_ce_RuntimeException, + "Uri::__toString() must return a string", 0); + RETURN_THROWS(); + } - /* Release temporary uri_str if it was created from Uri object conversion */ - if (Z_TYPE_P(uri_param) == IS_OBJECT && instanceof_function(Z_OBJCE_P(uri_param), signalforge_uri_ce)) { - zend_string_release(uri_str); + /* Transfer ownership from temporary zval to object */ + ZVAL_COPY_VALUE(&intern->zv_uri, &uri_string_zv); + ZVAL_UNDEF(&uri_string_zv); /* Mark as transferred, no double-free */ + } else { + zend_throw_exception(spl_ce_InvalidArgumentException, + "URI must be a string or Uri object", 0); + RETURN_THROWS(); } + intern->request_uri = Z_STRVAL(intern->zv_uri); + intern->request_uri_len = Z_STRLEN(intern->zv_uri); /* Set query string if present in URI */ const char *query_pos = strchr(Z_STRVAL(intern->zv_uri), '?'); diff --git a/src/response.c b/src/response.c index ef74aea..17344e7 100644 --- a/src/response.c +++ b/src/response.c @@ -636,9 +636,14 @@ PHP_METHOD(Signalforge_Http_Response, getHeaderLine) } else if (Z_TYPE_P(val) == IS_STRING) { smart_str_appendl(&str, Z_STRVAL_P(val), Z_STRLEN_P(val)); } - + smart_str_0(&str); - RETURN_STR(str.s); + /* Handle edge case where header exists but is empty */ + if (str.s) { + RETURN_STR(str.s); + } else { + RETURN_EMPTY_STRING(); + } } /* }}} */ diff --git a/src/stream.c b/src/stream.c index 2aba0b3..9dd65f4 100644 --- a/src/stream.c +++ b/src/stream.c @@ -752,10 +752,9 @@ PHP_METHOD(Signalforge_Http_Stream, close) /* Resource-based stream */ if (Z_TYPE(intern->zv_resource) == IS_RESOURCE) { - php_stream *stream = signalforge_get_php_stream(intern); - if (stream) { - php_stream_close(stream); - } + /* Just release the resource - zval_ptr_dtor will trigger php_stream_close internally. + * Calling php_stream_close() explicitly causes a double-free because it releases + * the resource from PHP's resource list, then zval_ptr_dtor tries to release it again. */ zval_ptr_dtor(&intern->zv_resource); ZVAL_UNDEF(&intern->zv_resource); } @@ -1145,7 +1144,9 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_stream_fromString, 0, 1, Signalfo ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_stream_fromResource, 0, 1, Signalforge\\NativeHttp\\Stream, 0) - ZEND_ARG_TYPE_INFO(0, resource, IS_RESOURCE, 0) + /* PHP 8.4+ does not allow IS_RESOURCE in zend_type - use untyped parameter. + * Runtime validation still enforces resource type in fromResource() implementation. */ + ZEND_ARG_INFO(0, resource) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_stream_fromFile, 0, 1, Signalforge\\NativeHttp\\Stream, 0) @@ -1197,7 +1198,10 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_stream_close, 0, 0, IS_VOID, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_stream_detach, 0, 0, IS_RESOURCE, 1) +/* PHP 8.4+ does not allow IS_RESOURCE in zend_type. + * PSR-7 StreamInterface::detach() returns resource|null, but we cannot express this + * in arginfo. Return type validation happens at runtime. */ +ZEND_BEGIN_ARG_INFO_EX(arginfo_stream_detach, 0, 0, 0) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_stream___toString, 0, 0, IS_STRING, 0) diff --git a/src/uploadedfile.c b/src/uploadedfile.c index e484b0f..a5b1e5e 100644 --- a/src/uploadedfile.c +++ b/src/uploadedfile.c @@ -401,9 +401,8 @@ PHP_METHOD(Signalforge_Http_UploadedFile, getStream) ZVAL_COPY(&intern->zv_stream, &stream_zv); intern->stream_loaded = 1; - /* Return the stream */ + /* Return the stream - RETVAL_ZVAL with dtor=1 already destroys stream_zv */ RETVAL_ZVAL(&stream_zv, 1, 1); - zval_ptr_dtor(&stream_zv); zval_ptr_dtor(&resource_zv); /* Successfully created stream - return it */ diff --git a/tests/900_heap_request_lifecycle.phpt b/tests/900_heap_request_lifecycle.phpt new file mode 100644 index 0000000..64481fb --- /dev/null +++ b/tests/900_heap_request_lifecycle.phpt @@ -0,0 +1,129 @@ +--TEST-- +Request object lifecycle - heap corruption prevention +--EXTENSIONS-- +signalforge_http +--FILE-- +getUri(); + + // ACT: Access URI properties to ensure object is valid + $host = $retrieved->getHost(); + + // ASSERT: Uri object should still be valid after retrieval + var_dump($host === "test{$i}.com"); +} +echo "All iterations passed\n"; + +echo "\n=== Test 2: Request with method chains (immutability stress test) ===\n"; +// Each with*() creates a new object - tests proper cleanup of intermediate objects +$base = Request::create('GET', 'http://example.com'); +$r1 = $base->withMethod('POST'); +$r2 = $r1->withHeader('X-Custom', 'value'); +$r3 = $r2->withAddedHeader('X-Custom', 'value2'); +$r4 = $r3->withoutHeader('X-Custom'); +$r5 = $r4->withBody($base->getBody()); + +// ASSERT: Final object should be valid +var_dump($r5->getMethod() === 'POST'); +var_dump(count($r5->getHeaders()) >= 0); // Should not crash +echo "Immutability chain passed\n"; + +echo "\n=== Test 3: Reusing Uri object across multiple Requests ===\n"; +// Tests that Uri refcount is properly managed when shared +$shared_uri = Uri::fromString('http://shared.example.com/resource'); +$requests = []; +for ($i = 0; $i < 10; $i++) { + $requests[] = Request::create('GET', $shared_uri); +} + +// ACT: Access all requests to ensure Uri is still valid +foreach ($requests as $idx => $req) { + $uri = $req->getUri(); + // ASSERT: Uri should be accessible and correct + var_dump($uri->getHost() === 'shared.example.com'); +} +echo "Shared Uri passed\n"; + +echo "\n=== Test 4: String URI vs Uri object ownership ===\n"; +// Tests different ownership paths in Request::create() +$string_req = Request::create('POST', 'http://string-uri.com/test'); +$object_req = Request::create('POST', Uri::fromString('http://object-uri.com/test')); + +// ASSERT: Both should work correctly +var_dump($string_req->getUri()->getHost() === 'string-uri.com'); +var_dump($object_req->getUri()->getHost() === 'object-uri.com'); +echo "URI ownership passed\n"; + +echo "\n=== Test 5: Rapid object creation/destruction ===\n"; +// Stress test for memory allocation/deallocation patterns +for ($i = 0; $i < 100; $i++) { + $req = Request::create('PUT', "http://rapid{$i}.com/test"); + $method = $req->getMethod(); + $uri = $req->getUri(); + // Object destroyed at end of loop - tests free_obj handler +} +echo "Rapid lifecycle passed\n"; + +echo "\nAll heap corruption tests passed\n"; +?> +--EXPECT-- +=== Test 1: Multiple Request::create() with Uri objects === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +All iterations passed + +=== Test 2: Request with method chains (immutability stress test) === +bool(true) +bool(true) +Immutability chain passed + +=== Test 3: Reusing Uri object across multiple Requests === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +Shared Uri passed + +=== Test 4: String URI vs Uri object ownership === +bool(true) +bool(true) +URI ownership passed + +=== Test 5: Rapid object creation/destruction === +Rapid lifecycle passed + +All heap corruption tests passed diff --git a/tests/901_heap_response_lifecycle.phpt b/tests/901_heap_response_lifecycle.phpt new file mode 100644 index 0000000..5e69030 --- /dev/null +++ b/tests/901_heap_response_lifecycle.phpt @@ -0,0 +1,119 @@ +--TEST-- +Response object lifecycle - memory leak prevention +--EXTENSIONS-- +signalforge_http +--FILE-- +withStatus(200); +$r2 = $r1->withHeader('Content-Type', 'text/plain'); +$r3 = $r2->withAddedHeader('X-Custom', 'value1'); +$r4 = $r3->withAddedHeader('X-Custom', 'value2'); +$r5 = $r4->withoutHeader('X-Custom'); + +// ASSERT: Final object should be valid +var_dump($r5->getStatusCode() === 200); +var_dump($r5->hasHeader('Content-Type') === true); +var_dump($r5->hasHeader('X-Custom') === false); +echo "Header chain passed\n"; + +echo "\n=== Test 2: getHeaderLine() with empty headers (edge case from heap fix) ===\n"; +// This specifically tests the smart_str_0() edge case fix +$response = new Response(); +$response = $response->withHeader('Empty-Header', ''); +$line = $response->getHeaderLine('Empty-Header'); + +// ASSERT: Should return empty string, not crash +var_dump($line === ''); +echo "Empty header passed\n"; + +echo "\n=== Test 3: Multiple withStatus() calls ===\n"; +// Tests that status_text zend_string is properly managed +$resp = new Response(); +for ($i = 200; $i <= 210; $i++) { + $resp = $resp->withStatus($i); + // ASSERT: Status should be correct + var_dump($resp->getStatusCode() === $i); +} +echo "Status chain passed\n"; + +echo "\n=== Test 4: Body replacement stress test ===\n"; +// Tests zval body management in immutability +$resp = new Response(); +for ($i = 0; $i < 20; $i++) { + // Create new stream for each iteration + $body = Stream::fromString("Content iteration {$i}\n"); + $resp = $resp->withBody($body); +} + +// ASSERT: Body should be accessible +$final_body = (string)$resp->getBody(); +var_dump(strlen($final_body) > 0); +echo "Body replacement passed\n"; + +echo "\n=== Test 5: Rapid Response creation/destruction ===\n"; +for ($i = 0; $i < 100; $i++) { + $r = new Response(); + $r = $r->withStatus(200 + ($i % 100)); + $r = $r->withHeader('X-Test', "value{$i}"); + $status = $r->getStatusCode(); + // Object destroyed at end of loop +} +echo "Rapid lifecycle passed\n"; + +echo "\n=== Test 6: Protocol version chains ===\n"; +$resp = new Response(); +$resp = $resp->withProtocolVersion('1.0'); +$resp = $resp->withProtocolVersion('1.1'); +$resp = $resp->withProtocolVersion('2.0'); + +// ASSERT: Protocol version should be correct +var_dump($resp->getProtocolVersion() === '2.0'); +echo "Protocol chain passed\n"; + +echo "\nAll Response heap tests passed\n"; +?> +--EXPECT-- +=== Test 1: Response immutability chain (header operations) === +bool(true) +bool(true) +bool(true) +Header chain passed + +=== Test 2: getHeaderLine() with empty headers (edge case from heap fix) === +bool(true) +Empty header passed + +=== Test 3: Multiple withStatus() calls === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +Status chain passed + +=== Test 4: Body replacement stress test === +bool(true) +Body replacement passed + +=== Test 5: Rapid Response creation/destruction === +Rapid lifecycle passed + +=== Test 6: Protocol version chains === +bool(true) +Protocol chain passed + +All Response heap tests passed diff --git a/tests/902_heap_stream_lifecycle.phpt b/tests/902_heap_stream_lifecycle.phpt new file mode 100644 index 0000000..998729f --- /dev/null +++ b/tests/902_heap_stream_lifecycle.phpt @@ -0,0 +1,167 @@ +--TEST-- +Stream object lifecycle - memory and resource leak prevention +--EXTENSIONS-- +signalforge_http +--FILE-- + 0); + // Stream destroyed at end of loop - tests cleanup +} +echo "String stream lifecycle passed\n"; + +echo "\n=== Test 2: Stream operations after detach ===\n"; +// Create a writable stream using fromFile with write mode +$tmpfile = tempnam(sys_get_temp_dir(), 'stream_detach_'); +$stream = Stream::fromFile($tmpfile, 'w+'); +$stream->write("Before detach"); +$resource = $stream->detach(); + +// ASSERT: Stream should be detached +var_dump($resource !== null); +var_dump($stream->isReadable() === false); +var_dump($stream->isWritable() === false); + +// Cleanup the detached resource +if (is_resource($resource)) { + fclose($resource); +} +@unlink($tmpfile); +echo "Detach lifecycle passed\n"; + +echo "\n=== Test 3: Stream read/seek/rewind cycle ===\n"; +$stream = Stream::fromString("0123456789"); + +for ($i = 0; $i < 5; $i++) { + $stream->seek(0); + $data = $stream->read(10); + // ASSERT: Should read correctly + var_dump($data === "0123456789"); +} +echo "Read/seek cycle passed\n"; + +echo "\n=== Test 4: Large content read (memory allocation test) ===\n"; +$large_content = str_repeat("A", 1024 * 100); // 100KB +$stream = Stream::fromString($large_content); +$read_content = $stream->getContents(); + +// ASSERT: Content should match +var_dump(strlen($read_content) === strlen($large_content)); +var_dump($read_content === $large_content); +echo "Large content passed\n"; + +echo "\n=== Test 5: Multiple streams with file resources ===\n"; +// Create temporary files +$files = []; +for ($i = 0; $i < 10; $i++) { + $tmpfile = tempnam(sys_get_temp_dir(), 'stream_test_'); + file_put_contents($tmpfile, "File content {$i}"); + $files[] = $tmpfile; + + $stream = Stream::fromFile($tmpfile, 'r'); + $content = (string)$stream; + + // ASSERT: File content should be correct + var_dump($content === "File content {$i}"); + // Stream destroyed, but file remains for cleanup +} + +// Cleanup temporary files +foreach ($files as $file) { + @unlink($file); +} +echo "File stream lifecycle passed\n"; + +echo "\n=== Test 6: Stream close and operations ===\n"; +$stream = Stream::fromString("Test"); +$stream->close(); + +// ASSERT: Stream should be closed +var_dump($stream->isReadable() === false); +var_dump($stream->isWritable() === false); +echo "Close lifecycle passed\n"; + +echo "\n=== Test 7: Rapid stream creation/destruction ===\n"; +for ($i = 0; $i < 50; $i++) { + $s = Stream::fromString("Iteration {$i}"); + $content = (string)$s; + // Stream destroyed at end of loop +} +echo "Rapid lifecycle passed\n"; + +echo "\nAll Stream heap tests passed\n"; +?> +--EXPECT-- +=== Test 1: Multiple Stream creation from string === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +String stream lifecycle passed + +=== Test 2: Stream operations after detach === +bool(true) +bool(true) +bool(true) +Detach lifecycle passed + +=== Test 3: Stream read/seek/rewind cycle === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +Read/seek cycle passed + +=== Test 4: Large content read (memory allocation test) === +bool(true) +bool(true) +Large content passed + +=== Test 5: Multiple streams with file resources === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +File stream lifecycle passed + +=== Test 6: Stream close and operations === +bool(true) +bool(true) +Close lifecycle passed + +=== Test 7: Rapid stream creation/destruction === +Rapid lifecycle passed + +All Stream heap tests passed diff --git a/tests/903_heap_hashtable_ownership.phpt b/tests/903_heap_hashtable_ownership.phpt new file mode 100644 index 0000000..01d9030 --- /dev/null +++ b/tests/903_heap_hashtable_ownership.phpt @@ -0,0 +1,142 @@ +--TEST-- +HashTable ownership and header manipulation - memory leak prevention +--EXTENSIONS-- +signalforge_http +--FILE-- +withHeader('Content-Type', 'application/json'); +$req = $req->withHeader('Accept', 'application/json'); +$req = $req->withHeader('X-Custom-1', 'value1'); +$req = $req->withHeader('X-Custom-2', 'value2'); +$req = $req->withHeader('X-Custom-3', 'value3'); + +// Modify existing headers +$req = $req->withHeader('Content-Type', 'text/html'); +$req = $req->withoutHeader('X-Custom-2'); +$req = $req->withAddedHeader('Accept', 'text/html'); + +// ASSERT: Headers should be correct +var_dump($req->getHeaderLine('Content-Type') === 'text/html'); +var_dump($req->hasHeader('X-Custom-2') === false); +var_dump(count($req->getHeader('Accept')) === 2); +echo "Request header chain passed\n"; + +echo "\n=== Test 2: Response header manipulation chain ===\n"; +$resp = new Response(); +$resp = $resp->withHeader('Cache-Control', 'no-cache'); +$resp = $resp->withHeader('Content-Type', 'application/json'); +$resp = $resp->withAddedHeader('Cache-Control', 'no-store'); + +// Replace and remove +$resp = $resp->withHeader('Cache-Control', 'max-age=3600'); +$resp = $resp->withoutHeader('Cache-Control'); + +// ASSERT: Headers should reflect changes +var_dump($resp->hasHeader('Cache-Control') === false); +var_dump($resp->hasHeader('Content-Type') === true); +echo "Response header chain passed\n"; + +echo "\n=== Test 3: Many headers stress test ===\n"; +$req = Request::create('POST', 'http://example.com'); +// Create 50 headers +for ($i = 0; $i < 50; $i++) { + $req = $req->withHeader("X-Header-{$i}", "value{$i}"); +} + +// Remove every other header +for ($i = 0; $i < 50; $i += 2) { + $req = $req->withoutHeader("X-Header-{$i}"); +} + +// ASSERT: Should have 25 headers remaining (plus any default headers) +$all_headers = $req->getHeaders(); +$custom_headers = array_filter(array_keys($all_headers), function($name) { + return strpos($name, 'x-header-') === 0; +}); +var_dump(count($custom_headers) === 25); +echo "Many headers passed\n"; + +echo "\n=== Test 4: Header case-insensitivity test ===\n"; +$req = Request::create('GET', 'http://test.com'); +$req = $req->withHeader('Content-Type', 'application/json'); + +// Access with different cases +$exists1 = $req->hasHeader('content-type'); +$exists2 = $req->hasHeader('CONTENT-TYPE'); +$exists3 = $req->hasHeader('CoNtEnT-TyPe'); + +// ASSERT: All should work due to case-insensitive hash +var_dump($exists1 === true); +var_dump($exists2 === true); +var_dump($exists3 === true); +echo "Case insensitivity passed\n"; + +echo "\n=== Test 5: withAddedHeader array values ===\n"; +$resp = new Response(); +$resp = $resp->withHeader('Set-Cookie', 'cookie1=value1'); +$resp = $resp->withAddedHeader('Set-Cookie', 'cookie2=value2'); +$resp = $resp->withAddedHeader('Set-Cookie', 'cookie3=value3'); + +$cookies = $resp->getHeader('Set-Cookie'); +// ASSERT: Should have 3 cookie values +var_dump(count($cookies) === 3); +var_dump(in_array('cookie1=value1', $cookies)); +var_dump(in_array('cookie2=value2', $cookies)); +var_dump(in_array('cookie3=value3', $cookies)); +echo "Added header arrays passed\n"; + +echo "\n=== Test 6: Rapid header add/remove cycle ===\n"; +$req = Request::create('DELETE', 'http://api.example.com'); +for ($i = 0; $i < 100; $i++) { + $req = $req->withHeader('X-Cycle', "iteration{$i}"); + $value = $req->getHeaderLine('X-Cycle'); + $req = $req->withoutHeader('X-Cycle'); +} +// ASSERT: Header should be gone +var_dump($req->hasHeader('X-Cycle') === false); +echo "Rapid cycle passed\n"; + +echo "\nAll HashTable ownership tests passed\n"; +?> +--EXPECT-- +=== Test 1: Request header manipulation chain === +bool(true) +bool(true) +bool(true) +Request header chain passed + +=== Test 2: Response header manipulation chain === +bool(true) +bool(true) +Response header chain passed + +=== Test 3: Many headers stress test === +bool(true) +Many headers passed + +=== Test 4: Header case-insensitivity test === +bool(true) +bool(true) +bool(true) +Case insensitivity passed + +=== Test 5: withAddedHeader array values === +bool(true) +bool(true) +bool(true) +bool(true) +Added header arrays passed + +=== Test 6: Rapid header add/remove cycle === +bool(true) +Rapid cycle passed + +All HashTable ownership tests passed diff --git a/tests/904_heap_uri_zval_ownership.phpt b/tests/904_heap_uri_zval_ownership.phpt new file mode 100644 index 0000000..6864be6 --- /dev/null +++ b/tests/904_heap_uri_zval_ownership.phpt @@ -0,0 +1,293 @@ +--TEST-- +Uri object zval ownership - refcount and memory leak prevention +--EXTENSIONS-- +signalforge_http +--FILE-- +getUri(); + $host = $retrieved_uri->getHost(); + $path = $retrieved_uri->getPath(); + + // ASSERT: Uri should be valid and correct + var_dump($host === "host{$i}.com"); + var_dump($path === "/path{$i}"); +} +echo "Uri ownership passed\n"; + +echo "\n=== Test 2: withUri() immutability and object retention ===\n"; +$base_req = Request::create('GET', 'http://original.com/path'); +$new_uri = Uri::fromString('http://updated.com/newpath'); +$updated_req = $base_req->withUri($new_uri); + +// ASSERT: Both requests should have valid URIs +var_dump($base_req->getUri()->getHost() === 'original.com'); +var_dump($updated_req->getUri()->getHost() === 'updated.com'); + +// ACT: Further modifications +$another_uri = Uri::fromString('http://third.com/anotherpath'); +$third_req = $updated_req->withUri($another_uri); + +// ASSERT: All three should still be valid +var_dump($base_req->getUri()->getHost() === 'original.com'); +var_dump($updated_req->getUri()->getHost() === 'updated.com'); +var_dump($third_req->getUri()->getHost() === 'third.com'); +echo "withUri immutability passed\n"; + +echo "\n=== Test 3: Uri with complex components lifecycle ===\n"; +// Test that all Uri components are properly managed +for ($i = 0; $i < 15; $i++) { + $uri = Uri::fromString("https://user{$i}:pass{$i}@host{$i}.com:808{$i}/path{$i}?query{$i}=val{$i}#frag{$i}"); + $req = Request::create('POST', $uri); + $u = $req->getUri(); + + // ASSERT: All components should be correct + var_dump($u->getUserInfo() !== ''); + var_dump($u->getHost() === "host{$i}.com"); + var_dump($u->getPath() === "/path{$i}"); + var_dump(strpos($u->getQuery(), "query{$i}=val{$i}") !== false); +} +echo "Complex Uri lifecycle passed\n"; + +echo "\n=== Test 4: Multiple requests sharing same Uri object ===\n"; +// Tests proper refcount management when Uri is shared +$shared_uri = Uri::fromString('http://shared-resource.com/api/endpoint'); +$requests = []; + +for ($i = 0; $i < 20; $i++) { + $requests[] = Request::create('GET', $shared_uri); +} + +// ASSERT: All requests should have the same Uri +foreach ($requests as $idx => $req) { + $uri = $req->getUri(); + var_dump($uri->getHost() === 'shared-resource.com'); +} +echo "Shared Uri refcount passed\n"; + +echo "\n=== Test 5: Uri::fromString() with various formats ===\n"; +$formats = [ + 'http://example.com', + 'https://example.com:443', + 'http://example.com/path', + 'http://example.com/path?query=value', + 'http://example.com/path?query=value#fragment', + 'http://user@example.com/path', + 'http://user:pass@example.com:8080/path?q=v#f', +]; + +foreach ($formats as $format) { + $uri = Uri::fromString($format); + $req = Request::create('GET', $uri); + $retrieved = $req->getUri(); + + // ASSERT: Uri should be valid + var_dump($retrieved instanceof Uri); + var_dump(strlen((string)$retrieved) > 0); +} +echo "Various formats passed\n"; + +echo "\n=== Test 6: withUri preserveHost parameter ===\n"; +$req = Request::create('GET', 'http://original-host.com/path'); +$req = $req->withHeader('Host', 'custom-host.com'); + +$new_uri = Uri::fromString('http://new-host.com/newpath'); +$req_preserve = $req->withUri($new_uri, true); // preserveHost = true + +// ASSERT: Host header should be preserved +var_dump($req_preserve->getHeaderLine('Host') === 'custom-host.com'); +echo "preserveHost passed\n"; + +echo "\nAll Uri zval ownership tests passed\n"; +?> +--EXPECT-- +=== Test 1: Uri object passed to Request::create() === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +Uri ownership passed + +=== Test 2: withUri() immutability and object retention === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +withUri immutability passed + +=== Test 3: Uri with complex components lifecycle === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +Complex Uri lifecycle passed + +=== Test 4: Multiple requests sharing same Uri object === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +Shared Uri refcount passed + +=== Test 5: Uri::fromString() with various formats === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +Various formats passed + +=== Test 6: withUri preserveHost parameter === +bool(true) +preserveHost passed + +All Uri zval ownership tests passed diff --git a/tests/905_heap_uploadedfile_ownership.phpt b/tests/905_heap_uploadedfile_ownership.phpt new file mode 100644 index 0000000..85dfebf --- /dev/null +++ b/tests/905_heap_uploadedfile_ownership.phpt @@ -0,0 +1,202 @@ +--TEST-- +UploadedFile object lifecycle - file path and metadata management +--EXTENSIONS-- +signalforge_http +--FILE-- + 'POST', 'REQUEST_URI' => '/test']; + $tmpfile = tempnam(sys_get_temp_dir(), "upload_{$i}_"); + file_put_contents($tmpfile, "Content {$i}"); + + $_FILES = [ + "file{$i}" => [ + 'name' => "test{$i}.txt", + 'type' => 'text/plain', + 'tmp_name' => $tmpfile, + 'error' => UPLOAD_ERR_OK, + 'size' => strlen("Content {$i}"), + ] + ]; + + $request = Request::capture(); + $files = $request->getUploadedFiles(); + $upload = $files["file{$i}"]; + + // ASSERT: File should be accessible + var_dump($upload->getClientFilename() === "test{$i}.txt"); + var_dump($upload->getSize() === strlen("Content {$i}")); + + @unlink($tmpfile); +} +echo "Request capture lifecycle passed\n"; + +echo "\n=== Test 2: UploadedFile metadata operations ===\n"; +$_SERVER = ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/upload']; +$tmpfile = tempnam(sys_get_temp_dir(), 'metadata_'); +file_put_contents($tmpfile, 'Test content for metadata'); + +$_FILES = [ + 'document' => [ + 'name' => 'report.pdf', + 'type' => 'application/pdf', + 'tmp_name' => $tmpfile, + 'error' => UPLOAD_ERR_OK, + 'size' => strlen('Test content for metadata'), + ] +]; + +$request = Request::capture(); +$files = $request->getUploadedFiles(); +$upload = $files['document']; + +// ASSERT: Metadata should be correct +var_dump($upload->getClientFilename() === 'report.pdf'); +var_dump($upload->getClientMediaType() === 'application/pdf'); +var_dump($upload->getError() === UPLOAD_ERR_OK); +var_dump($upload->getSize() === strlen('Test content for metadata')); + +@unlink($tmpfile); +echo "Metadata operations passed\n"; + +echo "\n=== Test 3: Multiple files in single request ===\n"; +$_SERVER = ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/multi-upload']; +$files_data = []; + +for ($i = 0; $i < 5; $i++) { + $tmpfile = tempnam(sys_get_temp_dir(), "multi_{$i}_"); + file_put_contents($tmpfile, "Multi content {$i}"); + + $files_data["upload{$i}"] = [ + 'name' => "file{$i}.txt", + 'type' => 'text/plain', + 'tmp_name' => $tmpfile, + 'error' => UPLOAD_ERR_OK, + 'size' => strlen("Multi content {$i}"), + ]; +} + +$_FILES = $files_data; +$request = Request::capture(); +$uploads = $request->getUploadedFiles(); + +// ASSERT: All files should be present +foreach ($files_data as $key => $file_info) { + var_dump(isset($uploads[$key])); + var_dump($uploads[$key]->getClientFilename() === $file_info['name']); + @unlink($file_info['tmp_name']); +} + +echo "Multiple files passed\n"; + +echo "\n=== Test 4: Rapid Request creation/destruction with files ===\n"; +for ($i = 0; $i < 20; $i++) { + $_SERVER = ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/rapid']; + $tmpfile = tempnam(sys_get_temp_dir(), "rapid_{$i}_"); + file_put_contents($tmpfile, "Rapid {$i}"); + + $_FILES = [ + 'rapid' => [ + 'name' => "rapid{$i}.txt", + 'type' => 'text/plain', + 'tmp_name' => $tmpfile, + 'error' => UPLOAD_ERR_OK, + 'size' => strlen("Rapid {$i}"), + ] + ]; + + $req = Request::capture(); + $files = $req->getUploadedFiles(); + $upload = $files['rapid']; + $name = $upload->getClientFilename(); + + @unlink($tmpfile); + // Request and UploadedFile destroyed at end of loop +} +echo "Rapid lifecycle passed\n"; + +echo "\n=== Test 5: Error handling for upload errors ===\n"; +$_SERVER = ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/error-test']; +$tmpfile = tempnam(sys_get_temp_dir(), 'error_'); +file_put_contents($tmpfile, 'Error test'); + +$_FILES = [ + 'error_file' => [ + 'name' => 'error.txt', + 'type' => 'text/plain', + 'tmp_name' => $tmpfile, + 'error' => UPLOAD_ERR_CANT_WRITE, + 'size' => 10, + ] +]; + +$request = Request::capture(); +$files = $request->getUploadedFiles(); +$upload = $files['error_file']; + +// ASSERT: Error should be preserved +var_dump($upload->getError() === UPLOAD_ERR_CANT_WRITE); + +@unlink($tmpfile); +echo "Error handling passed\n"; + +echo "\nAll UploadedFile ownership tests passed\n"; +?> +--EXPECT-- +=== Test 1: Multiple UploadedFile via Request::capture() === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +Request capture lifecycle passed + +=== Test 2: UploadedFile metadata operations === +bool(true) +bool(true) +bool(true) +bool(true) +Metadata operations passed + +=== Test 3: Multiple files in single request === +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +Multiple files passed + +=== Test 4: Rapid Request creation/destruction with files === +Rapid lifecycle passed + +=== Test 5: Error handling for upload errors === +bool(true) +Error handling passed + +All UploadedFile ownership tests passed diff --git a/tests/999_debug_uri_refcount.phpt b/tests/999_debug_uri_refcount.phpt new file mode 100644 index 0000000..3f864cb --- /dev/null +++ b/tests/999_debug_uri_refcount.phpt @@ -0,0 +1,101 @@ +--TEST-- +Debug: Uri object refcount in Request::create() - PHP 8.5 heap corruption investigation +--EXTENSIONS-- +signalforge_http +--FILE-- +getUri() instanceof Uri); +echo "URI: " . $request->getUri() . "\n"; + +echo "\n=== Test 2: Multiple Uri conversions (stress test) ===\n"; +for ($i = 0; $i < 10; $i++) { + $uri = Uri::fromString("http://test{$i}.com/path{$i}"); + $req = Request::create('POST', $uri); + $retrieved_uri = $req->getUri(); + echo "Iteration {$i}: " . $retrieved_uri . "\n"; + // Explicitly test that Uri object is still valid + var_dump($retrieved_uri->getHost() === "test{$i}.com"); +} + +echo "\n=== Test 3: Uri with complex components ===\n"; +$uri = Uri::fromString('https://user:pass@example.com:8080/path?query=value#fragment'); +$request = Request::create('PUT', $uri); +$retrieved = $request->getUri(); +echo "Scheme: " . $retrieved->getScheme() . "\n"; +echo "Host: " . $retrieved->getHost() . "\n"; +echo "Port: " . ($retrieved->getPort() ?? 'null') . "\n"; +echo "Path: " . $retrieved->getPath() . "\n"; +echo "Query: " . $retrieved->getQuery() . "\n"; +echo "Fragment: " . $retrieved->getFragment() . "\n"; + +echo "\n=== Test 4: Reusing same Uri object multiple times ===\n"; +$uri = Uri::fromString('http://shared.com/resource'); +for ($i = 0; $i < 5; $i++) { + $req = Request::create('GET', $uri); + echo "Shared iteration {$i}: " . $req->getUri() . "\n"; +} + +echo "\n=== Test 5: String URI vs Uri object ===\n"; +$req1 = Request::create('GET', 'http://string-uri.com/path'); +$req2 = Request::create('GET', Uri::fromString('http://object-uri.com/path')); +echo "String URI: " . $req1->getUri() . "\n"; +echo "Object URI: " . $req2->getUri() . "\n"; + +echo "\nAll tests completed successfully\n"; +?> +--EXPECT-- +=== Test 1: Basic Uri object in Request::create() === +bool(true) +URI: http://example.com/path + +=== Test 2: Multiple Uri conversions (stress test) === +Iteration 0: http://test0.com/path0 +bool(true) +Iteration 1: http://test1.com/path1 +bool(true) +Iteration 2: http://test2.com/path2 +bool(true) +Iteration 3: http://test3.com/path3 +bool(true) +Iteration 4: http://test4.com/path4 +bool(true) +Iteration 5: http://test5.com/path5 +bool(true) +Iteration 6: http://test6.com/path6 +bool(true) +Iteration 7: http://test7.com/path7 +bool(true) +Iteration 8: http://test8.com/path8 +bool(true) +Iteration 9: http://test9.com/path9 +bool(true) + +=== Test 3: Uri with complex components === +Scheme: https +Host: example.com +Port: 8080 +Path: /path +Query: query=value +Fragment: fragment + +=== Test 4: Reusing same Uri object multiple times === +Shared iteration 0: http://shared.com/resource +Shared iteration 1: http://shared.com/resource +Shared iteration 2: http://shared.com/resource +Shared iteration 3: http://shared.com/resource +Shared iteration 4: http://shared.com/resource + +=== Test 5: String URI vs Uri object === +String URI: http://string-uri.com/path +Object URI: http://object-uri.com/path + +All tests completed successfully