Variable PW_SERVE_INLINE_PHPConst
PW_SERVE_INLINE_PHP: "\nuse Drupal\\Component\\Utility\\Html;\nuse Drupal\\Core\\DestructableInterface;\nuse Drupal\\Core\\DrupalKernel;\nuse Drupal\\Core\\Entity\\EntityViewBuilder;\nuse Drupal\\drupflare\\Terminate;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\HttpKernelInterface;\n\n/**\n * Closes the session an earlier render left open.\n *\n * @return string\n * What was done, for the probe that reports it.\n */\nfunction cfw_close_session(): string\n{\n\t// THE HOST IS THE SAPI, SO IT OWNS THE CLOSE THAT BigPipe DOES NOT REACH.\n\t// BigPipe::sendContent() ends with performPostSendTasks(), which is the session save, and there\n\t// is no try/finally around the three sends before it. StackMiddleware\\Session deliberately\n\t// skipped its own save already, because a BigPipeResponse is ResponseKeepSessionOpenInterface.\n\t// So a sendContent() that throws loses every session write the render made.\n\t//\n\t// The CSRF seed is one of those writes. RouteProcessorCsrf defers a _csrf_token route to a lazy\n\t// builder on an HTML request, so CsrfTokenGenerator::get() MINTS the seed while placeholders are\n\t// being replaced -- inside sendContent(). A lost seed makes validate() answer false on the next\n\t// request, which is a 403 on every _csrf_token link the page carries.\n\ttry {\n\t\t$container = Drupal::getContainer();\n\t\tif ($container !== null) {\n\t\t\tforeach (['session', 'session_manager'] as $name) {\n\t\t\t\tif ($container->initialized($name)) {\n\t\t\t\t\t$service = $container->get($name);\n\t\t\t\t\tif (method_exists($service, 'save')) {\n\t\t\t\t\t\t$service->save();\n\t\t\t\t\t\treturn $name;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} catch (Throwable $e) {\n\t}\n\tif (function_exists('session_status') && session_status() === PHP_SESSION_ACTIVE) {\n\t\t@session_write_close();\n\t\treturn 'session_write_close';\n\t}\n\treturn 'nothing to close';\n}\n/**\n * Runs one request through the booted kernel and returns the response.\n *\n * @param string $path\n * The request path, with its query string.\n * @param bool|string $destruct\n * True, false, or a comma-separated allowlist of service ids to destruct.\n * @param string $method\n * The HTTP method.\n * @param string $body\n * The raw request body.\n * @param string $contentType\n * The request content type.\n * @param string $cookieHeader\n * The raw Cookie header.\n * @param string $origin\n * A scheme://host[:port], or an empty string.\n * @param string $clientIp\n * The visitor's address, or an empty string.\n * @param string $accept\n * The raw Accept header, or an empty string.\n * @param array $headers\n * Every other inbound header, lowercase name to value, such as authorization.\n *\n * @return Response\n * The kernel's response.\n */\nfunction cfw_serve(\n\t$path,\n\t$destruct = true,\n\t$method = 'GET',\n\t$body = '',\n\t$contentType = '',\n\t$cookieHeader = '',\n\t$origin = '',\n\t$clientIp = '',\n\t$accept = '',\n\t$headers = [],\n) {\n\t$kernel = $GLOBALS['__pw_kernel'];\n\n\t// PHP'S HEADER LIST OUTLIVES THE REQUEST ON A PERSISTENT INTERPRETER, and session_start()\n\t// emits its Set-Cookie into exactly that list. Without this, visitor B's response carries\n\t// visitor A's session cookie -- a session handover, not a stale header. Cleared BEFORE the\n\t// request rather than after, so a fragment that never reaches the end still cannot leak one.\n\tif (function_exists('header_remove')) {\n\t\theader_remove();\n\t}\n\n\t// THE METHOD AND BODY ARE THREADED FROM THE HOST, and before this every call site passed a\n\t// literal \"GET\". No form submission of any kind could work: not login, not a contact form, not\n\t// node edit. A CMS that cannot accept a form is not a CMS.\n\t//\n\t// The parsed parameters are passed to Request::create() rather than only set on $_POST, because\n\t// Drupal reads the REQUEST OBJECT and not the superglobal. Setting $_POST alone produces a\n\t// request Drupal treats as an empty submission, which returns 200 and looks like it worked.\n\t$method = strtoupper($method === '' ? 'GET' : $method);\n\t$parameters = [];\n\t$uploads = [];\n\t$isForm = stripos($contentType, 'application/x-www-form-urlencoded') !== false;\n\t$isMultipart = stripos($contentType, 'multipart/form-data') !== false;\n\tif ($method !== 'GET' && $body !== '' && $isForm) {\n\t\tparse_str($body, $parameters);\n\t}\n\n\t// the uploads this request may move (CfwFileSystem reads it); a leftover from the last request\n\t// was never moved, so it is deleted the way PHP deletes an unmoved upload at shutdown\n\tforeach ($GLOBALS['__cfw_uploads'] ?? [] as $stale => $unused) {\n\t\tif (is_file($stale)) {\n\t\t\t@unlink($stale);\n\t\t}\n\t}\n\t$GLOBALS['__cfw_uploads'] = [];\n\n\t// MULTIPART IS PARSED BY HAND, because PHP fills $_POST and $_FILES only for a real POST SAPI and\n\t// this interpreter has none. Without it every form carrying a file field submitted an EMPTY\n\t// request: Drupal saw no form_id, rebuilt the form and answered 200, so /user/register and\n\t// /user/*/edit discarded every submission with no error anywhere. A file field is what sets\n\t// enctype, so the blast radius is every node type with an image, media add, and both account forms.\n\t//\n\t// NO REGEX AND NO APOSTROPHES: this fragment is emitted inside a single-quoted eval string, so an\n\t// apostrophe closes it and a backslash needs doubling. substr parsing sidesteps both.\n\tif ($method !== 'GET' && $body !== '' && $isMultipart) {\n\t\t$quoted = function (string $line, string $key): ?string {\n\t\t\t$at = stripos($line, $key . \"=\\\"\");\n\t\t\tif ($at === false) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\t$from = $at + strlen($key) + 2;\n\t\t\t$end = strpos($line, \"\\\"\", $from);\n\t\t\tif ($end === false) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\treturn substr($line, $from, $end - $from);\n\t\t};\n\t\t$boundary = '';\n\t\t$bat = stripos($contentType, 'boundary=');\n\t\tif ($bat !== false) {\n\t\t\t$boundary = trim(substr($contentType, $bat + 9));\n\t\t\t$semi = strpos($boundary, ';');\n\t\t\tif ($semi !== false) {\n\t\t\t\t$boundary = substr($boundary, 0, $semi);\n\t\t\t}\n\t\t\t$boundary = trim($boundary, \"\\\" \");\n\t\t}\n\t\tif ($boundary !== '') {\n\t\t\t$pairs = [];\n\t\t\tforeach (explode('--' . $boundary, $body) as $part) {\n\t\t\t\tif (trim($part) === '' || trim($part) === '--') {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\t$split = strpos($part, \"\\r\\n\\r\\n\");\n\t\t\t\tif ($split === false) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\t$head = substr($part, 0, $split);\n\t\t\t\t$value = substr($part, $split + 4);\n\t\t\t\t// the CRLF before the next boundary belongs to the delimiter, not to the value\n\t\t\t\tif (substr($value, -2) === \"\\r\\n\") {\n\t\t\t\t\t$value = substr($value, 0, -2);\n\t\t\t\t}\n\t\t\t\t$name = '';\n\t\t\t\t$filename = null;\n\t\t\t\t$partType = 'application/octet-stream';\n\t\t\t\tforeach (explode(\"\\r\\n\", trim($head)) as $line) {\n\t\t\t\t\tif (stripos($line, 'content-disposition:') === 0) {\n\t\t\t\t\t\t$got = $quoted($line, 'name');\n\t\t\t\t\t\tif ($got !== null) {\n\t\t\t\t\t\t\t$name = $got;\n\t\t\t\t\t\t}\n\t\t\t\t\t\t$filename = $quoted($line, 'filename');\n\t\t\t\t\t} elseif (stripos($line, 'content-type:') === 0) {\n\t\t\t\t\t\t$partType = trim(substr($line, 13));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif ($name === '') {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif ($filename === null) {\n\t\t\t\t\t$pairs[] = urlencode($name) . '=' . urlencode($value);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\t// an unchosen file arrives as filename=\"\" with an empty body; treating that as an upload\n\t\t\t\t// makes Drupal validate a zero-byte file nobody sent\n\t\t\t\tif ($filename === '') {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\t$tmp = tempnam(sys_get_temp_dir(), 'cfwup');\n\t\t\t\tif ($tmp === false) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tfile_put_contents($tmp, $value);\n\t\t\t\t$tmp = realpath($tmp) ?: $tmp;\n\t\t\t\t$GLOBALS['__cfw_uploads'][$tmp] = true;\n\t\t\t\t// TEST MODE, because a raw array became an UploadedFile whose isValid() asks\n\t\t\t\t// is_uploaded_file(), which only a POST SAPI answers true, so every upload was refused\n\t\t\t\t$entry = new UploadedFile($tmp, $filename, $partType, 0, true);\n\t\t\t\t// one bracketed level is what a form sends, as in files[user_picture_0]\n\t\t\t\t$open = strpos($name, '[');\n\t\t\t\tif ($open !== false && substr($name, -1) === ']') {\n\t\t\t\t\t$outer = substr($name, 0, $open);\n\t\t\t\t\t$inner = substr($name, $open + 1, strlen($name) - $open - 2);\n\t\t\t\t\t$uploads[$outer][$inner] = $entry;\n\t\t\t\t} else {\n\t\t\t\t\t$uploads[$name] = $entry;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ($pairs !== []) {\n\t\t\t\tparse_str(implode('&', $pairs), $parameters);\n\t\t\t}\n\t\t}\n\t}\n\n\t$server = [];\n\tif ($contentType !== '') {\n\t\t$server['CONTENT_TYPE'] = $contentType;\n\t}\n\tif ($body !== '') {\n\t\t$server['CONTENT_LENGTH'] = (string) strlen($body);\n\t}\n\tif ($cookieHeader !== '') {\n\t\t$server['HTTP_COOKIE'] = $cookieHeader;\n\t}\n\t// ON THE REQUEST BAG, not just \\$_SERVER: flood control reads \\$request->getClientIp(), and\n\t// Request::create() builds its own bag, so an assignment afterwards is invisible to it\n\tif ($clientIp !== '') {\n\t\t$server['REMOTE_ADDR'] = $clientIp;\n\t}\n\t// same reason, and it is why the Web server row on the status report was BLANK: it reads\n\t// \\$request->server->get(\"SERVER_SOFTWARE\") and the \\$_SERVER assignment below never reached the bag\n\t$server['SERVER_SOFTWARE'] = 'Cloudflare Workers';\n\t// AND WITHOUT THIS, EVERY EXPIRABLE KEYVALUE WRITE LANDS IN 1970. Time::getRequestTime() reads\n\t// REQUEST_TIME off this bag, so an absent one is 0, and DatabaseStorageExpirable stores\n\t// REQUEST_TIME + \\$ttl -- which for setWithExpire(\\$k, \\$v, 3600) is an expiry of 01:00 on\n\t// 1 Jan 1970. The row is written and is already expired, so the next read filters it out and the\n\t// GC deletes it. Measured on update_project_projects: getProjects() rebuilt it on every request,\n\t// never saw it again, and update_available_releases stayed empty forever with no error anywhere.\n\t// The rows that DID survive were the ones core happens to offset by\n\t// getCurrentTime() - getRequestTime(), which is a full epoch when the second term is 0.\n\t$now = time();\n\t$server['REQUEST_TIME'] = $now;\n\t$server['REQUEST_TIME_FLOAT'] = (float) $now;\n\t// AND WITHOUT THIS, EVERY AJAX RESPONSE COMES BACK IN A TEXTAREA. AjaxResponseSubscriber wraps\n\t// the JSON and relabels it text/html when Accept contains text/html, which is an IE9\n\t// iframe-upload workaround -- and Request::create() supplies a DEFAULT Accept that matches it.\n\t// So the browser asked for application/json, Drupal answered a wrapped text/html document, and\n\t// Drupal.AjaxError fired on every AJAX request the admin makes. Measured on Add field.\n\tif ($accept !== '') {\n\t\t$server['HTTP_ACCEPT'] = $accept;\n\t}\n\t// EVERY OTHER HEADER, because a headless site authenticates on Authorization and this bag used\n\t// to carry none: a bearer token reached the front worker and never Drupal, so each token route\n\t// answered as anonymous. The keys are remembered so the NEXT request can remove them from\n\t// $_SERVER, which outlives it on a persistent interpreter.\n\t$forwarded = [];\n\tforeach (is_array($headers) ? $headers : [] as $name => $value) {\n\t\t$key = 'HTTP_' . strtoupper(str_replace('-', '_', (string) $name));\n\t\tif (is_string($value) && !isset($server[$key])) {\n\t\t\t$server[$key] = $value;\n\t\t\t$forwarded[$key] = $value;\n\t\t}\n\t}\n\n\t// THE COOKIE IS WHY AN AUTHENTICATED REQUEST EXISTS AT ALL. Without it every request is uid 0,\n\t// so Drupal denies a create-entity route at the ROUTING layer and no form is ever built --\n\t// which is what \"the submission does not work\" looked like from outside. Parsed by hand rather\n\t// than through a helper, because the value arrives as one raw header line from the host.\n\t$cookies = [];\n\tforeach (explode(';', $cookieHeader) as $pair) {\n\t\t$pair = trim($pair);\n\t\tif ($pair === '') {\n\t\t\tcontinue;\n\t\t}\n\t\t$split = strpos($pair, '=');\n\t\tif ($split === false) {\n\t\t\tcontinue;\n\t\t}\n\t\t$cookies[urldecode(substr($pair, 0, $split))] = urldecode(substr($pair, $split + 1));\n\t}\n\n\t// THE ORIGIN IS PREPENDED SO SYMFONY PARSES IT, rather than set on $_SERVER afterwards.\n\t// Request::create() builds its OWN server bag from defaults and does not read $_SERVER, so the\n\t// HTTP_HOST assignments the fragments make were never what Drupal saw -- an absolute URI is. With\n\t// a relative one Symfony fills in \"localhost\", which is why a deployed site put http://localhost\n\t// into every canonical tag, form action, Location header and password-reset mail.\n\t$url = $origin === '' ? $path : rtrim($origin, '/') . $path;\n\t$request = Request::create($url, $method, $parameters, $cookies, $uploads, $server, $body);\n\n\t// the superglobals follow the request rather than leading it, so a fragment reading $_POST and\n\t// one reading the Request agree -- INCLUDING the host trio, which is read directly by code that\n\t// predates the request object\n\t$_SERVER['HTTP_HOST'] = $request->getHttpHost();\n\t$_SERVER['SERVER_NAME'] = $request->getHost();\n\t$_SERVER['SERVER_PORT'] = (string) $request->getPort();\n\tif ($request->isSecure()) {\n\t\t$_SERVER['HTTPS'] = 'on';\n\t} else {\n\t\tunset($_SERVER['HTTPS']);\n\t}\n\t$_SERVER['REQUEST_METHOD'] = $method;\n\tif ($clientIp !== '') {\n\t\t$_SERVER['REMOTE_ADDR'] = $clientIp;\n\t}\n\t$_SERVER['SERVER_SOFTWARE'] = $request->server->get('SERVER_SOFTWARE');\n\t// EVERY input superglobal, not just $_POST. When a CSRF token fails, FormBuilder empties the\n\t// request and calls $request->overrideGlobals() to make the globals agree\n\t// (FormBuilder.php:1024-1030); on a real SAPI those globals die with the process and here they\n\t// do not. Re-initialising all of them is what a SAPI does per request. NOTE: this alone does not\n\t// fix the residual defect pinned in tests/integration/csrf.spec.ts -- measured, so not claimed.\n\t$_POST = $parameters;\n\t$_GET = [];\n\t$_FILES = [];\n\t$_REQUEST = $parameters;\n\t$_COOKIE = $cookies;\n\tif ($contentType !== '') {\n\t\t$_SERVER['CONTENT_TYPE'] = $contentType;\n\t}\n\tif ($body !== '') {\n\t\t$_SERVER['CONTENT_LENGTH'] = (string) strlen($body);\n\t}\n\tif ($cookieHeader !== '') {\n\t\t$_SERVER['HTTP_COOKIE'] = $cookieHeader;\n\t} else {\n\t\tunset($_SERVER['HTTP_COOKIE']);\n\t}\n\t// the previous request's headers are removed first, or visitor B inherits visitor A's token\n\tforeach (array_keys($GLOBALS['__pw_forwarded_headers'] ?? []) as $stale) {\n\t\tunset($_SERVER[$stale]);\n\t}\n\tforeach ($forwarded as $key => $value) {\n\t\t$_SERVER[$key] = $value;\n\t}\n\t$GLOBALS['__pw_forwarded_headers'] = $forwarded;\n\n\t// THE SESSION HAS TO BE ENDED BEFORE THE NEXT ONE IS READ, and this interpreter is where that\n\t// stops being automatic. PHP holds $_SESSION and the active id on the PROCESS, and Symfony\n\t// memoises its started flag on a service that outlives the request -- so without this, request 2\n\t// is whoever request 1 was. Measured: a second login POST answered\n\t// \"This route can only be accessed by anonymous users\".\n\t//\n\t// drupflare owns the mechanism because it is the same mechanism drupal_static() and the node\n\t// grants need; the hand-rolled resets below are the fallback for a site that has not enabled it.\n\ttry {\n\t\t$container = Drupal::getContainer();\n\t\tif ($container !== null && $container->has('drupflare.request_resetter')) {\n\t\t\t$GLOBALS['__pw_reset'] = $container->get('drupflare.request_resetter')->reset();\n\t\t} else {\n\t\t\tif (function_exists('session_status') && session_status() === PHP_SESSION_ACTIVE) {\n\t\t\t\t@session_write_close();\n\t\t\t}\n\t\t\t$_SESSION = [];\n\t\t}\n\t} catch (Throwable $e) {\n\t\t$GLOBALS['__pw_reset'] = ['error' => $e->getMessage()];\n\t}\n\n\t// AND THE ID HAS TO BE SET FROM THIS REQUEST, not left wherever the last one put it.\n\t// session_start() prefers an id already set on the process over the cookie, so an unset id is\n\t// not a clean slate -- it is the previous visitor. Always overwrite: the cookie when there is\n\t// one, a fresh id when there is not.\n\tif (function_exists('session_id')) {\n\t\t$sid = '';\n\t\tforeach ($cookies as $cookieName => $cookieValue) {\n\t\t\tif (strncmp($cookieName, 'SESS', 4) === 0 || strncmp($cookieName, 'SSESS', 5) === 0) {\n\t\t\t\t$sid = (string) $cookieValue;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif ($sid !== '' && preg_match(\"/^[A-Za-z0-9,-]{1,128}$/\", $sid) === 1) {\n\t\t\t@session_id($sid);\n\t\t} elseif (function_exists('session_create_id')) {\n\t\t\t@session_id(session_create_id());\n\t\t}\n\t}\n\n\ttry {\n\t\t$rp = new ReflectionProperty(DrupalKernel::class, 'prepared');\n\t\t$rp->setValue($kernel, false);\n\t} catch (Throwable $e) {\n\t}\n\n\ttry {\n\t\t$stack = Drupal::service('request_stack');\n\t\twhile ($stack->getCurrentRequest() !== null) {\n\t\t\t$stack->pop();\n\t\t}\n\t} catch (Throwable $e) {\n\t}\n\n\tif (function_exists('drupal_static_reset')) {\n\t\tdrupal_static_reset();\n\t}\n\n\t// Html::$seenIds is a plain static and is NOT registered through drupal_static(), so the line\n\t// above does not clear it. On a persistent interpreter the id registry accumulates across\n\t// requests: render 1 is 12,304 bytes, renders 2-5 come back 12,310 with\n\t// block-olivero-page-title--2 through --5. Measured on a deployed worker, so the same URL stops\n\t// being byte-reproducible and every anchor, aria-labelledby target and id-based selector moves.\n\t//\n\tif ((new ReflectionClass(Html::class))->hasMethod('resetSeenIds')) {\n\t\tHtml::resetSeenIds();\n\t}\n\n\t// AND Html::$isAjax IS THE SAME STATIC ONE CLASS OVER, which resetSeenIds() does not touch.\n\t// AjaxResponseSubscriber sets it true on an ajax request and nothing sets it back, so on a\n\t// persistent interpreter the FIRST ajax request makes getUniqueId() take its random branch --\n\t// Crypt::randomBytesBase64(8) -- for every later render on that incarnation. Measured in the\n\t// browser lane: add a field through the field-UI modal, and /node/add/page then renders\n\t// node-page-form--iGSVurTf0ZU instead of node-page-form, differently on every request. A cached\n\t// page stops being byte-reproducible and every id-based selector on the site moves.\n\tif ((new ReflectionClass(Html::class))->hasMethod('setIsAjax')) {\n\t\tHtml::setIsAjax(false);\n\t}\n\n\t// AND THE PAGE CACHE KILL SWITCH IS THE SAME SHAPE AGAIN, on a container SERVICE this time.\n\t// KillSwitch::trigger() sets $kill true and core never sets it back, because a real SAPI ends the\n\t// process instead. Messenger::addMessage() calls it, so ONE saved node or config form makes\n\t// check() answer DENY for every later render on this incarnation: every page comes back\n\t// private, no-store, fillOne() refuses the upsert, and cfw_page stops filling for the whole site.\n\t// Measured in the browser lane -- save a node as admin and the ANONYMOUS front page is\n\t// uncacheable from then on. The service carries the page_cache and dynamic_page_cache tags both,\n\t// so one reset covers both policies. Skipped when it was never built, which cannot have set it.\n\ttry {\n\t\t$container = Drupal::getContainer();\n\t\tif ($container !== null && $container->initialized('page_cache_kill_switch')) {\n\t\t\t$switch = $container->get('page_cache_kill_switch');\n\t\t\t$ref = new ReflectionObject($switch);\n\t\t\tif ($ref->hasProperty('kill')) {\n\t\t\t\t$ref->getProperty('kill')->setValue($switch, false);\n\t\t\t}\n\t\t}\n\t} catch (Throwable $e) {\n\t}\n\n\t// AND EntityViewBuilder::$recursionKeys, which is the one that costs CONTENT rather than bytes.\n\t// A key goes in at #pre_render and comes out at #post_render, so a render that throws in between\n\t// leaves it set -- and on a persistent interpreter every later build of that entity and view mode\n\t// is marked #printed and renders EMPTY. Measured: save a node, empty the render bin, and the front\n\t// page comes back 9,981 bytes against 15,055 with the teaser gone, logging \"Recursive rendering\n\t// attempt aborted for node:entity_id:1:1:en:teaser\". One failed render blanks that node for the\n\t// life of the incarnation, and /node/1 still rendering is what makes it look like a view problem.\n\ttry {\n\t\t$keys = new ReflectionProperty(EntityViewBuilder::class, 'recursionKeys');\n\t\t$keys->setValue(null, []);\n\t} catch (Throwable $e) {\n\t}\n\n\t// AND Renderer::$isRenderingRoot, which turns every later render into a 500.\n\t// renderRoot() sets it, and core resets it in a catch -- so an EXCEPTION is handled and an\n\t// abort is not. This SAPI does not unwind: a run cut short leaves the flag true on a service\n\t// that outlives the request, and every renderRoot() after it throws \"A stray renderRoot()\n\t// invocation is causing bubbling of attached assets to break\". Measured in the e2e lane as the\n\t// front page answering 500 after an invalidation, with the site otherwise healthy.\n\t// Walked through any decorator, for the reason the path.matcher walk below gives.\n\ttry {\n\t\t$node = Drupal::service('renderer');\n\t\t$seen = 0;\n\t\twhile (is_object($node) && $seen < 8) {\n\t\t\t$seen++;\n\t\t\t$ref = new ReflectionObject($node);\n\t\t\tif ($ref->hasProperty('isRenderingRoot')) {\n\t\t\t\t$ref->getProperty('isRenderingRoot')->setValue($node, false);\n\t\t\t}\n\t\t\tif (!$ref->hasProperty('decorated')) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\t$node = $ref->getProperty('decorated')->getValue($node);\n\t\t}\n\t} catch (Throwable $e) {\n\t}\n\n\t// PATH.MATCHER LEAKS ITS FRONT-PAGE VERDICT ACROSS RENDERS, and this fixes markup that was\n\t// being served wrong to real visitors. isFrontPage() memoises into $isCurrentFrontPage, and on a\n\t// persistent container the FIRST path rendered decides for every later one. Measured: render /\n\t// then /user/login on one interpreter and /user/login comes back with class=\"path-frontpage\",\n\t// no active trail and no breadcrumb -- front-page markup on a page that is not the front page.\n\t//\n\t// walked by reflection, and the chain is why. path_alias DECORATES path.matcher, so\n\t// Drupal::service(\"path.matcher\") is an AliasPathMatcher holding the real one in $decorated --\n\t// and it declares its OWN $isCurrentFrontPage, memoised with ??=, which shadows the inner\n\t// matcher entirely. Two earlier attempts missed that: drupal_static_reset() does not touch a\n\t// protected property, and giving the INNER class a reset() fixed an object whose answer is\n\t// never consulted. Walking every link means no decoration depth or ordering can hide a memo.\n\t//\n\t// NULL rather than FALSE: isFrontPage() guards on the property being unset, so FALSE reads as a\n\t// computed \"not the front page\" and pins every later request to it -- the same bug reversed.\n\ttry {\n\t\t$node = Drupal::service('path.matcher');\n\t\t$seen = 0;\n\t\twhile (is_object($node) && $seen < 8) {\n\t\t\t$seen++;\n\t\t\t$ref = new ReflectionObject($node);\n\t\t\tif ($ref->hasProperty('isCurrentFrontPage')) {\n\t\t\t\t$prop = $ref->getProperty('isCurrentFrontPage');\n\t\t\t\t$prop->setValue($node, null);\n\t\t\t}\n\t\t\tif (!$ref->hasProperty('decorated')) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\t$inner = $ref->getProperty('decorated');\n\t\t\t$node = $inner->getValue($node);\n\t\t}\n\t} catch (Throwable $e) {\n\t}\n\n\t// NOT releasing locks here, and the reasoning is measured. The mechanism is real:\n\t// DatabaseLockBackend relies on releaseAll() at PROCESS SHUTDOWN, this interpreter never\n\t// shuts down, and a lock held forever would be worse than a stale cache because\n\t// Lock::wait() calls usleep() inside a synchronous wasm call that nothing can interrupt --\n\t// it stalls instead of failing.\n\t// But the semaphore table measured EMPTY on every site exercised, including three that ran\n\t// the destruct pass before any release was added, so nothing actually leaks on these\n\t// paths: CacheCollector::destruct() releases its own lock. Paying a statement per\n\t// render for an unobserved leak is the same trade this file just rejected for the\n\t// destruct pass. Instead: alarm() releases (cheap, periodic, unattended) and\n\t// test-serve-chain.mjs asserts the semaphore table is empty, so a future leak trips a test\n\t// rather than stalling a request.\n\n\t// $catch = TRUE, which is what index.php passes and what this had wrong. With FALSE, HttpKernel\n\t// rethrows instead of dispatching KernelEvents::EXCEPTION -- so Drupal's own 403 and 404 pages\n\t// never rendered, and a successful login came back as a bare\n\t// Drupal\\Core\\Form\\EnforcedResponseException because the redirect a form sets is DELIVERED as an\n\t// exception and converted by EnforcedFormResponseSubscriber. Every one of those is a normal\n\t// response that was being reported as a render failure.\n\t$response = $kernel->handle($request, HttpKernelInterface::MAIN_REQUEST, true);\n\n\t// shutdown callbacks and the named TERMINATE subscribers, which a persistent interpreter never\n\t// runs by itself; a server error skips them inside drain()\n\tif (class_exists(Terminate::class)) {\n\t\ttry {\n\t\t\tTerminate::drain($kernel, $request, $response);\n\t\t} catch (Throwable $e) {\n\t\t}\n\t}\n\n\t// Nothing had ever completed the request lifecycle, so every needs_destruction\n\t// service -- theme.registry, library.discovery, library.parsing_cache,\n\t// menu.active_trail, router.builder, path_alias -- discarded its accumulated\n\t// CacheCollector entries instead of persisting them. Those writes happen in\n\t// CacheCollector::destruct().\n\t//\n\t// This is NOT $kernel->terminate(). Two measured reasons:\n\t// First, terminate() dispatches TERMINATE, which automated_cron subscribes to. With\n\t// system.cron_last absent it runs drupal_cron() inline, cron reaches for\n\t// outbound HTTP, and the invocation dies with \"ReferenceError: Asyncify is\n\t// not defined\" -- a JS exception that catch (Throwable) cannot contain.\n\t// Second, even with cron disabled, terminate() POISONED the interpreter: the first\n\t// render returned 12,304 bytes and every render after it returned 0 bytes,\n\t// with rows-written per render jumping 15 -> 85. terminate() is written for a\n\t// process that is about to exit; this interpreter is persistent and reuses\n\t// the same kernel.\n\t// So iterate the container parameter the compiler pass fills and destruct only the\n\t// services that were actually initialised this request -- the collector writes we\n\t// want, none of the process-death semantics we do not.\n\t// $destruct is true, false, or a comma-separated allowlist of service ids, so the\n\t// culprit can be bisected\n\tif ($destruct !== false && $destruct !== '0') {\n\t\t$only = is_string($destruct) ? explode(',', $destruct) : null;\n\t\t// theme.registry is EXCLUDED, and it is the one service whose destruct() cannot\n\t\t// be used here. Bisected one service at a time: state, menu.active_trail,\n\t\t// router.builder, library.discovery and library.parsing_cache all destruct\n\t\t// safely (12,310-byte render, every render); theme.registry alone gives 12,304\n\t\t// on render 1 and then 0 BYTES on every render after it.\n\t\t// Registry::destruct() persists the RUNTIME registry, which core's own docblock\n\t\t// calls \"incomplete\". Clearing cache_bootstrap between renders does NOT fix it,\n\t\t// so the corruption is the in-memory collector object, which survives because\n\t\t// this interpreter reuses the container across requests. Registry::reset() is not\n\t\t// an escape either -- it deletes the theme_registry:runtime:* cids that destruct()\n\t\t// just wrote, so it undoes the persistence it would be repairing.\n\t\t// The COMPLETE registry was never at risk: Registry::get() persists that itself\n\t\t// via setCache() when the module handler is loaded, with no destruct() involved.\n\t\t$skip = ['theme.registry'];\n\t\ttry {\n\t\t\t$c = Drupal::getContainer();\n\t\t\t$GLOBALS['__pw_destructed'] = [];\n\t\t\tforeach ($c->getParameter('kernel.destructable_services') as $id) {\n\t\t\t\tif ($only !== null && !in_array($id, $only, true)) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif ($only === null && in_array($id, $skip, true)) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (!$c->initialized($id)) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\t$svc = $c->get($id);\n\t\t\t\tif ($svc instanceof DestructableInterface) {\n\t\t\t\t\t$svc->destruct();\n\t\t\t\t\t$GLOBALS['__pw_destructed'][] = $id;\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (Throwable $e) {\n\t\t}\n\t}\n\n\treturn $response;\n}\n"