From d8ac30125b39938789630cd307d89c9defec6a9b Mon Sep 17 00:00:00 2001 From: Junsu Han <148194790+junius-sec@users.noreply.github.com> Date: Thu, 28 May 2026 13:56:05 +0900 Subject: [PATCH 1/4] deps: cherry-pick e807d4e379 from SQLite Backport the SQLite session extension fix for corrupt changesets that omit old values for primary-key columns. This avoids passing NULL to sessionBindValue() while applying UPDATE changesets. Refs: https://sqlite.org/src/info/e807d4e3798efd53 Signed-off-by: junius-sec PR-URL: https://github.com/nodejs/node/pull/63525 Refs: https://hackerone.com/reports/3736889 Refs: https://github.com/sqlite/sqlite/commit/b869ed6b067d623cb1383549f2a18aa35508385d Reviewed-By: Antoine du Hamel Reviewed-By: Chemi Atlow --- deps/sqlite/sqlite3.c | 2 +- test/parallel/test-sqlite-session.js | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/deps/sqlite/sqlite3.c b/deps/sqlite/sqlite3.c index dfd557adeda581..0c83f247e89464 100644 --- a/deps/sqlite/sqlite3.c +++ b/deps/sqlite/sqlite3.c @@ -238388,7 +238388,7 @@ static int sessionApplyOneOp( for(i=0; rc==SQLITE_OK && iabPK[i] || (bPatchset==0 && pOld) ){ + if( pOld && (p->abPK[i] || bPatchset==0) ){ rc = sessionBindValue(pUp, i*2+2, pOld); } if( rc==SQLITE_OK && pNew ){ diff --git a/test/parallel/test-sqlite-session.js b/test/parallel/test-sqlite-session.js index 934ef576bc93fa..189835ce4c3003 100644 --- a/test/parallel/test-sqlite-session.js +++ b/test/parallel/test-sqlite-session.js @@ -496,6 +496,27 @@ test('database.applyChangeset() - wrong arguments', (t) => { }); }); +test('database.applyChangeset() - malformed changeset returns SQLITE_CORRUPT', { + skip: process.config.variables.node_shared_sqlite ? + 'requires the bundled SQLite session fix' : false, +}, (t) => { + const database = new DatabaseSync(':memory:'); + database.exec('CREATE TABLE t1(a INTEGER PRIMARY KEY, b, c, d)'); + + const changeset = Buffer.from( + '540401000000743100177e0072286565286565', + 'hex'); + + t.assert.throws(() => { + database.applyChangeset(changeset); + }, { + name: 'Error', + message: 'database disk image is malformed', + errcode: 11, + code: 'ERR_SQLITE_ERROR', + }); +}); + test('session.patchset()', (t) => { const database = new DatabaseSync(':memory:'); database.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT)'); From 57427b5842857d5a87aa97e83ed66384b723345e Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 28 May 2026 08:25:00 +0200 Subject: [PATCH 2/4] test: shorten path in net pipe connect errors Signed-off-by: Matteo Collina PR-URL: https://github.com/nodejs/node/pull/63405 Reviewed-By: Luigi Pinca Reviewed-By: Paolo Insogna --- test/parallel/test-net-pipe-connect-errors.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/parallel/test-net-pipe-connect-errors.js b/test/parallel/test-net-pipe-connect-errors.js index fec4259b348dfb..33dc6a8a23b5e1 100644 --- a/test/parallel/test-net-pipe-connect-errors.js +++ b/test/parallel/test-net-pipe-connect-errors.js @@ -24,6 +24,7 @@ const common = require('../common'); const fixtures = require('../common/fixtures'); const fs = require('fs'); const net = require('net'); +const path = require('path'); const assert = require('assert'); // Test if ENOTSOCK is fired when trying to connect to a file which is not @@ -38,11 +39,11 @@ if (common.isWindows) { } else { const tmpdir = require('../common/tmpdir'); tmpdir.refresh(); - // Keep the file name very short so that we don't exceed the 108 char limit - // on CI for a POSIX socket. Even though this isn't actually a socket file, - // the error will be different from the one we are expecting if we exceed the - // limit. - emptyTxt = `${tmpdir.path}0.txt`; + // Use a short relative path so that we don't exceed the 108 byte limit for + // Unix socket paths in long or multibyte CI workspaces. Even though this + // isn't actually a socket file, the error will be different from the one we + // are expecting if the path is too long. + emptyTxt = path.join(path.relative(process.cwd(), tmpdir.path), '0.txt'); function cleanup() { try { From bac704d3d3af623999e0524da1e9667a86146f06 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 28 May 2026 08:25:13 +0200 Subject: [PATCH 3/4] http2: emit session close before stream close Signed-off-by: Matteo Collina PR-URL: https://github.com/nodejs/node/pull/63414 Fixes: https://github.com/nodejs/node/issues/63412 Reviewed-By: Tim Perry Reviewed-By: Trivikram Kamat Reviewed-By: Stephen Belanger --- doc/api/http2.md | 8 ++- lib/internal/http2/core.js | 54 ++++++++++------- test/parallel/test-http2-client-destroy.js | 15 ++++- ...lient-session-close-before-stream-close.js | 59 +++++++++++++++++++ 4 files changed, 110 insertions(+), 26 deletions(-) create mode 100644 test/parallel/test-http2-client-session-close-before-stream-close.js diff --git a/doc/api/http2.md b/doc/api/http2.md index 63a86ddc538887..ac6e1eb9afef05 100644 --- a/doc/api/http2.md +++ b/doc/api/http2.md @@ -1113,10 +1113,12 @@ creates and returns an `Http2Stream` instance that can be used to send an HTTP/2 request to the connected server. When a `ClientHttp2Session` is first created, the socket may not yet be -connected. if `clienthttp2session.request()` is called during this time, the +connected. If `clienthttp2session.request()` is called during this time, the actual request will be deferred until the socket is ready to go. -If the `session` is closed before the actual request be executed, an -`ERR_HTTP2_GOAWAY_SESSION` is thrown. + +If the session becomes unavailable before the request can be created, the +returned stream will emit `ERR_HTTP2_GOAWAY_SESSION` or +`ERR_HTTP2_INVALID_SESSION` asynchronously. This method is only available if `http2session.type` is equal to `http2.constants.NGHTTP2_SESSION_CLIENT`. diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index 273ddd15414b51..1c6edd65cae8f0 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -838,6 +838,10 @@ function requestOnConnect(headersList, options) { } } +function requestOnError(error) { + this.destroy(error); +} + // Validates that priority options are correct, specifically: // 1. options.weight must be a number // 2. options.parent must be a positive number @@ -1153,7 +1157,7 @@ function setupHandle(socket, type, options) { process.nextTick(emit, this, 'connect', this, socket); } -// Emits a close event followed by an error event if err is truthy. Used +// Emits an error event followed by a close event if err is truthy. Used // by Http2Session.prototype.destroy() function emitClose(self, error) { if (error) @@ -1224,6 +1228,9 @@ function closeSession(session, code, error) { session.setTimeout(0); session.removeAllListeners('timeout'); + const socket = session[kSocket]; + const handle = session[kHandle]; + // Destroy any pending and open streams if (state.pendingStreams.size > 0 || state.streams.size > 0) { const cancel = new ERR_HTTP2_STREAM_CANCEL(error); @@ -1231,10 +1238,6 @@ function closeSession(session, code, error) { state.streams.forEach((stream) => stream.destroy(error)); } - // Disassociate from the socket and server. - const socket = session[kSocket]; - const handle = session[kHandle]; - // Destroy the handle if it exists at this point. if (handle !== undefined) { handle.ondone = finishSessionClose.bind(null, session, error); @@ -1809,11 +1812,15 @@ class ClientHttp2Session extends Http2Session { request(headersParam, options) { debugSessionObj(this, 'initiating request'); - if (this.destroyed) - throw new ERR_HTTP2_INVALID_SESSION(); - - if (this.closed) - throw new ERR_HTTP2_GOAWAY_SESSION(); + // Keep argument validation synchronous, but defer session-state failures + // to the returned stream so request retries from stream callbacks do not + // throw before session lifecycle handlers run. + let requestError; + if (this.destroyed) { + requestError = new ERR_HTTP2_INVALID_SESSION(); + } else if (this.closed) { + requestError = new ERR_HTTP2_GOAWAY_SESSION(); + } this[kUpdateTimer](); @@ -1899,19 +1906,24 @@ class ClientHttp2Session extends Http2Session { } } - const onConnect = reqAsync.bind(requestOnConnect.bind(stream, headersList, options)); - if (this.connecting) { - if (this[kPendingRequestCalls] !== null) { - this[kPendingRequestCalls].push(onConnect); + if (requestError) { + process.nextTick(reqAsync.bind(requestOnError.bind(stream, requestError))); + } else { + const onConnect = reqAsync.bind( + requestOnConnect.bind(stream, headersList, options)); + if (this.connecting) { + if (this[kPendingRequestCalls] !== null) { + this[kPendingRequestCalls].push(onConnect); + } else { + this[kPendingRequestCalls] = [onConnect]; + this.once('connect', () => { + this[kPendingRequestCalls].forEach((f) => f()); + this[kPendingRequestCalls] = null; + }); + } } else { - this[kPendingRequestCalls] = [onConnect]; - this.once('connect', () => { - this[kPendingRequestCalls].forEach((f) => f()); - this[kPendingRequestCalls] = null; - }); + onConnect(); } - } else { - onConnect(); } if (onClientStreamCreatedChannel.hasSubscribers) { diff --git a/test/parallel/test-http2-client-destroy.js b/test/parallel/test-http2-client-destroy.js index ff98c23e864f74..7034f98abb6836 100644 --- a/test/parallel/test-http2-client-destroy.js +++ b/test/parallel/test-http2-client-destroy.js @@ -81,7 +81,19 @@ const { listenerCount } = require('events'); assert.throws(() => client.ping(), sessionError); assert.throws(() => client.settings({}), sessionError); assert.throws(() => client.goaway(), sessionError); - assert.throws(() => client.request(), sessionError); + + const pendingReq = client.request(); + pendingReq.on('response', common.mustNotCall()); + pendingReq.on('error', common.expectsError(sessionError)); + pendingReq.on('close', common.mustCall()); + + client.on('close', common.mustCall(() => { + const postCloseReq = client.request(); + postCloseReq.on('response', common.mustNotCall()); + postCloseReq.on('error', common.expectsError(sessionError)); + postCloseReq.on('close', common.mustCall()); + })); + client.close(); // Should be a non-op at this point // Wait for setImmediate call from destroy() to complete @@ -92,7 +104,6 @@ const { listenerCount } = require('events'); assert.throws(() => client.ping(), sessionError); assert.throws(() => client.settings({}), sessionError); assert.throws(() => client.goaway(), sessionError); - assert.throws(() => client.request(), sessionError); client.close(); // Should be a non-op at this point })); diff --git a/test/parallel/test-http2-client-session-close-before-stream-close.js b/test/parallel/test-http2-client-session-close-before-stream-close.js new file mode 100644 index 00000000000000..5ebcd8522dfb41 --- /dev/null +++ b/test/parallel/test-http2-client-session-close-before-stream-close.js @@ -0,0 +1,59 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const http2 = require('http2'); + +const server = http2.createServer(); +let serverSocket; + +server.on('connection', common.mustCall((socket) => { + serverSocket = socket; + socket.on('error', () => {}); +})); + +server.on('sessionError', () => {}); +server.on('stream', common.mustCall((stream, headers) => { + if (headers[':path'] === '/close') { + stream.respond({ ':status': 200 }); + stream.write('partial', common.mustCall(() => { + setImmediate(() => serverSocket.destroy()); + })); + return; + } + + stream.respond({ ':status': 200 }); + stream.end('ok'); +})); + +server.listen(0, common.mustCall(() => { + const session = http2.connect(`http://localhost:${server.address().port}`); + let cachedSession = session; + + session.on('error', () => {}); + session.on('close', common.mustCall(() => { + cachedSession = undefined; + server.close(); + })); + + const req = session.request({ ':path': '/close' }); + req.on('response', common.mustCall()); + req.on('error', () => {}); + req.on('close', common.mustCall(() => { + // This must not throw synchronously even though the session is no longer + // usable. Depending on teardown timing, the returned stream may report a + // closed session before the destroy state is fully observable here. + const req2 = session.request({ ':path': '/again' }); + + req2.on('error', common.mustCall((err) => { + assert.ok( + err.code === 'ERR_HTTP2_INVALID_SESSION' || + err.code === 'ERR_HTTP2_GOAWAY_SESSION'); + assert.strictEqual(cachedSession, undefined); + })); + })); + req.resume(); +})); From 8d0a3b84ff4b1e5bb1b0d54265ebd553bb29930e Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 28 May 2026 09:47:24 +0200 Subject: [PATCH 4/4] test_runner: ignore erased TS lines in coverage Fixes: https://github.com/nodejs/node/issues/54753 Signed-off-by: Matteo Collina PR-URL: https://github.com/nodejs/node/pull/63510 Reviewed-By: Moshe Atlow Reviewed-By: Chemi Atlow Reviewed-By: Pietro Marchini --- lib/internal/modules/typescript.js | 6 ++ lib/internal/test_runner/coverage.js | 75 ++++++++++++++++++- .../source-maps/type-only-import/dist/a.mjs | 3 + .../type-only-import/dist/a.mjs.map | 1 + .../source-maps/type-only-import/src/a.mts | 3 + .../source-maps/type-only-import/test.mjs | 1 + .../test-runner-coverage-source-map.js | 25 +++++++ 7 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/test-runner/source-maps/type-only-import/dist/a.mjs create mode 100644 test/fixtures/test-runner/source-maps/type-only-import/dist/a.mjs.map create mode 100644 test/fixtures/test-runner/source-maps/type-only-import/src/a.mts create mode 100644 test/fixtures/test-runner/source-maps/type-only-import/test.mjs diff --git a/lib/internal/modules/typescript.js b/lib/internal/modules/typescript.js index 8d2a97259704a1..86ef400ca7559e 100644 --- a/lib/internal/modules/typescript.js +++ b/lib/internal/modules/typescript.js @@ -145,6 +145,11 @@ function processTypeScriptCode(code, options) { return transformedCode; } +function stripTypeScriptTypesForCoverage(code) { + validateString(code, 'code'); + return processTypeScriptCode(code, { mode: 'strip-only' }); +} + /** * Performs type-stripping to TypeScript source code internally. @@ -205,4 +210,5 @@ function addSourceMap(code, sourceMap) { module.exports = { stripTypeScriptModuleTypes, stripTypeScriptTypes, + stripTypeScriptTypesForCoverage, }; diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index 577ce15f147d03..37ff2473b68011 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -16,6 +16,7 @@ const { StringPrototypeIncludes, StringPrototypeLocaleCompare, StringPrototypeStartsWith, + StringPrototypeTrim, } = primordials; const { copyFileSync, @@ -44,6 +45,20 @@ const kIgnoreRegex = /\/\* node:coverage ignore next (?\d+ )?\*\//; const kLineEndingRegex = /\r?\n$/u; const kLineSplitRegex = /(?<=\r?\n)/u; const kStatusRegex = /\/\* node:coverage (?enable|disable) \*\//; +const kTypeOnlyImportRegex = /^\s*import\s+type\b/u; +const kTypeScriptSourceRegex = /\.(?:cts|mts|ts)$/u; + +let stripTypeScriptTypesForCoverage; + +function getStripTypeScriptTypesForCoverage() { + if (!process.config.variables.node_use_amaro) { + return; + } + + stripTypeScriptTypesForCoverage ??= + require('internal/modules/typescript').stripTypeScriptTypesForCoverage; + return stripTypeScriptTypesForCoverage; +} class CoverageLine { constructor(line, startOffset, src, length = src?.length) { @@ -69,6 +84,7 @@ class TestCoverage { } #sourceLines = new SafeMap(); + #typeScriptLines = new SafeSet(); getLines(fileUrl, source) { // Split the file source into lines. Make sure the lines maintain their @@ -133,6 +149,57 @@ class TestCoverage { return lines; } + markTypeScriptOnlyLines(fileUrl, source) { + if (this.#typeScriptLines.has(fileUrl)) { + return; + } + this.#typeScriptLines.add(fileUrl); + + if (RegExpPrototypeExec(kTypeScriptSourceRegex, fileUrl) === null) { + return; + } + + const lines = this.getLines(fileUrl, source); + if (!lines) { + return; + } + + let strippedLines; + const stripSource = getStripTypeScriptTypesForCoverage(); + + if (stripSource) { + source ??= readFileSync(fileURLToPath(fileUrl), 'utf8'); + + try { + strippedLines = RegExpPrototypeSymbolSplit( + kLineSplitRegex, + stripSource(source), + ); + } catch { + strippedLines = undefined; + } + } + + for (let i = 0; i < lines.length; ++i) { + const originalLine = lines[i].src; + + if (StringPrototypeTrim(originalLine).length === 0) { + continue; + } + + if (strippedLines?.[i] !== undefined) { + if (StringPrototypeTrim(strippedLines[i]).length === 0) { + lines[i].ignore = true; + } + continue; + } + + if (RegExpPrototypeExec(kTypeOnlyImportRegex, originalLine) !== null) { + lines[i].ignore = true; + } + } + } + summary() { internalBinding('profiler').takeCoverage(); const coverage = this.getCoverageFromDirectory(); @@ -368,10 +435,12 @@ class TestCoverage { offset += length + 1; return coverageLine; }); - if (data.sourcesContent != null) { - for (let j = 0; j < data.sources.length; ++j) { - this.getLines(data.sources[j], data.sourcesContent[j]); + for (let j = 0; j < data.sources.length; ++j) { + const source = data.sourcesContent?.[j]; + if (source != null) { + this.getLines(data.sources[j], source); } + this.markTypeScriptOnlyLines(data.sources[j], source); } const sourceMap = new SourceMap(data, { __proto__: null, lineLengths }); diff --git a/test/fixtures/test-runner/source-maps/type-only-import/dist/a.mjs b/test/fixtures/test-runner/source-maps/type-only-import/dist/a.mjs new file mode 100644 index 00000000000000..1cb9576a123d5a --- /dev/null +++ b/test/fixtures/test-runner/source-maps/type-only-import/dist/a.mjs @@ -0,0 +1,3 @@ +console.log('Hi'); +export {}; +//# sourceMappingURL=a.mjs.map diff --git a/test/fixtures/test-runner/source-maps/type-only-import/dist/a.mjs.map b/test/fixtures/test-runner/source-maps/type-only-import/dist/a.mjs.map new file mode 100644 index 00000000000000..acbd8f58897d9a --- /dev/null +++ b/test/fixtures/test-runner/source-maps/type-only-import/dist/a.mjs.map @@ -0,0 +1 @@ +{"version":3,"file":"a.mjs","sourceRoot":"","sources":["../src/a.mts"],"names":[],"mappings":"AAEA,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC"} diff --git a/test/fixtures/test-runner/source-maps/type-only-import/src/a.mts b/test/fixtures/test-runner/source-maps/type-only-import/src/a.mts new file mode 100644 index 00000000000000..16e1f9f65763a7 --- /dev/null +++ b/test/fixtures/test-runner/source-maps/type-only-import/src/a.mts @@ -0,0 +1,3 @@ +import type {} from 'node:assert'; + +console.log('Hi'); diff --git a/test/fixtures/test-runner/source-maps/type-only-import/test.mjs b/test/fixtures/test-runner/source-maps/type-only-import/test.mjs new file mode 100644 index 00000000000000..dd791e471160c8 --- /dev/null +++ b/test/fixtures/test-runner/source-maps/type-only-import/test.mjs @@ -0,0 +1 @@ +import './dist/a.mjs'; diff --git a/test/parallel/test-runner-coverage-source-map.js b/test/parallel/test-runner-coverage-source-map.js index e3b0676a557a9f..64a452aba00779 100644 --- a/test/parallel/test-runner-coverage-source-map.js +++ b/test/parallel/test-runner-coverage-source-map.js @@ -73,6 +73,31 @@ describe('Coverage with source maps', async () => { t.assert.strictEqual(spawned.code, 1); }); + await it('should ignore erased TypeScript import type lines', async (t) => { + const report = generateReport([ + '# ----------------------------------------------------------', + '# file | line % | branch % | funcs % | uncovered lines', + '# ----------------------------------------------------------', + '# src | | | | ', + '# a.mts | 100.00 | 100.00 | 100.00 | ', + '# test.mjs | 100.00 | 100.00 | 100.00 | ', + '# ----------------------------------------------------------', + '# all files | 100.00 | 100.00 | 100.00 | ', + '# ----------------------------------------------------------', + ]); + + const spawned = await common.spawnPromisified(process.execPath, [ + ...flags, + 'test.mjs', + ], { + cwd: fixtures.path('test-runner', 'source-maps', 'type-only-import'), + }); + + t.assert.strictEqual(spawned.stderr, ''); + t.assert.ok(spawned.stdout.includes(report)); + t.assert.strictEqual(spawned.code, 0); + }); + await it('properly accounts for line endings in source maps', async (t) => { const report = generateReport([ '# ------------------------------------------------------------------',