Skip to content

Commit dcbfff7

Browse files
Amusacclaude
andauthored
fix(next/image): don't share the requester's socket with the internal image response (#98168)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent ac55fd9 commit dcbfff7

2 files changed

Lines changed: 78 additions & 7 deletions

File tree

‎packages/next/src/server/image-optimizer.ts‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { ImageConfigComplete } from '../shared/lib/image-config'
88
import { hasLocalMatch } from '../shared/lib/match-local-pattern'
99
import { hasRemoteMatch } from '../shared/lib/match-remote-pattern'
1010
import type { NextConfigComplete, NextConfigRuntime } from './config-shared'
11-
import { createRequestResponseMocks } from './lib/mock-request'
11+
import { MockedRequest, MockedResponse } from './lib/mock-request'
1212
import type { NextUrlWithParsedQuery } from './request-meta'
1313
import {
1414
CachedRouteKind,
@@ -644,12 +644,24 @@ export async function fetchInternalImage(
644644
// Coerce HEAD to GET to avoid issues with the image optimizer
645645
const method = !_req.method || _req.method === 'HEAD' ? 'GET' : _req.method
646646

647-
const mocked = createRequestResponseMocks({
648-
url: href,
649-
method,
650-
socket: _req.socket,
651-
maximumResponseBody,
652-
})
647+
// The mocked request keeps the requester's socket so that protocol and
648+
// remote address detection keep working, but the mocked response must not
649+
// reference it. `send` (used by `serveStatic`) watches `res.socket` through
650+
// `on-finished` and treats the response as finished as soon as that socket
651+
// stops being writable. When the requester disconnected mid-transfer this
652+
// tore down the file stream without ever ending the mocked response, and
653+
// since `ResponseCache` coalesces every request for the same cache key onto
654+
// this single fetch, the key stayed pending for every later requester until
655+
// the server restarted.
656+
const mocked = {
657+
req: new MockedRequest({
658+
url: href,
659+
method,
660+
headers: {},
661+
socket: _req.socket,
662+
}),
663+
res: new MockedResponse({ maximumResponseBody }),
664+
}
653665

654666
await handleRequest(mocked.req, mocked.res, parseReqUrl(href))
655667
await mocked.res.hasStreamed

‎test/unit/image-optimizer/fetch-internal-image.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import {
33
fetchInternalImage,
44
ImageError,
55
} from 'next/dist/server/image-optimizer'
6+
import { serveStatic } from 'next/dist/server/serve-static'
7+
import { EventEmitter } from 'events'
8+
import { promises as fs } from 'fs'
69
import type { IncomingMessage, ServerResponse } from 'http'
10+
import type { Socket } from 'net'
11+
import { tmpdir } from 'os'
12+
import { join } from 'path'
713

814
describe('fetchInternalImage', () => {
915
describe('non-2xx responses', () => {
@@ -206,4 +212,57 @@ describe('fetchInternalImage', () => {
206212
expect(result.buffer.length).toBe(maximumResponseBody)
207213
})
208214
})
215+
describe('when the requesting client disconnects', () => {
216+
it('should complete the internal fetch even if the requester socket is no longer writable', async () => {
217+
const size = 1024 * 1024
218+
const fileName = 'disconnect.png'
219+
const dir = await fs.mkdtemp(join(tmpdir(), 'fetch-internal-image-'))
220+
await fs.writeFile(join(dir, fileName), Buffer.alloc(size, 1))
221+
222+
try {
223+
// Emulate a requester whose connection already went away. `send`
224+
// (used by `serveStatic`) consults `on-finished`, which treats a
225+
// response as finished as soon as `res.socket.writable` is false.
226+
const closedSocket = Object.assign(new EventEmitter(), {
227+
writable: false,
228+
destroyed: true,
229+
}) as unknown as Socket
230+
const mockReq = {
231+
method: 'GET',
232+
socket: closedSocket,
233+
} as unknown as IncomingMessage
234+
const mockRes = {} as ServerResponse
235+
236+
const handleRequest = (req: IncomingMessage, res: ServerResponse) =>
237+
serveStatic(req, res, fileName, { root: dir })
238+
239+
const result = await Promise.race([
240+
fetchInternalImage(
241+
`/${fileName}`,
242+
mockReq,
243+
mockRes,
244+
300_000_000,
245+
handleRequest
246+
),
247+
new Promise<never>((_resolve, reject) => {
248+
const timer = setTimeout(
249+
() =>
250+
reject(
251+
new Error(
252+
'fetchInternalImage did not settle after the requester disconnected'
253+
)
254+
),
255+
5_000
256+
)
257+
timer.unref()
258+
}),
259+
])
260+
261+
expect(result.buffer.length).toBe(size)
262+
expect(result.contentType).toBe('image/png')
263+
} finally {
264+
await fs.rm(dir, { recursive: true, force: true })
265+
}
266+
})
267+
})
209268
})

0 commit comments

Comments
 (0)