Repository: nodejs/node
Pull request: http: add req.signal to IncomingMessage

Fixes: https://github.com/nodejs/node/issues/62481

## Summary

Adds a lazy `signal` getter to `IncomingMessage` that returns an
`AbortSignal` which aborts when the request is closed or aborted.
This mirrors the Web Fetch API's `Request.signal` and Deno's
`request.signal`.

## Motivation

Currently, cancelling async work (DB queries, fetch calls) when a
client disconnects requires manual boilerplate in every request handler:
```js
// before
server.on('request', async (req, res) => {
  const ac = new AbortController();
  req.on('close', () => ac.abort());
  const data = await fetch('https://slow-api.com', { signal: ac.signal });
  res.end(JSON.stringify(data));
});
```

With this change:
```js
// after
server.on('request', async (req, res) => {
  const data = await fetch('https://slow-api.com', { signal: req.signal });
  res.end(JSON.stringify(data));
});
```

## Design

**Lazy initialization** — `AbortController` is only created when
`req.signal` is first accessed. Zero overhead for handlers that
do not use it.

**Single listener** — listens on `this.once('close')` and
`this.once('abort')` rather than `socket.once('close')` directly,
since the request stream's `close` event fires in all teardown paths:
- socket close propagates through `_destroy()` to req `close`
- socketless `req.destroy()` fires req `close` directly
- client abort fires req `abort` directly

**Race condition** — if `.signal` is accessed after `req.destroy()`
has already fired, `this.destroyed` is checked and the signal is
aborted immediately rather than registering a listener that would
never fire.

**`configurable: true`** — allows frameworks (Express, Fastify, Koa)
to override the property on their own subclasses.

## Changes

- `lib/_http_incoming.js` — adds `signal` getter to `IncomingMessage`
- `test/parallel/test-http-request-signal.js` — adds tests

## Tests

- `req.signal` is an `AbortSignal` and not aborted initially
- signal aborts when `'abort'` event fires
- signal aborts when `'close'` event fires (client disconnect)
- signal is pre-aborted if accessed after `req.destroy()` (race condition)
- multiple accesses return the same signal instance (lazy init)



---

Fixes: https://github.com/nodejs/node/issues/62481

Changed files:
filename: doc/api/http.md
patch: @@ -2989,6 +2989,51 @@ added: v0.5.9
 
 Calls `message.socket.setTimeout(msecs, callback)`.
 
+### `message.signal`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* Type: {AbortSignal}
+
+An {AbortSignal} that is aborted when the underlying socket closes or the
+request is destroyed. The signal is created lazily on first access — no
+{AbortController} is allocated for requests that never use this property.
+
+This is useful for cancelling downstream asynchronous work such as database
+queries or `fetch` calls when a client disconnects mid-request.
+
+```mjs
+import http from 'node:http';
+
+http.createServer(async (req, res) => {
+  try {
+    const data = await fetch('https://example.com/api', { signal: req.signal });
+    res.end(JSON.stringify(await data.json()));
+  } catch (err) {
+    if (err.name === 'AbortError') return;
+    res.statusCode = 500;
+    res.end('Internal Server Error');
+  }
+}).listen(3000);
+```
+
+```cjs
+const http = require('node:http');
+
+http.createServer(async (req, res) => {
+  try {
+    const data = await fetch('https://example.com/api', { signal: req.signal });
+    res.end(JSON.stringify(await data.json()));
+  } catch (err) {
+    if (err.name === 'AbortError') return;
+    res.statusCode = 500;
+    res.end('Internal Server Error');
+  }
+}).listen(3000);
+```
+
 ### `message.socket`
 
 <!-- YAML

