diff --git a/.changelog/whoops-native-source-frames.md b/.changelog/whoops-native-source-frames.md new file mode 100644 index 0000000..bbff02e --- /dev/null +++ b/.changelog/whoops-native-source-frames.md @@ -0,0 +1,12 @@ +--- +category: fixed +type: patch +--- + +Require optional Whoops `^2.18.7` for development HTML, adopting its upstream fix +for native STDIN and `php -r` source inspection. Reject older Whoops versions +through Composer without making the optional package mandatory. Preserve original exception +frames, one original report, legitimate application warnings and the existing +2.x response and lifecycle policies. Align installation guidance and the bundled +consumer skill, with bounded native source regressions alongside the renderer's +unit tests. diff --git a/composer.json b/composer.json index 8c34fa0..c83595f 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "suggest": { "ext-json": "Required only for debug JSON responses on PHP 7.4; included in PHP 8.", "fast-forward/container": "^1.9", - "filp/whoops": "Install ^2.18.4 for optional development HTML responses.", + "filp/whoops": "Install ^2.18.7 for optional development HTML responses.", "llm/skills": "Optionally synchronize the bundled agent skill in consumer projects." }, "require": { @@ -41,6 +41,9 @@ "psr/event-dispatcher": "^1.0", "psr/log": "^1.1 || ^2.0 || ^3.0" }, + "conflict": { + "filp/whoops": "<2.18.7" + }, "autoload": { "psr-4": { "CoiSA\\ErrorHandler\\": "src/" @@ -58,7 +61,7 @@ "require-dev": { "coisa/php-cs-fixer": "^3.0", "fast-forward/container": "^1.9", - "filp/whoops": "^2.18.4", + "filp/whoops": "^2.18.7", "jangregor/phpstan-prophecy": "^1.0", "laminas/laminas-servicemanager": "^4.3", "llm/skills": "^1.15", diff --git a/docs/container.md b/docs/container.md index 9db4773..8108954 100644 --- a/docs/container.md +++ b/docs/container.md @@ -146,7 +146,7 @@ Use `declare(strict_types=1)` in the application entrypoint, as the complete rec ### Development HTML profile -Select `WhoopsErrorHandlerServiceProvider` only after the application's trusted development and HTML-format decision. Install optional `filp/whoops:^2.18.4` where that renderer is used. With the same application PSR-17 and private reporter definitions, the default form is: +Select `WhoopsErrorHandlerServiceProvider` only after the application's trusted development and HTML-format decision. Install optional `filp/whoops:^2.18.7` where that renderer is used. With the same application PSR-17 and private reporter definitions, the default form is: ```php use CoiSA\ErrorHandler\ServiceProvider\ErrorHandlerServiceProvider; @@ -234,7 +234,7 @@ Applications decide when a resolved process handler calls `register()`. For HTTP ## Optional development HTML composition -For manual or legacy-container composition, supply application PSR-17 factories and a callable supplying `Whoops\Handler\PrettyPageHandler`, then compose private reporting with `ReportingThrowableResponseFactory` in 2.x. The renderer adds no legacy aliases or ConfigProvider bindings; the separate opt-in [Whoops Interop profile](#development-html-profile) supplies its own map. The renderer can be cached with configuration; do not capture a current request. A manual callback may create a fresh page or reuse one for sequential rendering under the state limits above. Construct it only after the application's trusted development/format decision. Core/container installation does not require Whoops; choosing the adapter without installing `filp/whoops:^2.18.4` raises `LogicException`. See [the HTML guide](http.md#opt-in-development-html-with-whoops) and [manual recipe](../examples/http-whoops.php). Existing container/discovery/middleware defaults remain unchanged. +For manual or legacy-container composition, supply application PSR-17 factories and a callable supplying `Whoops\Handler\PrettyPageHandler`, then compose private reporting with `ReportingThrowableResponseFactory` in 2.x. The renderer adds no legacy aliases or ConfigProvider bindings; the separate opt-in [Whoops Interop profile](#development-html-profile) supplies its own map. The renderer can be cached with configuration; do not capture a current request. A manual callback may create a fresh page or reuse one for sequential rendering under the state limits above. Construct it only after the application's trusted development/format decision. Core/container installation does not require Whoops; choosing the adapter without installing `filp/whoops:^2.18.7` raises `LogicException`. See [the HTML guide](http.md#opt-in-development-html-with-whoops) and [manual recipe](../examples/http-whoops.php). Existing container/discovery/middleware defaults remain unchanged. ## Troubleshoot service resolution diff --git a/docs/examples.md b/docs/examples.md index c434a50..7573bfa 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1404,7 +1404,7 @@ echo 'HTTP Whoops ' . $mode . ': explicit renderer selection and one private rep ## Optional Interop Whoops profile -With PHP 8.3+, install FastForward as an application runtime dependency and optional Whoops ^2.18.4 as described in the [provider guide](container.md#development-html-profile). Trusted application configuration selects this diagnostic HTML profile. Compose the independent providers in one aggregate ordered base, Whoops, application. The provider has no constructor arguments; its default page service is lazy and cached. The configured mode reads Config and binds a prepared `PrettyPageHandler` through `ServiceFactory`. Both use private reporting and two distinct middleware scopes, without negotiation or emission. Sequential renders retain page configuration while replacing the current diagnostic. The guide records container fallback and missing-package limits. +With PHP 8.3+, install FastForward as an application runtime dependency and optional Whoops ^2.18.7 as described in the [provider guide](container.md#development-html-profile). Trusted application configuration selects this diagnostic HTML profile. Compose the independent providers in one aggregate ordered base, Whoops, application. The provider has no constructor arguments; its default page service is lazy and cached. The configured mode reads Config and binds a prepared `PrettyPageHandler` through `ServiceFactory`. Both use private reporting and two distinct middleware scopes, without negotiation or emission. Sequential renders retain page configuration while replacing the current diagnostic. The guide records container fallback and missing-package limits. Source: [examples/provider-whoops.php](https://github.com/coisa/error-handler/blob/master/examples/provider-whoops.php). diff --git a/docs/http.md b/docs/http.md index b3b0483..36a8495 100644 --- a/docs/http.md +++ b/docs/http.md @@ -145,9 +145,9 @@ Read the [provider dependencies, Config values and override precedence](containe ## Opt-in development HTML with Whoops -`Http\Message\WhoopsThrowableResponseFactory` was published in 2.5.0. Applications selecting this development HTML route need `coisa/error-handler:^2.5`. Install optional `filp/whoops:^2.18.4` in that application's development dependencies. The library suggests Whoops and uses it for development tests; core/container installations do not require it. Loading the adapter class without Whoops works; constructing it without the dependency raises `LogicException` with installation guidance. +`Http\Message\WhoopsThrowableResponseFactory` was published in 2.5.0. Applications selecting this development HTML route need `coisa/error-handler:^2.5`. Install optional `filp/whoops:^2.18.7` in that application's development dependencies. The library suggests Whoops and uses it for development tests; core/container installations do not require it. Loading the adapter class without Whoops works; constructing it without the dependency raises `LogicException` with installation guidance. -The minimum Whoops 2.18.4 includes the upstream PHP 8.5 deprecation fix. Older Whoops versions are outside this adapter's supported integration; the minimum keeps that native deprecation from adding a PHP error event during rendering. This optional integration requirement does not add Whoops to core runtime dependencies. +The minimum Whoops 2.18.7 includes the upstream PHP 8.5 deprecation fix and native virtual-source inspection repair. Whoops preserves frames created through STDIN (`Standard input code`) and `php -r` (`Command line code`) without trying to read those names as files. Composer rejects older Whoops versions through an optional conflict constraint; it does not install Whoops unless the application requests it. This requirement neither adds Whoops to core runtime dependencies nor suppresses legitimate application warnings. The constructor takes `ResponseFactoryInterface`, `StreamFactoryInterface` and a required `callable(): Whoops\Handler\PrettyPageHandler`. The callable supplies the configured page for each render and is not invoked during construction. It may create a new page or return a configured page reused for sequential rendering. Whoops replaces the page's current exception, inspector and generated template data; configuration stays in place. A reused page retains its latest diagnostic until the next render. This is not a concurrency or arbitrary-subclass guarantee. The adapter creates its own fresh Run, sets `handleUnconditionally(true)`, `allowQuit(false)`, `writeToOutput(false)` and `sendHttpCode(false)`, and never calls `register()`. @@ -155,7 +155,7 @@ HTML comes from Whoops' public string-returning path. Its internal buffering bel Reuse `ReportingThrowableResponseFactory($reporter, $html)` in the 2.x middleware. The private reporter runs once before rendering; its failure prevents page lookup. Failures escaping the manual page callback, Whoops or HTTP collaborators propagate without adapter retry, fallback or another report. Returning an incompatible page produces `TypeError`. These controls do not suppress arbitrary output, callbacks or process termination deliberately performed by application callbacks/subclasses. -One integration limit is reproduced on PHP 8.5.11 with Whoops 2.18.6: rendering a Throwable created through STDIN makes Whoops try to read `Standard input code` as a source file. Its resulting `file_get_contents` warning reaches the active PHP-error handler as a separate event. The original is still reported once; a reporter that throws on that additional warning can interrupt rendering. This also occurs in the manual composition, independently of the provider, and is tracked in [issue 99](https://github.com/coisa/error-handler/issues/99). The executable recipes use real source files; no warning suppression or trace rewriting is supplied. +The renderer leaves the original Throwable and its trace unchanged. The 2.x reporting decorator still reports the original once before rendering, and enabled application PHP warnings still reach their configured reporter. No warning suppression, fabricated filename or trace rewriting is supplied. The [native source regression](testing.md#optional-whoops-html-adapter) checks STDIN, `php -r` and real files separately. The [complete HTML recipe](https://github.com/coisa/error-handler/blob/master/examples/http-whoops.php) starts with generic JSON. Its trusted `development` mode selects Whoops in an explicitly chosen HTML pipeline; `public` mode never constructs a page and can run without Whoops. The mode represents application configuration, not a request parameter. There is no additional debug flag on the HTML class: choosing this diagnostic renderer is the explicit opt-in. @@ -165,7 +165,7 @@ This factory receives only Throwable. It does not negotiate Accept, infer a brow ### Whoops through an Interop service provider -With application PHP 8.3+, `fast-forward/container:^1.9` installed at runtime and optional `filp/whoops:^2.18.4`, choose Whoops after the trusted development/HTML decision. Compose the independent providers in one `AggregateServiceProvider`, ordered `ErrorHandlerServiceProvider`, `WhoopsErrorHandlerServiceProvider`, application. Its last definition wins, retaining HTML and application page overrides. Supply the same application PSR-17 factories and private reporter used by the JSON profile. The Whoops map selects the 2.x reporting decorator; the base supplies `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` for a fresh HTTP scope. +With application PHP 8.3+, `fast-forward/container:^1.9` installed at runtime and optional `filp/whoops:^2.18.7`, choose Whoops after the trusted development/HTML decision. Compose the independent providers in one `AggregateServiceProvider`, ordered `ErrorHandlerServiceProvider`, `WhoopsErrorHandlerServiceProvider`, application. Its last definition wins, retaining HTML and application page overrides. Supply the same application PSR-17 factories and private reporter used by the JSON profile. The Whoops map selects the 2.x reporting decorator; the base supplies `ServiceProvider\Factory\ErrorHandlerMiddlewareFactory` for a fresh HTTP scope. The profile has no constructor arguments. It defines `PrettyPageHandler::class` through FastForward's `InvokableFactory` and resolves that cached page lazily when rendering starts. Neither provider discovery, renderer resolution nor middleware scope construction creates the default page. The [complete provider recipe](../examples/provider-whoops.php) also shows application Config preparing a customized page before composition, bound through `ServiceFactory`. Sequential renders reuse page configuration while replacing the current diagnostic, with a new Run, response and stream each time. There is no request negotiation or automatic development detection. Selected key hiding remains a limited diagnostic policy, not comprehensive redaction. diff --git a/docs/index.html b/docs/index.html index b32ef60..707533a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -281,7 +281,7 @@

Select an optional response provider

$application, )); -

