Problem/Motivation
ApiSseController::sse() re-renders every island inside a streamed response. Drupal closes the session as soon as the kernel returns, before the response is sent:
// \Drupal\Core\StackMiddleware\Session::handle()
$result = $this->httpKernel->handle($request, $type, $catch);
if ($type === self::MAIN_REQUEST && !$result instanceof ResponseKeepSessionOpenInterface && PHP_SAPI !== 'cli') {
$request->getSession()->save();
}
So anything the stream renders which reads the session tries to start one, and a session cannot start once the headers are out:
Drupal\views\Plugin\Block\ViewsExposedFilterBlock->build() Drupal\views\Plugin\views\display\DisplayPluginBase->viewExposedFormBlocks() Drupal\Core\Form\FormBuilder->buildForm() Symfony\Component\HttpFoundation\Session\Session->has() Drupal\Core\Session\SessionManager->start() RuntimeException: Failed to start the session because headers have already been sent by "" at line 0.
Every block that builds a form does this. A single Views exposed filter placed anywhere in the display is enough. The exception is written into the event stream, which breaks its framing, and the browser reconnects in a loop - so collaboration silently stops relaying instead of failing loudly.
Steps to reproduce
- Enable the
collaborationisland on a profile. - Build a display that contains a block with an exposed form (a Views exposed filter is the easiest).
- Open that builder in two sessions and edit from one.
- The other never updates. Watching the stream directly shows the exception instead of
island-*events:
curl -N -b cookies.txt -H "Accept: text/event-stream" \ https://example.test/api/display-builder/INSTANCE_ID/sse
A display holding only components does not reproduce it, which is why tests/src/Playwright/Tests/sse.spec.ts has never caught it: it builds a throwaway page with one test component.
Proposed resolution
Core provides the opt-out the middleware checks for, and BigPipe already uses it for exactly this - rendering after the response is sent:
final class KeepSessionOpenEventStreamResponse extends EventStreamResponse implements ResponseKeepSessionOpenInterface {}
ApiSseController::sse() returns that instead of EventStreamResponse. No other change.
Caveat to weigh in review: \Drupal\Core\Session\ResponseKeepSessionOpenInterface is marked @internal. There is no public equivalent, and the alternative is a feature that does not work on real content, but a core release could move it.
Rejected: starting the session in the controller before returning the response. The middleware closes it again before send(), so it does not help. Tried and discarded.
Remaining tasks
- Extend
sse.spec.tsso the streamed display contains a block with a form. That is the coverage gap that hid this, and without it the fix can regress silently.
User interface changes
None.
API changes
New \Drupal\display_builder\Render\KeepSessionOpenEventStreamResponse. ApiSseController::sse()'s return type narrows to it.
Data model changes
None.
Issue fork display_builder-3621928
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
- 3621928-sse-relay-crashes
changes, plain diff MR !392
Comments
Comment #2
pdureau commentedIs it really about forms or about blocks starting a session (which are more common among forms, indeed) ?
Is it a duplicate of #3546354: SSE: fail when a source is trying to start a session already in review?
Comment #4
mogtofu33 commentedProbably, at least it's an updated version. So let's keep this one.
Comment #5
mogtofu33 commentedComment #6
mogtofu33 commented