filename: lib/_http_incoming.js
patch: @@ -29,12 +29,15 @@ const {
 
 const { Readable, finished } = require('stream');
 
+const { AbortController } = require('internal/abort_controller');
+
 const kHeaders = Symbol('kHeaders');
 const kHeadersDistinct = Symbol('kHeadersDistinct');
 const kHeadersCount = Symbol('kHeadersCount');
 const kTrailers = Symbol('kTrailers');
 const kTrailersDistinct = Symbol('kTrailersDistinct');
 const kTrailersCount = Symbol('kTrailersCount');
+const kAbortController = Symbol('kAbortController');
 
 function readStart(socket) {
   if (socket && !socket._paused && socket.readable)
@@ -90,6 +93,7 @@ function IncomingMessage(socket) {
   // Flag for when we decide that this message cannot possibly be
   // read by the user, so there's no point continuing to handle it.
   this._dumped = false;
+  this[kAbortController] = null;
 }
 ObjectSetPrototypeOf(IncomingMessage.prototype, Readable.prototype);
 ObjectSetPrototypeOf(IncomingMessage, Readable);
@@ -184,6 +188,25 @@ ObjectDefineProperty(IncomingMessage.prototype, 'trailersDistinct', {
   },
 });
 
+ObjectDefineProperty(IncomingMessage.prototype, 'signal', {
+  __proto__: null,
+  configurable: true,
+  get: function() {
+    if (this[kAbortController] === null) {
+      const ac = new AbortController();
+      this[kAbortController] = ac;
+      if (this.destroyed) {
+        ac.abort();
+      } else {
+        this.once('close', function() {
+          ac.abort();
+        });
+      }
+    }
+    return this[kAbortController].signal;
+  },
+});
+
 IncomingMessage.prototype.setTimeout = function setTimeout(msecs, callback) {
   if (callback)
     this.on('timeout', callback);

filename: test/parallel/test-http-request-signal.js
patch: @@ -0,0 +1,85 @@
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const http = require('http');
+
+// Test 1: req.signal is an AbortSignal and aborts on 'close'
+{
+  const server = http.createServer(common.mustCall((req, res) => {
+    assert.ok(req.signal instanceof AbortSignal);
+    assert.strictEqual(req.signal.aborted, false);
+    req.signal.onabort = common.mustCall(() => {
+      assert.strictEqual(req.signal.aborted, true);
+    });
+    res.destroy();
+  }));
+  server.listen(0, common.mustCall(() => {
+    http.get({ port: server.address().port }, () => {}).on('error', () => {
+      server.close();
+    });
+  }));
+}
+
+// Test 2: req.signal is aborted if accessed after destroy
+{
+  const req = new http.IncomingMessage(null);
+  req.destroy();
+  assert.strictEqual(req.signal.aborted, true);
+}
+
+// Test 3: Multiple accesses return the same signal
+{
+  const req = new http.IncomingMessage(null);
+  assert.strictEqual(req.signal, req.signal);
+}
+
+
+// Test 4: res.signal on a client-side http.request() response (IncomingMessage).
+{
+  const server = http.createServer(common.mustCall((req, res) => {
+    res.writeHead(200);
+    res.write('partial');
+  }));
+
+  server.listen(0, common.mustCall(() => {
+    const clientReq = http.request(
+      { port: server.address().port },
+      common.mustCall((res) => {
+        assert.ok(res.signal instanceof AbortSignal);
+        assert.strictEqual(res.signal.aborted, false);
+
+        res.signal.onabort = common.mustCall(() => {
+          assert.strictEqual(res.signal.aborted, true);
+          server.close();
+        });
+        clientReq.destroy();
+      }),
+    );
+    clientReq.on('error', () => {});
+    clientReq.end();
+  }));
+}
+
+// Test 5: Client cancels a pending request.
+{
+  const server = http.createServer(common.mustCall((req, res) => {
+    req.signal.onabort = common.mustCall(() => {
+      assert.strictEqual(req.signal.aborted, true);
+      server.close();
+    });
+    res.flushHeaders();
+  }));
+
+  server.listen(0, common.mustCall(() => {
+    const clientReq = http.request(
+      { port: server.address().port },
+      common.mustCall((res) => {
+        res.on('error', () => {});
+        clientReq.destroy();
+      }),
+    );
+    clientReq.on('error', () => {});
+    clientReq.end();
+  }));
+}