Skip to content

Commit 9f12427

Browse files
kriszypclaude
andauthored
Fix corrupt gzip from withNodeAdapter middleware on Node 26.11+, and return install_node_modules' npm report as an object on every npm (#3108) (#3393)
* Keep piped chunks on a patched write() under Node 26.11, and parse npm's report behind its dry-run diff Node 26.11.0's Readable.pipe fast path (nodejs/node#66182) calls _write() directly for a byte-mode Writable destination, skipping write() overrides. withNodeAdapter's response is a real PassThrough, so compression's res.write patch was skipped for send(...).pipe(res): corrupt gzip on the wire, or a stalled body. On runtimes with the fast path the response gives each pipe source a second, inert 'data' listener, which keeps pipe on dest.write(), and removes it on unpipe. npm before 11.20 prints the human dry-run diff ahead of its --json report, so install_node_modules returned that output as an unparsed string; npm 11.20 dropped the diff and the dry_run tests' match on it. Parse the report after any prefix and assert on its added count. Claude-Session: https://claude.ai/code/session_013r4w8GFxG9nBkMVeRf7bbR Dispatch-Task: main-red-kriszyp_harper_26a914501_38e57eff * Release the pipe guard before pipe's own unpipe cleanup, so a stalled source stays paused pipe's cleanup resumes a source awaiting 'drain' while any 'data' listener remains, so with the guard's listener still attached an unpipe or a destroyed response left the source flowing into nothing and its buffered data was lost. Claude-Session: https://claude.ai/code/session_013r4w8GFxG9nBkMVeRf7bbR Dispatch-Task: main-red-kriszyp_harper_26a914501_38e57eff * Guard only core Readable sources, which legacy pipe() never releases Legacy Stream.prototype.pipe emits 'pipe' but never 'unpipe', and neither it nor a userland pipe() takes Node's fast path, so the inert listener is skipped for them. Claude-Session: https://claude.ai/code/session_013r4w8GFxG9nBkMVeRf7bbR Dispatch-Task: main-red-kriszyp_harper_26a914501_38e57eff * Skip the pipe guard for sources the fast path never takes, and release it from one constructor listener Object-mode and decoded sources cannot take Node 26.11's pipe fast path, so the inert listener only cost them a call per chunk. One 'unpipe' listener prepended in the constructor replaces the per-pipe release closure and still runs ahead of each pipe's own cleanup. Claude-Session: https://claude.ai/code/session_013r4w8GFxG9nBkMVeRf7bbR Dispatch-Task: main-red-kriszyp_harper_26a914501_38e57eff * Tolerate an 'unpipe' emitted without a source Core and readable-stream always pass the source, but a bare res.emit('unpipe') from third-party middleware must not throw out of the guard's release. Claude-Session: https://claude.ai/code/session_013r4w8GFxG9nBkMVeRf7bbR Dispatch-Task: main-red-kriszyp_harper_26a914501_38e57eff * Float the Node 26 unit-test leg to the newest 26.x, as integration tests do Without check-latest, setup-node prefers a cached 26.x, on which the pipe fast-path regression test passes with the guard removed. Claude-Session: https://claude.ai/code/session_013r4w8GFxG9nBkMVeRf7bbR Dispatch-Task: main-red-kriszyp_harper_26a914501_38e57eff * Check which package a dry run would add when npm reports it npm 11's dry-run report names the packages in its add entries; npm 10's only counts them, so the name is asserted when present. Claude-Session: https://claude.ai/code/session_013r4w8GFxG9nBkMVeRf7bbR Dispatch-Task: main-red-kriszyp_harper_26a914501_38e57eff * Release the pipe guard only from a Readable, matching where it attaches Claude-Session: https://claude.ai/code/session_013r4w8GFxG9nBkMVeRf7bbR Dispatch-Task: main-red-kriszyp_harper_26a914501_38e57eff --------- (cherry picked from commit b6b5edd) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8305567 commit 9f12427

6 files changed

Lines changed: 151 additions & 21 deletions

File tree

‎.github/workflows/unit-test.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ jobs:
4747
with:
4848
node-version: ${{ matrix.node-version }}
4949
package-manager-cache: false
50+
# As in integration-tests.yml: the 26 leg floats to the newest 26.x rather than a cached one, so
51+
# tests that only fail on the newest runtime (nodeAdapterMiddleware's pipe fast-path case) run there.
52+
check-latest: ${{ matrix.node-version == 26 }}
5053

5154
- name: Install dependencies
5255
timeout-minutes: 6

‎server/DESIGN.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,7 @@ The SQL and job paths are additive rather than exclusive: `verifyPermsAST` valid
648648
- **Headers commit exactly once, through `this.writeHead`.** `write()`, `end()`, `flushHeaders()` and `_implicitHeader()` all reach `this.writeHead(this.statusCode)` by property lookup, so a `writeHead` that `on-headers` replaced on the instance runs its listeners (the ones that set `Content-Encoding` and remove `Content-Length`) before the promise resolves. After commit, `setHeader`/`appendHeader`/`removeHeader` and a second `writeHead` throw `ERR_HTTP_HEADERS_SENT` as Node's do; `_header` (which `compression` ≤ 1.7 tests instead of `headersSent`) and `finished` (which `on-finished` tests) derive from that state.
649649
- **The adapter owns the `'error'` listener.** A `destroy(err)` right after `writeHead()` emits before the awaiting caller can attach one; the error stays in the stream's `errored` state for `pipeline()`, `finished()` or async iteration. Client disconnect (`Request.signal`) destroys the response without an error after headers (a plain premature close, which `pipeBodyToResponse` treats as routine) and rejects the promise with the abort reason before them; a handler that throws or rejects before ending the response destroys it, and one that fails after `end()` is logged at warn.
650650
- **Header names are case-insensitive on removal too.** `Headers.delete` lowercases like `set`/`get`/`has`; the inherited `Map.delete` silently left `send`'s `Content-Length` on a gzip body (truncated transfers).
651+
- **Every piped chunk reaches `write()` by property lookup.** Node 26.11's `Readable.pipe` fast path (nodejs/node#66182) calls `_write()` directly when pipe's listener is a byte-mode source's only `'data'` listener, so `compression`'s instance patch of `res.write` was skipped for most chunks of `send(...).pipe(res)`: raw bytes inside the gzip stream, or a stall once `compression` had routed pipe's `'drain'` to its idle zlib stream. On runtimes that have the fast path (`'writeKnownBuffer' in Writable`, a static it added) the response adds an inert `'data'` listener to each byte-mode core `Readable` on `'pipe'`, which fails the fast path's single-listener check, and removes it on `'unpipe'` through a listener prepended in the constructor: pipe's own cleanup resumes a source awaiting `'drain'` while any `'data'` listener remains, which would leave an unpiped source flowing into nothing. Object-mode and decoded sources, and legacy `Stream.prototype.pipe` (which never emits `'unpipe'`), are skipped. Remove it once every supported Node 26 release dispatches pipe through an overridden `write()`.
651652
- **Express is not a target.** `express`'s `app.handle()` replaces the response's prototype with one rooted at `http.ServerResponse.prototype`, which no Writable-derived response survives, and would do the same to the request `Proxy`'s target, Harper's real `IncomingMessage`. Middleware that duck-types the response (Next.js, `compression`, `send`, `serve-static`, `finalhandler`, h3, fastify) is the supported surface.
652653

653654
## `manageThreads` has two different `workerCount`s (`server/threads/manageThreads.js`)

‎server/serverHelpers/NodeAdapterResponse.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
ServerResponse as NodeServerResponse,
77
} from 'node:http';
88
import type { Socket } from 'node:net';
9-
import { PassThrough } from 'node:stream';
9+
import { PassThrough, Readable, Writable } from 'node:stream';
1010
import { Headers as ResponseHeaders, applyWriteHeadHeaders } from './Headers.ts';
1111