The Whoops profile has no constructor arguments and declares only its page and HTML/reporting services. The application composes the independent maps in one aggregate: base, Whoops, application. Later definitions select HTML and application page overrides; the base supplies fresh scopes. Install optional filp/whoops:^2.18.4 wherever development HTML is selected. The default PrettyPageHandler service is lazy and cached. The configured recipe reads Config and binds a prepared page through ServiceFactory. Sequential renders retain configuration and replace current diagnostic data; the Run, response and stream are fresh. Selected key hiding is not complete redaction. Read the container failure boundaries: concrete factory errors may trigger autowiring fallback, and separate providers without Whoops may report an original twice through legacy capture. This choice does not infer debug mode or negotiate Accept.

+

The Whoops profile has no constructor arguments and declares only its page and HTML/reporting services. The application composes the independent maps in one aggregate: base, Whoops, application. Later definitions select HTML and application page overrides; the base supplies fresh scopes. Install optional filp/whoops:^2.18.7 wherever development HTML is selected. The default PrettyPageHandler service is lazy and cached. The configured recipe reads Config and binds a prepared page through ServiceFactory. Sequential renders retain configuration and replace current diagnostic data; the Run, response and stream are fresh. Selected key hiding is not complete redaction. Read the container failure boundaries: concrete factory errors may trigger autowiring fallback, and separate providers without Whoops may report an original twice through legacy capture. This choice does not infer debug mode or negotiate Accept.

Fresh scope from the same executable recipe · factory service under ServiceProvider\Factory
$middlewareFactory = $services->get(ErrorHandlerMiddlewareFactory::class);
@@ -326,7 +326,7 @@ 

Separate HTTP responsibilities

$middleware = new ErrorHandlerMiddleware($errors, $responses);
-

The optional WhoopsThrowableResponseFactory takes two PSR-17 factories and a callable creating a fresh configured PrettyPageHandler. Install filp/whoops:^2.18.4 only where development HTML is used. Each render owns a new, unregistered Run, returns HTML without emission/exit and sets text/html; charset=UTF-8 plus no-store. It preserves integer codes 400–599 and delegates the reason to PSR-17. Core/container defaults remain unchanged.

+

The optional WhoopsThrowableResponseFactory takes two PSR-17 factories and a callable creating a fresh configured PrettyPageHandler. Install filp/whoops:^2.18.7 only where development HTML is used. Each render owns a new, unregistered Run, returns HTML without emission/exit and sets text/html; charset=UTF-8 plus no-store. It preserves integer codes 400–599 and delegates the reason to PSR-17. Core/container defaults remain unchanged.

Applications select this renderer through trusted development configuration and keep the private reporting decorator for 2.x. Default Whoops diagnostic pages can expose sensitive globals; hiding selected keys does not redact messages, traces, source or every secret. No request/Accept negotiation or automatic debug activation is supplied. Verify the selected checkout/release contains this additive API.

