Repository: codeigniter4/CodeIgniter4 (PHP)

Issue:
Bug: Custom 404 error handler. Response caching.
### PHP Version

7.3

### CodeIgniter4 Version

4.1.8

### CodeIgniter4 Installation Method

Composer (using `codeigniter4/appstarter`)

### Which operating systems have you tested for this bug?

Windows

### Which server did you use?

apache

### Database

_No response_

### What happened?

If the custom 404 error handler does not output data to the buffer, then an empty body is returned.

### Steps to Reproduce

```php
$routes->set404Override('App\Controllers\Home::err');

public function err()
{
    return \Config\Services::response()->setJSON([1, 2, 3]);
}
```

### Expected Output

```json
[
    1, 
    2,
    3,
]
```

### Anything else?

In the ``CodeIgniter::display404errors()`` handler, the result of the ``CodeIgniter::runController()`` method is not passed to the ``CodeIgniter::gatherOutput()`` method.
https://github.com/codeigniter4/CodeIgniter4/blob/827c360a107f4ec7977b9e8f799edb1b50d99685/system/CodeIgniter.php#L859-L867

``CodeIgniter::gatherOutput()`` If the data is not passed and there was no output to the buffer, then the response body is reset to an empty string.
```php
$this->output = ob_get_contents(); // empty string
$this->response->setBody($this->output); // reset
```

I think that we need to consider not only what the ``CodeIgniter::runController()`` method returns, but also what is contained in the response body.
That is, it is necessary to define a priority between ``echo``, ``return`` and ``Response::setBody()``.





Discussion:
> That is, it is necessary to define a priority between echo, return and Response::setBody().

The current `gatherOutput()` is:
- echoed output + (returned response's body or returned string)
 
It seems no problem.

I don't think this is the correct approach. Although I may be wrong.
https://github.com/codeigniter4/CodeIgniter4/blob/93b8bca5d68652fca97de495cfb9a376345adcad/system/CodeIgniter.php#L926-L928
It may not be correct, but what's wrong?

I mean the concatenation of the data from the buffer and the returned string.
It seems to me that it is better to use "protection against the fool". And in the future, refuse to capture the buffer.
Do you think the following is better?

- echoed output
- or returned response's body
- or returned string

I don't understand why it is "protection against the fool".

For the same reason why you write a return type.
This makes the code smaller. This simplifies the code.
I would prefer just ``return ResponseInterface`` =)
I think it's dangerous to change that. I don't recall the reasoning it was there exactly but if it would be changed it would need a good chunk of additional tests to ensure all of the varieties of responses are covered, no matter how crazy they might be, so we don't break a less experienced coder's mess. 



Patch:
diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php
index 4cc1978a96f1..6769911f2da0 100644
--- a/system/CodeIgniter.php
+++ b/system/CodeIgniter.php
@@ -777,6 +777,8 @@ public function setPath(string $path)
      * Now that everything has been setup, this method attempts to run the
      * controller method and make the script go. If it's not able to, will
      * show the appropriate Page Not Found error.
+     *
+     * @return ResponseInterface|string|void
      */
     protected function startController()
     {
@@ -804,7 +806,7 @@ protected function startController()
     /**
      * Instantiates the controller class.
      *
-     * @return mixed
+     * @return Controller
      */
     protected function createController()
     {
@@ -821,7 +823,7 @@ protected function createController()
      *
      * @param mixed $class
      *
-     * @return mixed
+     * @return false|ResponseInterface|string|void
      */
     protected function runController($class)
     {
@@ -847,6 +849,8 @@ protected function display404errors(PageNotFoundException $e)
     {
         // Is there a 404 Override available?
         if ($override = $this->router->get404Override()) {
+            $returned = null;
+
             if ($override instanceof Closure) {
                 echo $override($e->getMessage());
             } elseif (is_array($override)) {
@@ -857,13 +861,13 @@ protected function display404errors(PageNotFoundException $e)
                 $this->method     = $override[1];
 
                 $controller = $this->createController();
-                $this->runController($controller);
+                $returned   = $this->runController($controller);
             }
 
             unset($override);
 
             $cacheConfig = new Cache();
-            $this->gatherOutput($cacheConfig);
+            $this->gatherOutput($cacheConfig, $returned);
             $this->sendResponse();
 
             return;
@@ -891,7 +895,7 @@ protected function display404errors(PageNotFoundException $e)
      * Gathers the script output from the buffer, replaces some execution
      * time tag in the output and displays the debug toolbar, if required.
      *
-     * @param mixed|null $returned
+     * @param ResponseInterface|string|null $returned
      */
     protected function gatherOutput(?Cache $cacheConfig = null, $returned = null)
     {


Test patch:
diff --git a/tests/_support/Controllers/Popcorn.php b/tests/_support/Controllers/Popcorn.php
index 71a316685a32..660a405cccc2 100644
--- a/tests/_support/Controllers/Popcorn.php
+++ b/tests/_support/Controllers/Popcorn.php
@@ -30,7 +30,7 @@ public function index()
 
     public function pop()
     {
-        $this->respond('Oops', 567, 'Surprise');
+        return $this->respond('Oops', 567, 'Surprise');
     }
 
     public function popper()
@@ -40,12 +40,12 @@ public function popper()
 
     public function weasel()
     {
-        $this->respond('', 200);
+        return $this->respond('', 200);
     }
 
     public function oops()
     {
-        $this->failUnauthorized();
+        return $this->failUnauthorized();
     }
 
     public function goaway()
@@ -72,12 +72,12 @@ public function cat()
 
     public function json()
     {
-        $this->respond(['answer' => 42]);
+        return $this->respond(['answer' => 42]);
     }
 
     public function xml()
     {
-        $this->respond('<my><pet>cat</pet></my>');
+        return $this->respond('<my><pet>cat</pet></my>');
     }
 
     public function toindex()
diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php
index a34664d9c9e1..b4117673e785 100644
--- a/tests/system/CodeIgniterTest.php
+++ b/tests/system/CodeIgniterTest.php
@@ -94,15 +94,34 @@ public function testRun404Override()
         // Inject mock router.
         $routes = Services::routes();
         $routes->setAutoRoute(false);
-        $routes->set404Override('Home::index');
+        $routes->set404Override('Tests\Support\Controllers\Hello::index');
         $router = Services::router($routes, Services::request());
         Services::injectMock('router', $router);
 
         ob_start();
-        $this->codeigniter->useSafeOutput(true)->run();
+        $this->codeigniter->useSafeOutput(true)->run($routes);
         $output = ob_get_clean();
 
-        $this->assertStringContainsString('Welcome to CodeIgniter', $output);
+        $this->assertStringContainsString('Hello', $output);
+    }
+
+    public function testRun404OverrideControllerReturnsResponse()
+    {
+        $_SERVER['argv'] = ['index.php', '/'];
+        $_SERVER['argc'] = 2;
+
+        // Inject mock router.
+        $routes = Services::routes();
+        $routes->setAutoRoute(false);
+        $routes->set404Override('Tests\Support\Controllers\Popcorn::pop');
+        $router = Services::router($routes, Services::request());
+        Services::injectMock('router', $router);
+
+        ob_start();
+        $this->codeigniter->useSafeOutput(true)->run($routes);
+        $output = ob_get_clean();
+
+        $this->assertStringContainsString('Oops', $output);
     }
 
     public function testRun404OverrideByClosure()