Skip to content

Chapter generation SSE closes before writing a byte, so the reader hangs forever #50

Description

@rsml

What happens

POST /api/books/:id/generate-next answers 200 with an empty body. The reader submits chapter feedback, enters its generating state, and sits there with a blinking caret forever. Generation itself completes and the chapter is written to disk correctly, so a reload recovers, but the streaming UI never receives a single event and never leaves the generating phase.

Found by the Phase 6 end-to-end journey suite, which is exactly the class of bug it was built to catch.

Root cause

server/routes/generation.ts, in pipeHubToSse:

request.raw.on('close', () => {
  unsubscribe()
  if (!ended) { ended = true; reply.raw.end() }
})

On Node 16 and later, IncomingMessage's close event fires when the request stream is finished, not only when the client disconnects. For a POST whose body Fastify has already read and parsed, that is immediately. The handler therefore unsubscribes and ends the reply before the generation has produced its first chunk.

server/services/chapter-generation-stream.ts keeps running, finishes the chapter, and saves it, so nothing is lost. The client just never hears about it.

Blast radius

All three routes that share pipeHubToSse.

  • POST /api/books/:id/generate-next
  • POST /api/books/:id/chapters/:num/regenerate
  • GET /api/books/:id/generation-stream (the reconnect path)

The other streaming routes are unaffected, because openSseStream attaches no close listener. That is why creating a book and generating chapter 1 through the wizard both work, and why this went unnoticed.

Reproduction, no browser and no fakes involved

A standalone script against the production startServer path, with a seeded book on disk and a real socket:

PROBE listening on 61110
PROBE status 200 hasBody true
PROBE stream ended. bytes: 0
PROBE content: ""

The same request through fastify.inject returns the full event stream, because light-my-request does not model the socket lifecycle, which is why the existing inject-based tests pass.

Suggested direction

Distinguish a real disconnect from a completed request body. request.raw.on('aborted', ...) covers the pre-Node-17 signal, and checking reply.raw.writableEnded or request.raw.destroyed inside the close handler distinguishes the two cases on current Node. Whatever the fix, it wants a test that exercises a real socket rather than inject, since inject cannot see this failure mode.

Related

Phase 6 quarantines two journey assertions on this, each with a test.fixme citing this issue. They flip to green when this is fixed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions