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.
What happens
POST /api/books/:id/generate-nextanswers 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, inpipeHubToSse:On Node 16 and later,
IncomingMessage'scloseevent 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.tskeeps 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-nextPOST /api/books/:id/chapters/:num/regenerateGET /api/books/:id/generation-stream(the reconnect path)The other streaming routes are unaffected, because
openSseStreamattaches 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
startServerpath, with a seeded book on disk and a real socket:The same request through
fastify.injectreturns 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 checkingreply.raw.writableEndedorrequest.raw.destroyedinside the close handler distinguishes the two cases on current Node. Whatever the fix, it wants a test that exercises a real socket rather thaninject, sinceinjectcannot see this failure mode.Related
Phase 6 quarantines two journey assertions on this, each with a
test.fixmeciting this issue. They flip to green when this is fixed.