1212
export interface AdaptedResponse {
@@ -29,6 +29,20 @@ class UnsupportedResponseMethodError extends Error {
2929
}
3030

3131
const ignoreError = () => {};
32+
const ignoreChunk = () => {};
33+
34+
// Node 26.11's pipe fast path (nodejs/node#66182) skips write() overrides such as compression's while pipe's listener
35+
// is a byte-mode source's only 'data' listener; see server/DESIGN.md
36+
const pipeSkipsWrite = 'writeKnownBuffer' in Writable;
37+
function guardPipeSource(source: Readable) {
38+
// object mode, a decoder, and legacy or userland pipe() never take the fast path; legacy pipe() never emits 'unpipe'
39+
if (source instanceof Readable && !source.readableObjectMode && !source.readableEncoding)
40+
source.on('data', ignoreChunk);
41+
}
42+
// must run before pipe's own unpipe cleanup, which resumes a source awaiting 'drain' while any 'data' listener remains
43+
function releasePipeSource(source: unknown) {
44+
if (source instanceof Readable) source.removeListener('data', ignoreChunk);
45+
}
3246

3347
export class NodeAdapterResponse extends PassThrough implements NodeServerResponse {
3448
statusCode = 200;
@@ -60,6 +74,10 @@ export class NodeAdapterResponse extends PassThrough implements NodeServerRespon
6074
this.#resolve = resolve;
6175
this.#reject = reject;
6276
this.on('error', ignoreError);
77+
if (pipeSkipsWrite) {
78+
this.on('pipe', guardPipeSource);
79+
this.prependListener('unpipe', releasePipeSource);
80+
}
6381
if (typeof nodeResponse?.on === 'function') {
6482
const forward = (...args: any[]) => this.emit('timeout', ...args);
6583
let forwarding = false;

‎unitTests/server/serverHelpers/nodeAdapterMiddleware.test.js‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const net = require('node:net');
1111
const os = require('node:os');
1212
const path = require('node:path');
1313
const zlib = require('node:zlib');
14-
const { Readable } = require('node:stream');
14+
const { PassThrough, Readable, Stream } = require('node:stream');
1515
const { setTimeout: sleep } = require('node:timers/promises');
1616
const onFinished = require('on-finished');
1717
const onHeaders = require('on-headers');
@@ -340,6 +340,74 @@ describe('withNodeAdapter with real Node middleware', function () {
340340
assert.deepStrictEqual(events, ['finish', 'close']);
341341
});
342342

343+
for (const objectMode of [false, true]) {
344+
it(`runs a write() that middleware patched for every chunk ${objectMode ? 'an object' : 'a byte'}-mode Readable pipes in`, async function () {
345+
const request = makeRequest();
346+
const chunks = [];
347+
for (let offset = 0; offset < BODY.length; offset += CHUNK_SIZE)
348+
chunks.push(BODY.subarray(offset, offset + CHUNK_SIZE));
349+
let next = 0;
350+
const source = new Readable({
351+
objectMode,
352+
read() {
353+
this.push(next < chunks.length ? chunks[next++] : null);
354+
},
355+
});
356+
let patchedWrites = 0;
357+
const responsePromise = request.withNodeAdapter((req, res) => {
358+
const write = res.write;
359+
res.write = function (...args) {
360+
patchedWrites++;
361+
return write.apply(this, args);
362+
};
363+
source.pipe(res);
364+
});
365+
366+
const { body } = await withTimeout(responsePromise, 'response headers');
367+
const received = await withTimeout(collectSlowly(body), 'the response body');
368+
assert.deepStrictEqual(received, BODY);
369+
assert.strictEqual(patchedWrites, chunks.length);
370+
await waitUntil(() => source.listenerCount('data') === 0, "the source's 'data' listeners to be removed");
371+
});
372+
}
373+
374+
it('leaves a source paused with its data when its stalled pipe is unpiped or the response is destroyed, and no listener on any source', async function () {
375+
const request = makeRequest();
376+
const unpiped = new PassThrough();
377+
const destroyed = new PassThrough();
378+
let response;
379+
const responsePromise = request.withNodeAdapter((req, res) => {
380+
response = res;
381+
res.writeHead(200);
382+
unpiped.pipe(res);
383+
destroyed.pipe(res);
384+
});
385+
await withTimeout(responsePromise, 'response headers');
386+
for (let offset = 0; offset < BODY.length; offset += CHUNK_SIZE)
387+
unpiped.write(BODY.subarray(offset, offset + CHUNK_SIZE));
388+
await waitUntil(() => response.writableNeedDrain && unpiped.readableLength > 0, 'the pipe to stall awaiting drain');
389+
const buffered = unpiped.readableLength;
390+
391+
unpiped.unpipe(response);
392+
await sleep(1);
393+
assert.strictEqual(unpiped.listenerCount('data'), 0);
394+
assert.strictEqual(unpiped.readableFlowing, false);
395+
assert.strictEqual(unpiped.readableLength, buffered);
396+
397+
destroyed.write(BODY.subarray(0, CHUNK_SIZE));
398+
destroyed.write(BODY.subarray(CHUNK_SIZE, 2 * CHUNK_SIZE));
399+
await waitUntil(() => destroyed.readableFlowing === false, 'the second pipe to stall awaiting drain');
400+
const legacy = new Stream();
401+
legacy.pipe(response);
402+
legacy.emit('end');
403+
assert.strictEqual(legacy.listenerCount('data'), 0);
404+
response.destroy();
405+
await waitUntil(() => destroyed.listenerCount('data') === 0, "the destroyed pipe's 'data' listeners to be removed");
406+
await sleep(1);
407+
assert.strictEqual(destroyed.readableFlowing, false);
408+
assert.ok(destroyed.readableLength > 0, 'the source lost the data it had not delivered');
409+
});
410+
343411
it('runs on-headers listeners inside _implicitHeader() before the headers resolve', async function () {
344412
const request = makeRequest();
345413
const order = [];

‎unitTests/utility/npmUtilities.test.js‎

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,17 @@ describe('install_node_modules', function () {
5656
fs.rmSync(lifecycleMarker, { force: true });
5757
});
5858

59-
// npm still reports the dependency it would add under --dry-run, so asserting on that output
59+
// npm still counts the dependency it would add under --dry-run, so asserting on that count
6060
// distinguishes a real dry run from npm never running at all
6161
function assertDryRun(response) {
62-
assert.match(JSON.stringify(response.application.npm_output), /add local-dependency/);
62+
const report = response.application.npm_output;
63+
assert.equal(report.added, 1, JSON.stringify(response.application));
64+
// npm 11 names what it would add; npm 10's report only counts it
65+
if (report.add)
66+
assert.deepStrictEqual(
67+
report.add.map(({ name }) => name),
68+
['local-dependency']
69+
);
6370
assert.equal(installedDependencyExists(), false);
6471
}
6572

@@ -159,30 +166,53 @@ describe('install_node_modules', function () {
159166
await assert.rejects(installModules({ dry_run: true }), { statusCode: 400, message: /'projects'/ });
160167
});
161168

162-
it('runs npm with the registry audit disabled', async function () {
163-
if (process.platform === 'win32') return this.skip(); // the shim below is a POSIX shell script
169+
// the shim is a POSIX shell script; its output and argv files travel as environment values, not as
170+
// text in the script, so a `$` or a backtick in TMPDIR is never expanded by the shell that runs it
171+
async function withNpmShim(stdout, callback) {
164172
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'harper-npm-shim-'));
165173
const originalPath = process.env.PATH;
166-
let argv;
167174
try {
168-
const argvPath = path.join(shimDir, 'argv.txt');
169-
// the destination travels as an environment value, not as text in the script: a `$` or a
170-
// backtick in TMPDIR would otherwise be expanded by the shell that runs this
175+
const stdoutPath = path.join(shimDir, 'stdout.txt');
176+
fs.writeFileSync(stdoutPath, stdout);
171177
fs.writeFileSync(
172178
path.join(shimDir, 'npm'),
173-
'#!/bin/sh\nprintf \'%s\\n\' "$@" > "$HARPER_TEST_NPM_ARGV_PATH"\necho \'{"added":0}\'\n',
179+
'#!/bin/sh\nprintf \'%s\\n\' "$@" > "$HARPER_TEST_NPM_ARGV_PATH"\ncat "$HARPER_TEST_NPM_STDOUT_PATH"\n',
174180
{ mode: 0o755 }
175181
);
176182
process.env.PATH = `${shimDir}${path.delimiter}${originalPath}`;
177-
process.env.HARPER_TEST_NPM_ARGV_PATH = argvPath;
178-
await installModules({ projects: ['application'] });
179-
argv = fs.readFileSync(argvPath, 'utf8').split('\n').filter(Boolean);
183+
process.env.HARPER_TEST_NPM_ARGV_PATH = path.join(shimDir, 'argv.txt');
184+
process.env.HARPER_TEST_NPM_STDOUT_PATH = stdoutPath;
185+
return await callback(process.env.HARPER_TEST_NPM_ARGV_PATH);
180186
} finally {
181187
process.env.PATH = originalPath;
182188
delete process.env.HARPER_TEST_NPM_ARGV_PATH;
189+
delete process.env.HARPER_TEST_NPM_STDOUT_PATH;
183190
fs.rmSync(shimDir, { recursive: true, force: true });
184191
}
192+
}
193+
194+
it('runs npm with the registry audit disabled', async function () {
195+
if (process.platform === 'win32') return this.skip();
196+
const argv = await withNpmShim('{"added":0}\n', async (argvPath) => {
197+
await installModules({ projects: ['application'] });
198+
return fs.readFileSync(argvPath, 'utf8').split('\n').filter(Boolean);
199+
});
185200

186201
assert.deepStrictEqual(argv, ['install', '--force', '--omit=dev', '--no-audit', '--no-fund', '--json']);
187202
});
203+
204+
it('parses the JSON report out of the dry-run diff that npm before 11.20 prints ahead of it', async function () {
205+
if (process.platform === 'win32') return this.skip();
206+
const cases = [
207+
['add local-dependency 1.0.0\n{\n "added": 1\n}\n', { added: 1 }],
208+
['add local-dependency 1.0.0\r\n{\r\n "added": 1\r\n}\r\n', { added: 1 }],
209+
['{\n "added": 1\n}\n', { added: 1 }],
210+
['{"from":"a lifecycle script"}\n{\n "added": 1\n}\n', { added: 1 }],
211+
['not json\n', 'not json'],
212+
];
213+
for (const [stdout, expected] of cases) {
214+
const response = await withNpmShim(stdout, () => installModules({ projects: ['application'], dry_run: true }));
215+
assert.deepStrictEqual(response.application.npm_output, expected, JSON.stringify(stdout));
216+
}
217+
});
188218
});

‎utility/npmUtilities.ts‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,9 @@ export async function installModules(req: any) {
4949
() => nonInteractiveSpawn(project, 'npm', args, projectPath),
5050
{ isOwnerAlive: (owner) => owner.pid !== process.pid || isThreadRunning(owner.threadId) }
5151
);
52-
stdout = stdout ? stdout.replace('\n', '') : null;
52+
responseObject[project].npm_output = stdout ? parseNpmReport(stdout) : null;
5353
stderr = stderr ? stderr.replace('\n', '') : null;
5454

55-
try {
56-
responseObject[project].npm_output = JSON.parse(stdout);
57-
} catch {
58-
responseObject[project].npm_output = stdout;
59-
}
60-
6155
try {
6256
responseObject[project].npm_error = JSON.parse(stderr);
6357
} catch {
@@ -78,6 +72,22 @@ export async function installModules(req: any) {
7872
return responseObject;
7973
}
8074

75+
// npm before 11.20 prints its human-readable dry-run diff ("add <name> <version>") ahead of the --json
76+
// report; the report is the last document that starts a line with '{'
77+
function parseNpmReport(stdout: string) {
78+
try {
79+
return JSON.parse(stdout);
80+
} catch {
81+
const reportStart = stdout.lastIndexOf('\n{') + 1;
82+
if (reportStart > 0) {
83+
try {
84+
return JSON.parse(stdout.slice(reportStart));
85+
} catch {}
86+
}
87+
return stdout.replace('\n', '');
88+
}
89+
}
90+
8191
function parseNPMStdErr(stderr: string) {
8292
//npm returns errors inconsistently, on 6 it returns json, on 8 it returns json stringified inside of a larger string
8393
let startSearchString = '"error": {';

0 commit comments

Comments
 (0)