From 3b6af6b7c8de16a34275dbb285d639fcf795420f Mon Sep 17 00:00:00 2001 From: Kartikay Singh Date: Thu, 15 Oct 2026 08:22:51 +0530 Subject: [PATCH 1/3] Fixed pm.execution.runRequest hanging with customParallelIterations The nested run inherited customParallelIterations, so no partition ever ran it. Its chain state lived on the run and was shared by every partition, and each nested run got a fresh cookie jar. - run the nested request once, sequentially - keep the runRequest chain per script execution and pass it to nested runs - give nested requests the calling partition's cookie jar - abort a stopped partition's in-flight nested runs Co-Authored-By: Claude --- CHANGELOG.yaml | 10 + lib/runner/extensions/event.command.js | 18 +- lib/runner/nested-request.js | 88 +++- lib/runner/partition-manager.js | 1 + lib/runner/partition.js | 17 + .../run-collection-request-parallel.test.js | 416 ++++++++++++++++++ test/unit/partition-manager.test.js | 17 + 7 files changed, 543 insertions(+), 24 deletions(-) create mode 100644 test/integration/runner-spec/run-collection-request-parallel.test.js diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index 986d4854b..4dac07b59 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -1,3 +1,13 @@ +unreleased: + fixed bugs: + - >- + Fixed `pm.execution.runRequest` hanging in runs with + `customParallelIterations`: the nested run now runs once, sequentially, + the `runRequest` chain is kept per script execution instead of in run + state shared by all partitions, nested requests use the calling + partition's cookie jar, and stopping a partition aborts its in-flight + nested runs + 7.56.1: date: 2026-08-10 fixed bugs: diff --git a/lib/runner/extensions/event.command.js b/lib/runner/extensions/event.command.js index 8d677d908..b51600c98 100644 --- a/lib/runner/extensions/event.command.js +++ b/lib/runner/extensions/event.command.js @@ -341,7 +341,13 @@ module.exports = { assertionFailed = [], asyncScriptError, - isNestedRequest = this.state.nestedRequest !== undefined, + // The pm.execution.runRequest chain this script belongs to. A nested run is handed its + // chain; a top-level script starts its own on its first runRequest call (see + // lib/runner/nested-request.js). Kept per execution, not in run state, so concurrent + // partitions of a run never see each other's chain. + nestedRequestScope = { nestedRequest: this.state.nestedRequest }, + + isNestedRequest = nestedRequestScope.nestedRequest !== undefined, rootItemId = isNestedRequest && this.state.nestedRequest.rootItem ? this.state.nestedRequest.rootItem.id : item.id, @@ -480,7 +486,7 @@ module.exports = { if (hasVaultAccess === undefined) { try { // eslint-disable-next-line require-atomic-updates - hasVaultAccess = Boolean(this.state.nestedRequest?.hasVaultAccess || + hasVaultAccess = Boolean(nestedRequestScope.nestedRequest?.hasVaultAccess || await vaultSecrets?._?.allowScriptAccess(rootItemId)); } catch (_) { @@ -726,7 +732,9 @@ module.exports = { }.bind(this)); this.host.on(EXECUTION_RUN_REQUEST_EVENT_BASE + executionId, - runNestedRequest({ executionId, isExecutionSkipped, vaultSecrets, item }).bind(this)); + runNestedRequest({ + executionId, isExecutionSkipped, vaultSecrets, item, nestedRequestScope + }).bind(this)); this.host.on(EXECUTION_SKIP_REQUEST_EVENT_BASE + executionId, function () { skippedExecutions.add(executionId); @@ -845,7 +853,7 @@ module.exports = { // Prevent mutations from being carry-forwarded to subsequent events vaultSecrets.disableTracking(); - if (hasVaultAccess || this.state.nestedRequest?.hasVaultAccess) { + if (hasVaultAccess || nestedRequestScope.nestedRequest?.hasVaultAccess) { result.vaultSecrets = vaultSecrets; } } @@ -882,7 +890,7 @@ module.exports = { if (!isNestedRequest) { // Reset for next invocation if there are subsequent executions of items in a single // runner via `iterationCount` or multiple `items` passed to a collection. - delete this.state.nestedRequest; + delete nestedRequestScope.nestedRequest; } // move to next script and pass on the results for accumulation diff --git a/lib/runner/nested-request.js b/lib/runner/nested-request.js index 1271f3fb3..738d55144 100644 --- a/lib/runner/nested-request.js +++ b/lib/runner/nested-request.js @@ -6,7 +6,30 @@ const _ = require('lodash'), SYNCABLE_CONTEXT_VARIABLE_SCOPES = ['_variables', 'collectionVariables', 'environment', 'globals'], EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE = 'execution.run_collection_request_response.'; -function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item }) { +/** + * Starts the pm.execution.runRequest chain of a top-level script. The chain is kept per script execution and handed + * down to every nested run, so concurrent partitions of a run each get their own. + * + * @param {Run} run - the run of the top-level script + * @param {Object} cursor - the top-level script's cursor + * @param {Item} item - the top-level item + * @returns {Object} + */ +function createNestedRequestChain (run, cursor, item) { + const partition = run.areIterationsParallelized && + _.get(run, ['partitionManager', 'partitions', cursor.partitionIndex]); + + return { + isNestedRequest: true, + rootCursor: cursor, + rootItem: item, + callStack: [], + // nested runs of this chain, aborted when the partition is stopped + nestedRuns: partition ? partition.nestedRuns : undefined + }; +} + +function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item, nestedRequestScope }) { // Note: Don't forget to bind this function to the runner to make sure `this` can give you the right options & state return function (cursor, eventId, requestId, requestToRunId, runRequestOptions = {}, runContext = {}) { const self = this, @@ -14,19 +37,26 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item runRequestRespEvent = EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE + eventId, maxInvokableNestedRequests = _.get(self, 'options.maxInvokableNestedRequests'), itemId = item.id, + + // Prepare nested request object for passing down to child request + // Have to keep object reference common so any changes made by nested executions is bubbled back to + // parent exec. A top-level script starts the chain on its first call. + nestedRequest = nestedRequestScope.nestedRequest || + (nestedRequestScope.nestedRequest = createNestedRequestChain(self, cursor, item)), + nestedRuns = nestedRequest.nestedRuns, currentItemState = { isPoppedFromCallStack: false }, popCurrentItemFromCallStack = () => { if (currentItemState.isPoppedFromCallStack) { return; } currentItemState.isPoppedFromCallStack = true; - if (!self.state.nestedRequest.callStack.length) { return; } + if (!nestedRequest.callStack.length) { return; } - const { callStack } = self.state.nestedRequest, + const { callStack } = nestedRequest, itemIndex = callStack.lastIndexOf(itemId); if (itemIndex !== -1) { - self.state.nestedRequest.callStack.splice(itemIndex, 1); + nestedRequest.callStack.splice(itemIndex, 1); } }; @@ -41,26 +71,22 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item requestId, error); } - // Prepare nested request object for passing down to child request - // Have to keep object reference common so any changes made by nested executions is bubbled back to parent exec - self.state.nestedRequest = _.defaults(self.state.nestedRequest || {}, { - isNestedRequest: true, - rootCursor: cursor, - rootItem: item, - callStack: [] - }); + nestedRequest.callStack.push(itemId); - self.state.nestedRequest.callStack.push(itemId); + // The partition that started this chain was stopped, don't start anything new for it + if (nestedRuns && nestedRuns.aborted) { + return dispatchErrorToListener(new Error('pm.execution.runRequest: the iteration was stopped.')); + } // No more than maxInvokableNestedRequests nested depth - if (self.state.nestedRequest.callStack.length > maxInvokableNestedRequests) { + if (nestedRequest.callStack.length > maxInvokableNestedRequests) { return dispatchErrorToListener(new Error('Max pm.execution.runRequest depth of ' + maxInvokableNestedRequests + ' has been reached.')); } - let rootRequestEventsList = self.state.nestedRequest.rootItem.events?.all?.() || [], + let rootRequestEventsList = nestedRequest.rootItem.events?.all?.() || [], nestedRequestMeta = null, - context = { rootItemId: self.state.nestedRequest.rootItem.id }; + context = { rootItemId: nestedRequest.rootItem.id }; for (const event of rootRequestEventsList) { if (event.script?.requests?.[requestToRunId]) { @@ -113,7 +139,16 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item parentIterationRow = self.state._dataIterator ? self.state._dataRow : getIterationData(_.isEmpty(self.state.data) ? [{}] : self.state.data, - self.state.nestedRequest.rootCursor.iteration); + nestedRequest.rootCursor.iteration), + + // A partitioned run hands its nested requests the calling partition's cookies: its own jar when + // per-partition jars are on, else the run's shared jar. An explicit jar also turns off per-partition + // jars for the nested run. Deeper nested runs inherit it through `requester`. Other runs keep their + // requester options as they are. + requester = self.areIterationsParallelized ? { + ..._.omit(self.options.requester, 'perPartitionCookieJar'), + cookieJar: self.getCookieJarFor(cursor) || _.get(self, 'requester.options.cookieJar') + } : self.options.requester; // Merge parent collection's variables with this collection's variables // Why the separate statement? Because we want the referenced request's collection's variables to @@ -155,6 +190,11 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item localVariables: localVariables, vaultSecrets: clonedVaultSecrets, stopOnError: true, + nestedRequest: nestedRequest, + // The nested request runs once, sequentially, for the script that invoked it + customParallelIterations: false, + maxConcurrency: 1, + requester: requester, host: { // Reuse current run's sandbox host across nested executions external: true, @@ -173,6 +213,15 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item .dispatch(EXECUTION_RUN_REQUEST_RESPONSE_EVENT_BASE + eventId, requestId, err); } + + // the partition may have been stopped while the request was being resolved + if (nestedRuns && nestedRuns.aborted) { + return dispatchErrorToListener(new Error('pm.execution.runRequest: the iteration was ' + + 'stopped.')); + } + + nestedRuns && nestedRuns.runs.add(run); + run.start({ script (_err, _cursor, result) { // This is to sync changes to pm.variables, pm.environment & pm.globals @@ -206,8 +255,8 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item // For nested requests, the request event should bubble up to the parent. // This event is merely used for notifying the consumer that a nested request was sent // similar to pm.sendRequest. So the consumer can work this into logs & any other logic. - if (typeof self.triggers.request === 'function' && self.state && self.state.nestedRequest) { - self.triggers.request(err, self.state.nestedRequest.rootCursor, ...rest); + if (typeof self.triggers.request === 'function' && nestedRequestScope.nestedRequest) { + self.triggers.request(err, nestedRequest.rootCursor, ...rest); } }, exception (_, err) { @@ -239,6 +288,7 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item } popCurrentItemFromCallStack(); + nestedRuns && nestedRuns.runs.delete(run); return self.host.dispatch(runRequestRespEvent, requestId, error || null, diff --git a/lib/runner/partition-manager.js b/lib/runner/partition-manager.js index 6121b36eb..825a196da 100644 --- a/lib/runner/partition-manager.js +++ b/lib/runner/partition-manager.js @@ -285,6 +285,7 @@ class PartitionManager { if (partition) { partition.clearPool(); + partition.abortNestedRuns(); // Per-partition cookie jar (requester.perPartitionCookieJar): // in customParallelIterations mode a stopped partition index diff --git a/lib/runner/partition.js b/lib/runner/partition.js index f05f930ee..ccca34e99 100644 --- a/lib/runner/partition.js +++ b/lib/runner/partition.js @@ -38,6 +38,10 @@ class Partition { // `variables`, the jar is not cloned from run-level state — a // fresh partition is born with an empty jar. this.cookieJar = null; + + // pm.execution.runRequest runs started by this partition's scripts, at every nesting depth. + // Replaced by abortNestedRuns when the partition is stopped. + this.nestedRuns = { aborted: false, runs: new Set() }; } /** @@ -95,6 +99,19 @@ class Partition { this.cookieJar = null; } + /** + * Aborts the nested runs (pm.execution.runRequest) still in flight for this partition. + * + * Called by stopSinglePartition. The stopped occupant's scripts keep a reference to the old + * `nestedRuns` object, so once it is flagged aborted they cannot start new nested runs, and + * nothing they do reaches the next occupant of the reused index. + */ + abortNestedRuns () { + this.nestedRuns.aborted = true; + this.nestedRuns.runs.forEach(function (run) { run.abort(_.noop); }); + this.nestedRuns = { aborted: false, runs: new Set() }; + } + /** * Creates a cursor for this partition * diff --git a/test/integration/runner-spec/run-collection-request-parallel.test.js b/test/integration/runner-spec/run-collection-request-parallel.test.js new file mode 100644 index 000000000..bcda9506c --- /dev/null +++ b/test/integration/runner-spec/run-collection-request-parallel.test.js @@ -0,0 +1,416 @@ +/** + * pm.execution.runRequest under customParallelIterations: every partition (VU) of the run calls + * runRequest on its own, so each partition must get its own nested request chain and its own cookies. + * + * Runs against a LOCAL http server that records every hit, so state mixing between partitions is + * observed on the wire. + */ + +var _ = require('lodash'), + url = require('url'), + sinon = require('sinon').createSandbox(), + expect = require('chai').expect, + Collection = require('postman-collection').Collection, + VariableScope = require('postman-collection').VariableScope, + RequestCookieJar = require('postman-request').jar, + Runner = require('../../../index.js').Runner, + IS_NODE = typeof window === 'undefined', + server = IS_NODE && require('../../fixtures/servers/_servers'); + +(IS_NODE ? describe : describe.skip)('pm.execution.runRequest with customParallelIterations', function () { + this.timeout(10 * 1000); + + var httpServer, + onTarget, // optional hook called with the vu of every /target hit + hits; // [{path, vu, cookie}] in arrival order, recorded by the server + + before(function (done) { + httpServer = server.createHTTPServer(); + + httpServer.on('/main', function (req, res) { + var vu = url.parse(req.url, true).query.vu; + + hits.push({ path: '/main', vu: vu, cookie: req.headers.cookie || '' }); + res.writeHead(200, { 'content-type': 'text/plain', 'set-cookie': 'main=' + vu + '; Path=/' }); + res.end('ok'); + }); + + // delayed, so that nested requests of concurrent partitions are in flight together + httpServer.on('/target', function (req, res) { + var vu = url.parse(req.url, true).query.vu; + + hits.push({ path: '/target', vu: vu, cookie: req.headers.cookie || '' }); + onTarget && onTarget(vu); + setTimeout(function () { + res.writeHead(200, { 'content-type': 'text/plain', 'set-cookie': 'nested=' + vu + '; Path=/' }); + res.end('ok'); + }, 100); + }); + + httpServer.listen(0, done); + }); + + after(function (done) { + httpServer.destroy(done); + }); + + afterEach(function () { + onTarget = null; + sinon.restore(); + }); + + function mainCollection (testScript) { + return new Collection({ + item: [{ + id: 'main-item', + event: [{ listen: 'test', script: { exec: testScript } }], + request: httpServer.url + '/main?vu={{vu}}' + }] + }); + } + + function requestResolver (testScript) { + return function (_requestId, _context, callback) { + callback(null, { + item: { + id: 'nested-item', + event: testScript ? [{ listen: 'test', script: { exec: testScript } }] : [], + request: httpServer.url + '/target?vu={{vu}}' + } + }); + }; + } + + /** + * Drives a custom-mode run: the partitions of a wave start together, the next wave starts once + * every iteration of the current one is done, and the run is aborted after the last wave. + * + * @param {Object} opts - + * @param {Collection} opts.collection - + * @param {Object} opts.options - extra run options + * @param {Array} opts.waves - [[{ index: , vu: }]] + * @param {Function} done - (err, spies) + */ + function runWaves (opts, done) { + var spies = {}, + waveIndex = 0, + pending = 0, + run; + + hits = []; + + _.forEach(_.keys(Runner.Run.triggers), function (name) { + spies[name] = sinon.spy(); + }); + + function nextWave () { + var wave = opts.waves[waveIndex++]; + + if (!wave) { + return run.abort(_.noop); + } + + pending = wave.length; + wave.forEach(function (start) { + run.startParallelIteration(start.index, + new VariableScope({ values: [{ key: 'vu', value: start.vu }] }), _.noop); + }); + } + + new Runner({}).run(opts.collection, _.assign({ + customParallelIterations: true + }, opts.options), function (err, runInstance) { + if (err) { return done(err); } + + run = runInstance; + spies.start = sinon.spy(nextWave); + spies.iteration = sinon.spy(function () { + --pending === 0 && nextWave(); + }); + spies.done = sinon.spy(function (err) { + // A run ended by run.abort calls `done` again once timeout.global (3 minutes by default) + // elapses, with "callback timed out". Only the first call is the run's real end. + if (spies.done.callCount > 1) { return; } + + setTimeout(function () { run.host.dispose(); }, 0); + done(err, spies); + }); + + run.start(spies); + }); + } + + function passedAssertions (spies, name) { + return _.flatMap(spies.assertion.args, function (args) { + return _.map(_.filter(args[1], { name: name, passed: true }), function () { + return args[0].partitionIndex; + }); + }).sort(); + } + + describe('with a single partition', function () { + var spies; + + before(function (done) { + runWaves({ + collection: mainCollection(` + const res = await pm.execution.runRequest('nested-item'); + pm.test('nested request ok', function () { + pm.expect(res.code).to.equal(200); + }); + `), + options: { script: { requestResolver: requestResolver() } }, + waves: [[{ index: 0, vu: 'p0' }]] + }, function (err, _spies) { + spies = _spies; + done(err); + }); + }); + + it('should run the nested request and complete the run', function () { + expect(spies.done.calledOnce).to.be.true; + expect(spies.done.firstCall.args[0]).to.be.null; + expect(_.map(hits, 'path')).to.eql(['/main', '/target']); + expect(passedAssertions(spies, 'nested request ok')).to.eql([0]); + }); + }); + + describe('with partitions calling runRequest concurrently', function () { + var spies; + + before(function (done) { + runWaves({ + collection: mainCollection(` + console.log('main', pm.variables.get('vu')); + const res = await pm.execution.runRequest('nested-item'); + pm.test('nested request ok', function () { + pm.expect(res.code).to.equal(200); + }); + `), + options: { + script: { requestResolver: requestResolver('console.log(\'nested\', pm.variables.get(\'vu\'));') } + }, + waves: [[{ index: 0, vu: 'p0' }, { index: 1, vu: 'p1' }, { index: 2, vu: 'p2' }]] + }, function (err, _spies) { + spies = _spies; + done(err); + }); + }); + + it('should complete the run', function () { + expect(spies.done.calledOnce).to.be.true; + expect(spies.done.firstCall.args[0]).to.be.null; + expect(passedAssertions(spies, 'nested request ok')).to.eql([0, 1, 2]); + }); + + it('should send one nested request per partition', function () { + expect(_.map(_.filter(hits, { path: '/target' }), 'vu').sort()).to.eql(['p0', 'p1', 'p2']); + }); + + it('should tag the logs of each partition, nested ones included, to that partition', function () { + var logs = spies.console.args.map(function (args) { + return args[3] + ':' + args[0].partitionIndex + ':' + args[2]; + }).sort(); + + expect(logs).to.eql([ + 'p0:0:main', 'p0:0:nested', + 'p1:1:main', 'p1:1:nested', + 'p2:2:main', 'p2:2:nested' + ]); + }); + }); + + describe('with partitions calling runRequest inside runRequest', function () { + var spies, + depthError = 'Max pm.execution.runRequest depth of 3 has been reached.'; + + before(function (done) { + var recurse = 'await pm.execution.runRequest(\'nested-item\');'; + + runWaves({ + collection: mainCollection(recurse), + options: { + maxInvokableNestedRequests: 3, + script: { requestResolver: requestResolver(recurse) } + }, + waves: [[{ index: 0, vu: 'p0' }, { index: 1, vu: 'p1' }]] + }, function (err, _spies) { + spies = _spies; + done(err); + }); + }); + + it('should enforce the depth limit for each partition on its own', function () { + expect(spies.done.calledOnce).to.be.true; + expect(_.countBy(_.filter(hits, { path: '/target' }), 'vu')).to.eql({ p0: 3, p1: 3 }); + }); + + it('should report the depth limit on the root request of each partition', function () { + var errors = spies.exception.args.map(function (args) { + return args[0].partitionIndex + ':' + args[1].message; + }).sort(); + + expect(errors).to.eql(['0:' + depthError, '1:' + depthError]); + }); + }); + + // Not partitioned: nested runs keep the run's requester options, so their cookies behave as before + describe('without partitions', function () { + function runSequential (requester, done) { + hits = []; + + new Runner({}).run(mainCollection('await pm.execution.runRequest(\'nested-item\');'), { + iterationCount: 2, + data: [{ vu: 'p0' }, { vu: 'p0' }], + requester, + script: { requestResolver: requestResolver() } + }, function (err, run) { + if (err) { return done(err); } + + run.start({ done: done }); + }); + } + + it('should share an explicitly passed cookie jar with nested requests', function (done) { + runSequential({ cookieJar: RequestCookieJar() }, function (err) { + if (err) { return done(err); } + + expect(hits.map(function (hit) { return hit.path + ':' + hit.cookie; })).to.eql([ + '/main:', '/target:main=p0', '/main:main=p0; nested=p0', '/target:main=p0; nested=p0' + ]); + done(); + }); + }); + + it('should give nested requests their own cookie jar when no jar is passed', function (done) { + runSequential(undefined, function (err) { + if (err) { return done(err); } + + expect(hits.map(function (hit) { return hit.path + ':' + hit.cookie; })).to.eql([ + '/main:', '/target:', '/main:main=p0', '/target:' + ]); + done(); + }); + }); + }); + + describe('with requester.perPartitionCookieJar', function () { + var spies; + + before(function (done) { + runWaves({ + collection: mainCollection('await pm.execution.runRequest(\'nested-item\');'), + options: { + requester: { perPartitionCookieJar: true }, + script: { requestResolver: requestResolver() } + }, + waves: [ + [{ index: 0, vu: 'p0' }, { index: 1, vu: 'p1' }], + [{ index: 0, vu: 'p0' }, { index: 1, vu: 'p1' }] + ] + }, function (err, _spies) { + spies = _spies; + done(err); + }); + }); + + it('should complete the run without the per-partition jar warning', function () { + expect(spies.done.calledOnce).to.be.true; + expect(spies.console.args.filter(function (args) { + return args[1] === 'warn'; + })).to.be.empty; + }); + + it('should send the calling partition\'s cookies on nested requests', function () { + expect(_.filter(hits, { path: '/target' }).slice(0, 2).map(function (hit) { + return hit.vu + ':' + hit.cookie; + }).sort()).to.eql(['p0:main=p0', 'p1:main=p1']); + }); + + it('should store cookies set by nested requests in the calling partition\'s jar', function () { + expect(_.filter(hits, { path: '/main' }).slice(2).map(function (hit) { + return hit.vu + ':' + hit.cookie; + }).sort()).to.eql(['p0:main=p0; nested=p0', 'p1:main=p1; nested=p1']); + }); + }); + + describe('with a partition stopped while its runRequest is in flight', function () { + var spies, + logs = function () { + return spies.console.args.map(function (args) { return args.slice(2).join(' '); }); + }; + + before(function (done) { + var run, + finished = false; + + hits = []; + spies = {}; + _.forEach(_.keys(Runner.Run.triggers), function (name) { + spies[name] = sinon.spy(); + }); + + // the run is over once the stopped VU's script has given up and the new VU's iteration is done + function maybeFinish () { + var all = logs(); + + if (!finished && all.includes('second new') && all.some(function (log) { + return log.startsWith('second failed old'); + })) { + finished = true; + run.abort(_.noop); + } + } + + // stop VU "old" while its nested request is on the wire, and reuse its index straight away + onTarget = function (vu) { + if (vu !== 'old') { return; } + + run.stopParallelIteration(0); + run.startParallelIteration(0, new VariableScope({ values: [{ key: 'vu', value: 'new' }] }), _.noop); + }; + + new Runner({}).run(mainCollection(` + await pm.execution.runRequest('nested-item'); + try { + await pm.execution.runRequest('nested-item'); + console.log('second', pm.variables.get('vu')); + } + catch (e) { + console.log('second failed', pm.variables.get('vu'), e.message); + } + `), { + customParallelIterations: true, + script: { requestResolver: requestResolver() } + }, function (err, runInstance) { + if (err) { return done(err); } + + run = runInstance; + spies.start = sinon.spy(function () { + run.startParallelIteration(0, new VariableScope({ values: [{ key: 'vu', value: 'old' }] }), + _.noop); + }); + spies.console = sinon.spy(maybeFinish); + spies.done = sinon.spy(function (err) { + setTimeout(function () { run.host.dispose(); }, 0); + done(err); + }); + + run.start(spies); + }); + }); + + it('should complete the run', function () { + expect(spies.done.calledOnce).to.be.true; + }); + + it('should not start new nested requests for the stopped partition', function () { + expect(logs()).to.include('second failed old pm.execution.runRequest: the iteration was stopped.'); + expect(_.filter(hits, { path: '/target', vu: 'old' })).to.have.lengthOf(1); + }); + + it('should give the next occupant of the index its own chain', function () { + expect(_.filter(hits, { path: '/target', vu: 'new' })).to.have.lengthOf(2); + }); + }); +}); diff --git a/test/unit/partition-manager.test.js b/test/unit/partition-manager.test.js index 45846b903..d87269cc0 100644 --- a/test/unit/partition-manager.test.js +++ b/test/unit/partition-manager.test.js @@ -377,6 +377,23 @@ describe('PartitionManager', function () { }); }); + it('should abort the nested runs of a stopped partition and give it a fresh set', function (done) { + var partition = partitionManager.partitions[0], + nestedRun = { abort: sinon.stub() }, + stoppedNestedRuns = partition.nestedRuns; + + stoppedNestedRuns.runs.add(nestedRun); + + partitionManager.stopSinglePartition(0, function (err) { + expect(err).to.be.null; + expect(nestedRun.abort.calledOnce).to.be.true; + expect(stoppedNestedRuns.aborted).to.be.true; + expect(partition.nestedRuns).to.not.equal(stoppedNestedRuns); + expect(partition.nestedRuns).to.eql({ aborted: false, runs: new Set() }); + done(); + }); + }); + it('should handle stopping non-existent partition gracefully', function (done) { partitionManager.stopSinglePartition(99, function (err) { expect(err).to.be.null; From 0f14b69e85d87de5ebb4834ed2e42e5a40a776d8 Mon Sep 17 00:00:00 2001 From: Kartikay Singh Date: Thu, 15 Oct 2026 08:34:08 +0530 Subject: [PATCH 2/3] tests: Fixed object-shorthand lint in runRequest parallel spec Co-Authored-By: Claude --- .../runner-spec/run-collection-request-parallel.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/runner-spec/run-collection-request-parallel.test.js b/test/integration/runner-spec/run-collection-request-parallel.test.js index bcda9506c..e4baf150c 100644 --- a/test/integration/runner-spec/run-collection-request-parallel.test.js +++ b/test/integration/runner-spec/run-collection-request-parallel.test.js @@ -262,12 +262,12 @@ var _ = require('lodash'), new Runner({}).run(mainCollection('await pm.execution.runRequest(\'nested-item\');'), { iterationCount: 2, data: [{ vu: 'p0' }, { vu: 'p0' }], - requester, + requester: requester, script: { requestResolver: requestResolver() } }, function (err, run) { if (err) { return done(err); } - run.start({ done: done }); + run.start({ done }); }); } From 449cae50c76a54a29d28eff5799e80931dd22b4b Mon Sep 17 00:00:00 2001 From: Kartikay Singh Date: Thu, 15 Oct 2026 17:06:48 +0530 Subject: [PATCH 3/3] tests: Ignored the second done() after abort in the in-flight stop spec After run.abort, timeout.global still calls done once more, so the spy's second call ran mocha's done() again and failed the hook on slower CI runners. The product double-completion is fixed separately in #1559 (2c4489ca). Co-Authored-By: Claude --- .../runner-spec/run-collection-request-parallel.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/integration/runner-spec/run-collection-request-parallel.test.js b/test/integration/runner-spec/run-collection-request-parallel.test.js index e4baf150c..1846c979f 100644 --- a/test/integration/runner-spec/run-collection-request-parallel.test.js +++ b/test/integration/runner-spec/run-collection-request-parallel.test.js @@ -392,6 +392,9 @@ var _ = require('lodash'), }); spies.console = sinon.spy(maybeFinish); spies.done = sinon.spy(function (err) { + // timeout.global calls `done` again after run.abort, see runWaves + if (spies.done.callCount > 1) { return; } + setTimeout(function () { run.host.dispose(); }, 0); done(err); });