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

  1. Enable the collaboration island on a profile.
  2. Build a display that contains a block with an exposed form (a Views exposed filter is the easiest).
  3. Open that builder in two sessions and edit from one.
  4. 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.ts so 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.

Command icon 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:

Comments

mogtofu33 created an issue. See original summary.

pdureau’s picture

Is 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?

mogtofu33’s picture

Probably, at least it's an updated version. So let's keep this one.

mogtofu33’s picture

Assigned: Unassigned » pdureau
Status: Active » Needs review
mogtofu33’s picture

Assigned: pdureau » Unassigned
Status: Needs review » Closed (duplicate)

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.