Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cannot call write after a stream was destroyed #69

Closed
michelalbers opened this issue Jul 18, 2023 · 17 comments · Fixed by #75
Closed

Cannot call write after a stream was destroyed #69

michelalbers opened this issue Jul 18, 2023 · 17 comments · Fixed by #75
Assignees
Labels
bug Something isn't working released Has been released and published

Comments

@michelalbers
Copy link

Screenshot
image

Expected Behaviour
Do not write to the stream after it is closed / destroyed

Actual Behaviour
Writes to stream after it was destroyed which results in an error.

Debug Information
This bug resulted when using the use/express handler created with a createHandler call. According to the code there is no check in place wether the stream was closed in the meantime.

Further Information
Maybe introduce a variable let cancelled = false; and set it to true once the stream was closed. Check for cancelled before trying to do a .write().

@enisdenjo
Copy link
Owner

enisdenjo commented Jul 19, 2023

Hmm, I thought that the close listener above the loop should suffice, but apparently not.

res.once('close', body.return);
for await (const value of body) {
await new Promise<void>((resolve, reject) =>
res.write(value, (err) => (err ? reject(err) : resolve())),
);
}

I am trying to write a failing test now and then I'll proceed with fixing the bug.

Thanks for reporting!

@enisdenjo enisdenjo added the bug Something isn't working label Jul 19, 2023
@enisdenjo
Copy link
Owner

I am struggling a bit with the failing test, can you share more details on when this happens? Or even better, a repro?

@enisdenjo enisdenjo self-assigned this Jul 20, 2023
@enisdenjo
Copy link
Owner

@michelalbers any luck with the repro?

@enisdenjo
Copy link
Owner

Unable to reproduce. Added some tests too.

@enisdenjo enisdenjo closed this as not planned Won't fix, can't repro, duplicate, stale Aug 7, 2023
@groundmuffin
Copy link

groundmuffin commented Aug 21, 2023

Same error:
Node version: 16.14.2

Error: Cannot call write after a stream was destroyed
at new NodeError (node:internal/errors:371:5)
at write_ (node:_http_outgoing:750:11)
at ServerResponse.write (node:_http_outgoing:707:15)
at /Users/john/Projects/graphql-api/node_modules/graphql-sse/lib/use/express.js:75:56
at new Promise ()
at handleRequest (/Users/john/Projects/graphql-api/node_modules/graphql-sse/lib/use/express.js:75:19)
at runMicrotasks ()
at processTicksAndRejections (node:internal/process/task_queues:96:5)
at /Users/john/Projects/graphql-api/src/express.js:162:5 {
code: 'ERR_STREAM_DESTROYED'
}
node:internal/errors:464
ErrorCaptureStackTrace(err);
^

Error: Cannot render headers after they are sent to the client
at new NodeError (node:internal/errors:371:5)
at ServerResponse.writeHead (node:_http_server:312:13)
at ServerResponse.writeHead (/Users/john/Projects/graphql-api/node_modules/on-headers/index.js:44:26)
at ServerResponse.writeHead (/Users/john/Projects/graphql-api/node_modules/on-headers/index.js:44:26)
at /Users/john/Projects/graphql-api/src/express.js:166:9
at runMicrotasks ()
at processTicksAndRejections (node:internal/process/task_queues:96:5) {
code: 'ERR_HTTP_HEADERS_SENT'
}

Process finished with exit code 1

@enisdenjo
Copy link
Owner

A repro would be very helpful as I am unable to create one myself.

@groundmuffin
Copy link

groundmuffin commented Aug 21, 2023

I'm unable to reproduce it every time as well, but the following code seems unsafe in certain situation:
:node_modules/graphql-sse/lib/use/express.js:75:56

for await (const value of body) {
        await new Promise((resolve, reject) => res.write(value, (err) => (err ? reject(err) : resolve())));
}

Error: Cannot call write after a stream was destroyed

It looks like the response stream/object was destroyed (for whatever reason), but the promise callback is not canceled.

Also, I put the subscription handler in a try/catch block, as the docs specifies, but the node process crash with an unhandled exception :

app.use(
  GQL_SUBSCRIPTIONS_ENDPOINT,
  async (req, res) => {
	  try {
		  await subscriptionsHandler(req, res)
	  }
	  catch (err) {
		  console.error(err)
		  res.writeHead(500).end()
	  }
  }
)

@enisdenjo
Copy link
Owner

