Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
18 changes: 13 additions & 5 deletions lib/runner/extensions/event.command.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (_) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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
Expand Down
88 changes: 69 additions & 19 deletions lib/runner/nested-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,57 @@ 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,
requestResolver = _.get(self, 'options.script.requestResolver'),
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);
}
};

Expand All @@ -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]) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -239,6 +288,7 @@ function runNestedRequest ({ executionId, isExecutionSkipped, vaultSecrets, item
}

popCurrentItemFromCallStack();
nestedRuns && nestedRuns.runs.delete(run);

return self.host.dispatch(runRequestRespEvent,
requestId, error || null,
Expand Down
1 change: 1 addition & 0 deletions lib/runner/partition-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions lib/runner/partition.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() };
}

/**
Expand Down Expand Up @@ -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
*
Expand Down
Loading
Loading