See the HTTP guide for success/failure restoration, capture limitations and the complete application composition.

diff --git a/docs/testing.md b/docs/testing.md index 8930acc..3678c68 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -69,8 +69,9 @@ rendering policy. ## Process bounds and known limits -Each native fixture runs with `PHP_BINARY -n`, a 16 MiB memory limit, a two-second -Symfony Process timeout and an interruption threshold of 64 KiB combined output. +The core lifecycle and output fixtures run with `PHP_BINARY -n`, a 16 MiB memory +limit, a two-second Symfony Process timeout and an interruption threshold of +64 KiB combined output. `finally` calls `stop(0)` on the owned child. Output can arrive in a final chunk during cleanup; this threshold is not a promise of exact byte-level buffer retention. The output callback raises once so cleanup cannot replace that @@ -179,8 +180,8 @@ and license; these checks do not install or activate an agent skill. `WhoopsThrowableResponseFactoryTest` uses real Whoops pages and PSR messages. Synthetic superglobal tables have positive exposure controls before configured key hiding and are restored after every test. Tests cover status boundaries/string SQLSTATE, original identity, one report, fresh sequential pages/bodies, escaped text and unchanged collaborator failures. The method touching native callback stacks uses PHPUnit's standard separate-process isolation. Caller output/buffers and continuation are tested without mocking native functions. Native CGI headers require separate bounded SAPI evidence, not CLI header assertions. -Whoops is a development dependency for these tests, optional for consumers. Verify the supported `^2.18.4` lower bound as well as the current version on actual PHP; a declared constraint alone is not execution proof. Production consumers without Whoops must load core/container and the adapter class, then receive a useful `LogicException` only if they select its constructor. Do not fake dependency absence inside the installed development autoloader. Consult the implementation PR for actual graphs and SHAs. +Whoops is a development dependency for these tests, optional for consumers. Verify the supported `^2.18.7` lower bound as well as the current version on actual PHP; a declared constraint alone is not execution proof. Check real Composer resolution rejects 2.18.6, accepts 2.18.7 and still installs without Whoops. Production consumers without Whoops must load core/container and the adapter class, then receive a useful `LogicException` only if they select its constructor. Do not fake dependency absence inside the installed development autoloader. Consult the implementation PR for actual graphs and SHAs. The optional Whoops provider uses real FastForward in PHPUnit: default/custom page services remain lazy and cached, configuration persists and sequential diagnostic/GET data is replaced. Tests exercise the exact provider map and both separate-provider orders, plus application overrides through one aggregate. With the selected Whoops integration present, rendering reports the original once and reporter/Whoops failures retain identity. Tests distinguish a failing concrete page factory's autowiring fallback from incompatible returned services and actual rendering failures. Native lifecycle cases are isolated and verify distinct active handlers and restoration; pure rendering cases install no callbacks. The bounded provider recipe then exercises the complete middleware/render/report path from a real source file. Installed consumers additionally verify profile construction without Whoops, the missing-dependency hint through the recommended aggregate, the separate-provider fallback limit and an application `--no-dev` graph with FastForward and Whoops explicitly required. Core PHP 7.4 and optional FastForward PHP 8.3+ are separate platform checks. -The [HTTP guide](http.md#opt-in-development-html-with-whoops) records the separately reproduced STDIN trace limitation on PHP 8.5.11/Whoops 2.18.6. PHPUnit's generated stdin harness can encounter the same upstream source-reading path. Keep lifecycle assertions isolated and pure rendering independent; neither a trace rewrite nor warning suppression establishes a production fix. +`ProcessBehaviorTest::testWhoopsNativeSourceFrames` executes the same owned fixture as a real file, native STDIN and `php -r`. The existing Symfony Process limits bound each child to two seconds, 64 MiB and 64 KiB of observed output. Children clear inherited environment values and replace Whoops tables with synthetic data; they emit only a JSON summary, not diagnostic HTML. The assertions preserve original identity and trace/source names, ordered delivery of one legitimate application warning and the original exception, status 503, HTML headers, caller buffers and restored callbacks. Whoops 2.18.7 fixes the virtual-source reads upstream; no suppression, native-function double or synthetic filename establishes the result. Child execution remains separate from parent unit coverage, while the existing renderer unit tests exercise real-file rendering and collaborator failures directly. diff --git a/examples/README.md b/examples/README.md index 6fb140b..1a15942 100644 --- a/examples/README.md +++ b/examples/README.md @@ -56,7 +56,7 @@ Without that variable, bootstrap uses the source checkout's `vendor/autoload.php ## Recipes -The optional development HTML recipe requires `composer require --dev 'filp/whoops:^2.18.4' --no-plugins --no-scripts` in the application. Its public mode selects generic JSON without constructing Whoops; development mode is an explicit trusted configuration for an HTML pipeline, not Accept negotiation. The library's own development dependencies include Whoops for verification. +The optional development HTML recipe requires `composer require --dev 'filp/whoops:^2.18.7' --no-plugins --no-scripts` in the application. Its public mode selects generic JSON without constructing Whoops; development mode is an explicit trusted configuration for an HTML pipeline, not Accept negotiation. The library's own development dependencies include Whoops for verification. | Recipe | What it executes | | --- | --- | diff --git a/resources/skills/coisa-error-handler/PROVENANCE.md b/resources/skills/coisa-error-handler/PROVENANCE.md index 7a10b49..b771048 100644 --- a/resources/skills/coisa-error-handler/PROVENANCE.md +++ b/resources/skills/coisa-error-handler/PROVENANCE.md @@ -15,5 +15,5 @@ Advanced JSON/Whoops/provider routes were published in 2.5.0 and require `coisa/error-handler ^2.5`, plus their named symbols in the selected installation. The common baseline remains `^2.4`; optional FastForward integration also requires `fast-forward/container ^1.9` and application PHP 8.3 or newer. -Whoops is an optional consumer dependency, with a minimum of `^2.18.4` for the +Whoops is an optional consumer dependency, with a minimum of `^2.18.7` for the development HTML route. No Whoops source is copied into this skill. diff --git a/resources/skills/coisa-error-handler/SKILL.md b/resources/skills/coisa-error-handler/SKILL.md index 540c7c9..6b1a231 100644 --- a/resources/skills/coisa-error-handler/SKILL.md +++ b/resources/skills/coisa-error-handler/SKILL.md @@ -2,7 +2,7 @@ name: coisa-error-handler description: Integrate coisa/error-handler with configured private reporters, process callbacks, legacy PSR-11 or ConfigProvider composition, PSR-15 responses and available JSON/Whoops/Interop profiles. Use when an application consumes this library; inspect its installed version before selecting an API. license: MIT; see LICENSE -compatibility: coisa/error-handler ^2.4 for common reporting, starting with 2.4.0; PHP ^7.4 || ^8.0 and Composer 2. Advanced JSON/Whoops/Interop routes require ^2.5, first published in 2.5.0, and their named classes in the installed source. Whoops requires optional filp/whoops ^2.18.4; optional FastForward integration requires fast-forward/container ^1.9 and application PHP 8.3+. Applications supply PSR-14/17 implementations and HTTP emission. No agent SDK or global installation is required. +compatibility: coisa/error-handler ^2.4 for common reporting, starting with 2.4.0; PHP ^7.4 || ^8.0 and Composer 2. Advanced JSON/Whoops/Interop routes require ^2.5, first published in 2.5.0, and their named classes in the installed source. Whoops requires optional filp/whoops ^2.18.7; optional FastForward integration requires fast-forward/container ^1.9 and application PHP 8.3+. Applications supply PSR-14/17 implementations and HTTP emission. No agent SDK or global installation is required. metadata: author: Felipe Sayão Lobato Abreu api-baseline: "2.4.0" diff --git a/resources/skills/coisa-error-handler/examples/README.md b/resources/skills/coisa-error-handler/examples/README.md index 6456e90..9e16b4b 100644 --- a/resources/skills/coisa-error-handler/examples/README.md +++ b/resources/skills/coisa-error-handler/examples/README.md @@ -146,7 +146,7 @@ optional Whoops and a PSR-17 implementation for the selected environment: ```sh composer require 'coisa/error-handler:^2.5' --no-plugins --no-scripts -composer require --dev 'filp/whoops:^2.18.4' 'nyholm/psr7:^1.8' --no-plugins --no-scripts +composer require --dev 'filp/whoops:^2.18.7' 'nyholm/psr7:^1.8' --no-plugins --no-scripts php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/whoops.php ./vendor/autoload.php ``` @@ -167,7 +167,7 @@ selected application runtime uses this profile with `--no-dev`, it must keep Whoops, FastForward and its PSR-17 implementation in its own runtime requirements: ```sh -composer require 'coisa/error-handler:^2.5' 'fast-forward/container:^1.9' 'filp/whoops:^2.18.4' 'nyholm/psr7:^1.8' --no-plugins --no-scripts +composer require 'coisa/error-handler:^2.5' 'fast-forward/container:^1.9' 'filp/whoops:^2.18.7' 'nyholm/psr7:^1.8' --no-plugins --no-scripts php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/provider-whoops.php ./vendor/autoload.php default php vendor/coisa/error-handler/resources/skills/coisa-error-handler/examples/provider-whoops.php ./vendor/autoload.php configured ``` @@ -186,7 +186,7 @@ before changing the composition. An aggregate selects bindings but does not disable autowire fallback after a concrete page factory fails. Separate providers can lose the missing-package hint and report the original twice through capture. The [diagnostic limits](../references/whoops.md#verify-diagnostic-and-delivery-limits) -also explain the independently reproduced native STDIN source-reading warning. +also explain native source-frame preservation and legitimate warning delivery. ## Failure and verification boundaries diff --git a/resources/skills/coisa-error-handler/examples/provider-whoops.php b/resources/skills/coisa-error-handler/examples/provider-whoops.php index 87d0d90..a851a2b 100644 --- a/resources/skills/coisa-error-handler/examples/provider-whoops.php +++ b/resources/skills/coisa-error-handler/examples/provider-whoops.php @@ -51,7 +51,7 @@ } if (!class_exists(PrettyPageHandler::class)) { - throw new RuntimeException('This optional application route needs filp/whoops:^2.18.4.'); + throw new RuntimeException('This optional application route needs filp/whoops:^2.18.7.'); } $mode = $argv[2] ?? 'default'; diff --git a/resources/skills/coisa-error-handler/examples/whoops.php b/resources/skills/coisa-error-handler/examples/whoops.php index ddbee39..ab9508d 100644 --- a/resources/skills/coisa-error-handler/examples/whoops.php +++ b/resources/skills/coisa-error-handler/examples/whoops.php @@ -36,7 +36,7 @@ } if (!class_exists(PrettyPageHandler::class)) { - throw new RuntimeException('This optional application route needs filp/whoops:^2.18.4.'); + throw new RuntimeException('This optional application route needs filp/whoops:^2.18.7.'); } // Running this program is an explicit development/HTML choice, never a request flag. diff --git a/resources/skills/coisa-error-handler/references/whoops.md b/resources/skills/coisa-error-handler/references/whoops.md index 03832c5..a914486 100644 --- a/resources/skills/coisa-error-handler/references/whoops.md +++ b/resources/skills/coisa-error-handler/references/whoops.md @@ -16,9 +16,10 @@ php -r 'require "vendor/autoload.php"; foreach (["CoiSA\\ErrorHandler\\Http\\Mes ``` The application supplies PSR-17 response/stream factories and -`filp/whoops:^2.18.4`. The patch minimum includes Whoops's PHP 8.5 deprecation -repair. A library development dependency is not installed transitively in a -consumer. Core and unrelated integration remain usable without Whoops. +`filp/whoops:^2.18.7`. The patch minimum includes Whoops's PHP 8.5 deprecation +and native STDIN/`php -r` source-inspection repairs. A library development dependency is not installed transitively in a +consumer. Composer rejects Whoops versions below 2.18.7 when that package is +selected; it does not require Whoops for core or unrelated integration. Use the installation's own supported route when these APIs are absent; do not upgrade or substitute another API automatically. @@ -123,10 +124,8 @@ source, causes and other globals may expose secrets. Never select this renderer for public users merely because they accept HTML. A deployment using it must retain Whoops in its actual runtime graph even with `--no-dev`. -A separately reproduced limit on PHP 8.5.11 with Whoops 2.18.6 affects throwables -created via native STDIN: Whoops can warn while reading `Standard input code`. -The reporter may receive that distinct PHP warning in addition to the original; -a reporter that throws can interrupt rendering. Do not claim this for all eval -or missing-file traces, suppress the warning or rewrite traces to hide it. -[Issue 99](https://github.com/coisa/error-handler/issues/99) tracks this limit. +Native STDIN and `php -r` frames keep their original virtual source names with +the supported Whoops version. Rendering does not add source-read warnings for +those names or alter the original trace. Legitimate application warnings retain +their configured PHP-error reporting policy; no warning suppression is installed. Request negotiation remains separate from this renderer. diff --git a/src/Http/Message/WhoopsThrowableResponseFactory.php b/src/Http/Message/WhoopsThrowableResponseFactory.php index a747852..3f81daf 100644 --- a/src/Http/Message/WhoopsThrowableResponseFactory.php +++ b/src/Http/Message/WhoopsThrowableResponseFactory.php @@ -54,7 +54,7 @@ public function __construct( callable $pageHandlerFactory ) { if (!class_exists(Run::class) || !class_exists(PrettyPageHandler::class)) { - throw new \LogicException('Install filp/whoops:^2.18.4 to use WhoopsThrowableResponseFactory.'); + throw new \LogicException('Install filp/whoops:^2.18.7 to use WhoopsThrowableResponseFactory.'); } $this->responseFactory = $responseFactory; diff --git a/tests/Fixtures/whoops-source.php b/tests/Fixtures/whoops-source.php new file mode 100644 index 0000000..3ff8959 --- /dev/null +++ b/tests/Fixtures/whoops-source.php @@ -0,0 +1,169 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +use CoiSA\ErrorHandler\ErrorHandler; +use CoiSA\ErrorHandler\Handler\CallableThrowableHandler; +use CoiSA\ErrorHandler\Http\Message\ReportingThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Message\WhoopsThrowableResponseFactory; +use CoiSA\ErrorHandler\Http\Middleware\ErrorHandlerMiddleware; +use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Whoops\Exception\Frame; +use Whoops\Handler\PrettyPageHandler; +use Whoops\Inspector\InspectorInterface; + +$autoload = $argv[1] ?? ''; + +require $autoload; + +// Whoops receives synthetic tables, never the agent's inherited application data. +foreach (['_GET', '_POST', '_FILES', '_COOKIE', '_SESSION', '_SERVER', '_ENV'] as $name) { + $GLOBALS[$name] = []; +} + +/** + * Creates a native frame in whichever source PHP is actually executing. + * + * @return RuntimeException the original diagnostic with an actual source and trace + */ +$createFailure = static fn(): RuntimeException => new RuntimeException('Synthetic source diagnostic', 503); + +$original = $createFailure(); +$trace = $original->getTrace(); +$received = []; +$inspected = []; +$frames = []; +$reporter = new CallableThrowableHandler( + /** @param Throwable $event the original diagnostic or a real application warning */ + static function (Throwable $event) use (&$received): void { + $received[] = $event; + }, +); +$messages = new Psr17Factory(); +$renderer = new WhoopsThrowableResponseFactory( + $messages, + $messages, + /** @return PrettyPageHandler a page observing the actual exception identity */ + static function () use (&$inspected, &$frames): PrettyPageHandler { + $page = new PrettyPageHandler(); + $page->addDataTableCallback( + 'Synthetic source identity', + /** @return array controlled page data without changing diagnostic frames */ + static function (InspectorInterface $inspector) use (&$inspected, &$frames): array { + $inspected[] = $inspector->getException(); + + /** @var Frame $frame an actual frame exposed by Whoops' collection */ + foreach ($inspector->getFrames() as $frame) { + $frames[] = [$frame->getFile(), $frame->getLine()]; + } + + return ['fixture' => 'native source']; + }, + ); + + return $page; + }, +); +$middleware = new ErrorHandlerMiddleware( + new ErrorHandler($reporter), + new ReportingThrowableResponseFactory($reporter, $renderer), +); +$next = new class ($original) implements RequestHandlerInterface { + /** @var Throwable the unchanged downstream diagnostic */ + private Throwable $failure; + + /** @param Throwable $failure the failure created by native PHP source execution */ + public function __construct(Throwable $failure) + { + $this->failure = $failure; + } + + /** + * Emits one legitimate warning before the downstream failure. + * + * @param ServerRequestInterface $request the synthetic incoming request + * + * @return ResponseInterface this failing route returns no response + * + * @throws Throwable the exact original failure + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + trigger_error('Legitimate application warning', E_USER_WARNING); + + throw $this->failure; + } +}; + +/** @return bool a temporary probe that leaves the previous callback's mask intact */ +$probeError = static fn(): bool => false; + +/** + * @param Throwable $event an unexpected exception reaching the temporary probe + * + * @throws LogicException the probe must never handle a native failure + */ +$probeException = static function (Throwable $event): void { + throw new LogicException('The callback probe must not handle a failure.', 0, $event); +}; +$previousError = set_error_handler($probeError); +restore_error_handler(); +$previousException = set_exception_handler($probeException); +restore_exception_handler(); +$entry = ob_get_level(); +ob_start(); +echo 'caller-owned bytes'; + +try { + $response = $middleware->process($messages->createServerRequest('GET', '/synthetic'), $next); + $bufferSame = $entry + 1 === ob_get_level() && 'caller-owned bytes' === ob_get_contents(); + $currentError = set_error_handler($probeError); + restore_error_handler(); + $currentException = set_exception_handler($probeException); + restore_exception_handler(); +} finally { + while (ob_get_level() > $entry) { + ob_end_clean(); + } +} + +echo json_encode([ + 'source' => $original->getFile(), + 'frameSource' => $trace[0]['file'] ?? null, + 'traceSame' => $trace === $original->getTrace(), + 'inspectedFrameSame' => in_array([$original->getFile(), $original->getLine()], $frames, true), + 'originalSame' => [$original] === $inspected && ($received[1] ?? null) === $original, + 'reports' => array_map( + /** + * @param Throwable $event a real delivered diagnostic + * + * @return array{string, null|int} message and native warning severity + */ + static fn(Throwable $event): array => [ + $event->getMessage(), + $event instanceof ErrorException ? $event->getSeverity() : null, + ], + $received, + ), + 'status' => $response->getStatusCode(), + 'contentType' => $response->getHeaderLine('Content-Type'), + 'cacheControl' => $response->getHeaderLine('Cache-Control'), + 'htmlContainsOriginal' => false !== strpos((string) $response->getBody(), $original->getMessage()), + 'bufferSame' => $bufferSame && $entry === ob_get_level(), + 'callbacksSame' => $previousError === $currentError && $previousException === $currentException, +], JSON_THROW_ON_ERROR) . "\n"; diff --git a/tests/Process/ProcessBehaviorTest.php b/tests/Process/ProcessBehaviorTest.php index 57df84c..53a85eb 100644 --- a/tests/Process/ProcessBehaviorTest.php +++ b/tests/Process/ProcessBehaviorTest.php @@ -209,4 +209,91 @@ public static function provideHazardousOutputHandlersCases(): iterable 'capture closed at native depth zero' => ['closed-capture-without-caller', 0, 0], ]; } + + /** + * Preserves native source frames and real warnings through the released Whoops integration. + * + * @param string $mode the actual PHP input route, without fabricated throwable paths + * + * @dataProvider provideWhoopsNativeSourceFramesCases + */ + public function testWhoopsNativeSourceFrames(string $mode): void + { + $fixture = \dirname(__DIR__) . '/Fixtures/whoops-source.php'; + $autoload = \dirname(__DIR__, 2) . '/vendor/autoload.php'; + $source = file_get_contents($fixture); + self::assertIsString($source); + $command = [ + PHP_BINARY, '-d', 'memory_limit=64M', '-d', 'display_errors=stderr', + '-d', 'log_errors=0', '-d', 'error_reporting=-1', + ]; + if ('file' === $mode) { + $command[] = $fixture; + } elseif ('command' === $mode) { + $command[] = '-r'; + $command[] = substr($source, \strlen("run( + /** + * Caps both fixture pipes using the existing native-process limit. + * + * @param string $type the standard pipe identifier + * @param string $chunk the newly observed fixture bytes + * + * @throws \LengthException when fixture output exceeds 64 KiB + */ + static function (string $type, string $chunk) use (&$bytes, &$limited): void { + $bytes += \strlen($chunk); + if ($bytes > 65536 && !$limited) { + $limited = true; + + throw new \LengthException('Whoops source fixture exceeded 64 KiB.'); + } + }, + ); + } finally { + $process->stop(0); + } + + self::assertFalse($process->isRunning()); + self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + self::assertSame('', $process->getErrorOutput()); + $expectedSource = 'file' === $mode ? $fixture + : ('stdin' === $mode ? 'Standard input code' : 'Command line code'); + self::assertSame([ + 'source' => $expectedSource, + 'frameSource' => $expectedSource, + 'traceSame' => true, + 'inspectedFrameSame' => true, + 'originalSame' => true, + 'reports' => [ + ['Legitimate application warning', E_USER_WARNING], + ['Synthetic source diagnostic', null], + ], + 'status' => 503, + 'contentType' => 'text/html; charset=UTF-8', + 'cacheControl' => 'no-store', + 'htmlContainsOriginal' => true, + 'bufferSame' => true, + 'callbacksSame' => true, + ], json_decode(trim($process->getOutput()), true, 512, JSON_THROW_ON_ERROR)); + } + + /** @return array real files and PHP's two native virtual sources */ + public static function provideWhoopsNativeSourceFramesCases(): iterable + { + return ['physical source' => ['file'], 'native STDIN' => ['stdin'], 'native php-r' => ['command']]; + } }