The write loop should break as soon as the connection closes (see #69 (comment)).

This line takes care of it:

res.once('close', body.return);

I am suspicious that express itself is late in reporting the closed connection event.

@groundmuffin
Copy link

Lets assume express actually is late in reporting the closed connection event. Isn't that enough reason to check the res availability before calling .write on it?

@groundmuffin
Copy link

groundmuffin commented Aug 22, 2023

Also I suppose that setting a handler on a 'close' event put it on a Macrotask queue (Node Event Loop), while setting a promise callback put it on a Microtask queue. Because of the fact that Microtask queue is consulted more frequently by design, we can meet a situation when the "for loop" handler is called before the "once" one.

@enisdenjo
Copy link
Owner

Both of your points make perfect sense @groundmuffin, I'll make the adjustments. Thanks!

@enisdenjo enisdenjo reopened this Aug 22, 2023
enisdenjo added a commit that referenced this issue Aug 22, 2023
enisdenjo pushed a commit that referenced this issue Aug 22, 2023
## [2.2.2](v2.2.1...v2.2.2) (2023-08-22)

### Bug Fixes

* **use/http,use/http2,use/express,use/fastify:** Handle cases where response's `close` event is late ([#75](#75)) ([4457cba](4457cba)), closes [#69](#69)
@enisdenjo
Copy link
Owner

🎉 This issue has been resolved in version 2.2.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

@enisdenjo enisdenjo added the released Has been released and published label Aug 22, 2023
@enisdenjo
Copy link
Owner

@michelalbers, @groundmuffin the v2.2.2 includes a fix, would be great if you can test it out and report if the issue persists. Thank you for reporting and advising!

@groundmuffin
Copy link

groundmuffin commented Aug 23, 2023

It's still happening

Error: Cannot call write after a stream was destroyed
    at new NodeError (node:internal/errors:371:5)
    at write_ (node:_http_outgoing:750:11)
    at ServerResponse.write (node:_http_outgoing:707:15)
    at closed (/Users/john/Projects/graphql-api/node_modules/graphql-sse/lib/use/express.js:81:25)
    at new Promise (<anonymous>)
    at handleRequest (/Users/john/Projects/graphql-api/node_modules/graphql-sse/lib/use/express.js:75:34)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at /Users/john/Projects/graphql-api/src/express.js:162:5 {
  code: 'ERR_STREAM_DESTROYED'
}
node:internal/errors:464
    ErrorCaptureStackTrace(err);
    ^

Error: Cannot render headers after they are sent to the client
    at new NodeError (node:internal/errors:371:5)
    at ServerResponse.writeHead (node:_http_server:312:13)
    at ServerResponse.writeHead (/Users/john/Projects/graphql-api/node_modules/on-headers/index.js:44:26)
    at ServerResponse.writeHead (/Users/john/Projects/graphql-api/node_modules/on-headers/index.js:44:26)
    at /Users/john/Projects/graphql-api/src/express.js:166:9
    at processTicksAndRejections (node:internal/process/task_queues:96:5) {
  code: 'ERR_HTTP_HEADERS_SENT'
}

@enisdenjo
Copy link
Owner

Wow, then the .closed flag is wrong too. 🤔

enisdenjo added a commit that referenced this issue Aug 23, 2023
…tead of `closed` before writing to response

Closes #69
enisdenjo pushed a commit that referenced this issue Aug 23, 2023
## [2.2.3](v2.2.2...v2.2.3) (2023-08-23)

### Bug Fixes

* **use/http,use/http2,use/express,use/fastify:** Check `writable` instead of `closed` before writing to response ([3c71f69](3c71f69)), closes [#69](#69)
@enisdenjo
Copy link
Owner

🎉 This issue has been resolved in version 2.2.3 🎉

The release is available on:

Your semantic-release bot 📦🚀

@enisdenjo
Copy link
Owner

@groundmuffin, @michelalbers can you try out v2.2.3? I use the .writable flag there instead.

pholuj-candis pushed a commit to CandisIO/graphql-sse that referenced this issue Oct 10, 2024
# 1.0.0 (2024-10-10)

### Bug Fixes

* Add file extensions to imports/exports in ESM type definitions ([bbf23b1](bbf23b1))
* Add koa exports to package.json ([enisdenjo#85](https://github.com/CandisIO/graphql-sse/issues/85)) ([e99cf99](e99cf99))
* Add support for `graphql@v16` ([89367f2](89367f2))
* Add types path to package.json `exports` ([44f95b6](44f95b6))
* Bump `graphql` version to v16 in package.json ([af219f9](af219f9))
* **client:** Abort request when reporting error ([91057bd](91057bd))
* **client:** Avoid bundling DOM types, have the implementor supply his own `Response` type ([98780c0](98780c0))
* **client:** Leverage active streams for reliable network error retries ([607b468](607b468))
* **client:** Network errors during event emission contain the keyword "stream" in Firefox ([054f16b](054f16b))
* **client:** Operation requests are of application/json content-type ([0084de7](0084de7))
* **client:** Respect retry attempts when server goes away after connecting ([enisdenjo#57](https://github.com/CandisIO/graphql-sse/issues/57)) ([75c9f17](75c9f17)), closes [enisdenjo#55](https://github.com/CandisIO/graphql-sse/issues/55)
* **client:** Respect retry attempts when server goes away after connecting in single connection mode ([enisdenjo#59](https://github.com/CandisIO/graphql-sse/issues/59)) ([e895c5b](e895c5b)), closes [enisdenjo#55](https://github.com/CandisIO/graphql-sse/issues/55)
* **client:** Retry if connection is closed while having active streams ([83a0178](83a0178)), closes [enisdenjo#28](https://github.com/CandisIO/graphql-sse/issues/28)
* **client:** Retry network errors even if they occur during event emission ([489b1b0](489b1b0)), closes [enisdenjo#27](https://github.com/CandisIO/graphql-sse/issues/27)
* **client:** Should not call complete after subscription error ([d8b7634](d8b7634))
* **client:** TypeScript generic for ensuring proper arguments when using "single connection mode" ([be2ae7d](be2ae7d))
* **client:** Use closures instead of bindings (with `this`) ([8ecdf3c](8ecdf3c))
* Define graphql execution results ([89da803](89da803))
* **handler:** Always include the `data` field in stream messages ([enisdenjo#71](https://github.com/CandisIO/graphql-sse/issues/71)) ([4643c9a](4643c9a))
* **handler:** Correct typings and support for http2 ([08d6ca3](08d6ca3)), closes [enisdenjo#38](https://github.com/CandisIO/graphql-sse/issues/38)
* **handler:** Detect `ExecutionArgs` in `onSubscribe` return value ([a16b921](a16b921)), closes [enisdenjo#58](https://github.com/CandisIO/graphql-sse/issues/58)
* **handler:** Support generics for requests and responses ([9ab10c0](9ab10c0))
* **handler:** Use 3rd `body` argument only if is object or string ([2062579](2062579))
* Prefer `X-GraphQL-Event-Stream-Token` header name for clarity ([9aaa0a9](9aaa0a9))
* remove package.json workspaces entry in release ([c6dc093](c6dc093))
* Request parameters `query` field can only be a string ([16c9600](16c9600)), closes [enisdenjo#65](https://github.com/CandisIO/graphql-sse/issues/65)
* **server:** Operation result can be async generator or iterable ([24b6078](24b6078))
* **use/express,use/fastify:** Resolve body if previously parsed ([6573e94](6573e94))
* **use/express:** make sure that we not send something through previously closed stream ([e4b8c5f](e4b8c5f))
* **use/fastify:** Include middleware headers ([134c1b0](134c1b0)), closes [enisdenjo#91](https://github.com/CandisIO/graphql-sse/issues/91)
* **use/http,use/http2,use/express,use/fastify:** Check `writable` instead of `closed` before writing to response ([3c71f69](3c71f69)), closes [enisdenjo#69](https://github.com/CandisIO/graphql-sse/issues/69)
* **use/http,use/http2,use/express,use/fastify:** Handle cases where response's `close` event is late ([enisdenjo#75](https://github.com/CandisIO/graphql-sse/issues/75)) ([4457cba](4457cba)), closes [enisdenjo#69](https://github.com/CandisIO/graphql-sse/issues/69)
* **use/koa:** Use parsed body from request ([enisdenjo#87](https://github.com/CandisIO/graphql-sse/issues/87)) ([b290b90](b290b90))

### Features

* Client ([enisdenjo#3](https://github.com/CandisIO/graphql-sse/issues/3)) ([754487d](754487d))
* **client:** Accept `referrer` and `referrerPolicy` fetch options ([enisdenjo#32](https://github.com/CandisIO/graphql-sse/issues/32)) ([dbaa90a](dbaa90a))
* **client:** Add `credentials` property for requests ([79d0266](79d0266))
* **client:** Add `lazyCloseTimeout` as a close timeout after last operation completes ([16e5e31](16e5e31)), closes [enisdenjo#17](https://github.com/CandisIO/graphql-sse/issues/17)
* **client:** Async iterator for subscriptions ([enisdenjo#66](https://github.com/CandisIO/graphql-sse/issues/66)) ([fb8bf11](fb8bf11))
* **client:** Event listeners for both operation modes ([enisdenjo#84](https://github.com/CandisIO/graphql-sse/issues/84)) ([6274f44](6274f44))
* **client:** Inspect incoming messages through `ClientOptions.onMessage` ([496e74b](496e74b)), closes [enisdenjo#20](https://github.com/CandisIO/graphql-sse/issues/20)
* **handler:** Export handler options type for each integration ([2a2e517](2a2e517))
* **handler:** Server and environment agnostic handler ([enisdenjo#37](https://github.com/CandisIO/graphql-sse/issues/37)) ([22cf03d](22cf03d))
* **handler:** Use Koa ([enisdenjo#80](https://github.com/CandisIO/graphql-sse/issues/80)) ([283b453](283b453)), closes [enisdenjo#78](https://github.com/CandisIO/graphql-sse/issues/78)
* Server request handler ([enisdenjo#2](https://github.com/CandisIO/graphql-sse/issues/2)) ([8381796](8381796))
* **use/koa:** expose full Koa context to options ([enisdenjo#86](https://github.com/CandisIO/graphql-sse/issues/86)) ([b37a6f9](b37a6f9))

### Performance Improvements

* **client:** Avoid recreating result variables when reading the response stream ([16f6a6c](16f6a6c))

### BREAKING CHANGES

* **handler:** The handler is now server agnostic and can run _anywhere_

- Core of `graphql-sse` is now server agnostic and as such offers a handler that implements a generic request/response model
- Handler does not await for whole operation to complete anymore. Only the processing part (parsing, validating and executing)
- GraphQL context is now typed
- Hook arguments have been changed, they're not providing the Node native req/res anymore - they instead provide the generic request/response
- `onSubscribe` hook can now return an execution result too (useful for caching for example)
- Throwing in `onNext` and `onComplete` hooks will bubble the error to the returned iterator

### Migration

Even though the core of graphql-sse is now completely server agnostic, there are adapters to ease the integration with existing solutions. Migrating is actually not a headache!

Beware that the adapters **don't** handle internal errors, it's your responsibility to take care of that and behave accordingly.

#### [`http`](https://nodejs.org/api/http.html)

```diff
import http from 'http';
- import { createHandler } from 'graphql-sse';
+ import { createHandler } from 'graphql-sse/lib/use/http';

// Create the GraphQL over SSE handler
const handler = createHandler({ schema });

// Create an HTTP server using the handler on `/graphql/stream`
const server = http.createServer((req, res) => {
  if (req.url.startsWith('/graphql/stream')) {
    return handler(req, res);
  }
  res.writeHead(404).end();
});

server.listen(4000);
console.log('Listening to port 4000');
```

#### [`http2`](https://nodejs.org/api/http2.html)

```diff
import fs from 'fs';
import http2 from 'http2';
- import { createHandler } from 'graphql-sse';
+ import { createHandler } from 'graphql-sse/lib/use/http2';

// Create the GraphQL over SSE handler
const handler = createHandler({ schema });

// Create an HTTP server using the handler on `/graphql/stream`
const server = http.createServer((req, res) => {
  if (req.url.startsWith('/graphql/stream')) {
    return handler(req, res);
  }
  res.writeHead(404).end();
});

server.listen(4000);
console.log('Listening to port 4000');
```

#### [`express`](https://expressjs.com/)

```diff
import express from 'express'; // yarn add express
- import { createHandler } from 'graphql-sse';
+ import { createHandler } from 'graphql-sse/lib/use/express';

// Create the GraphQL over SSE handler
const handler = createHandler({ schema });

// Create an express app
const app = express();

// Serve all methods on `/graphql/stream`
app.use('/graphql/stream', handler);

server.listen(4000);
console.log('Listening to port 4000');
```

#### [`fastify`](https://www.fastify.io/)

```diff
import Fastify from 'fastify'; // yarn add fastify
- import { createHandler } from 'graphql-sse';
+ import { createHandler } from 'graphql-sse/lib/use/fastify';

// Create the GraphQL over SSE handler
const handler = createHandler({ schema });

// Create a fastify app
const fastify = Fastify();

// Serve all methods on `/graphql/stream`
fastify.all('/graphql/stream', handler);

fastify.listen({ port: 4000 });
console.log('Listening to port 4000');
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working released Has been released and published
Projects
None yet
3 participants