Skip to content
Merged
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
40 changes: 40 additions & 0 deletions .github/workflows/process-contracts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: process execution contracts

on:
pull_request:
paths:
- index.js
- lib/run.sh
- test/process-contracts.e2e.test.js
- package.json
- .github/workflows/process-contracts.yml
push:
branches: [master]
paths:
- index.js
- lib/run.sh
- test/process-contracts.e2e.test.js
- package.json
- .github/workflows/process-contracts.yml
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
process-contracts:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- name: Run concurrency, failure aggregation, and side-effect journeys
run: node --test test/process-contracts.e2e.test.js
33 changes: 27 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

Object.defineProperty(exports, "__esModule", {value: true});

var cp = require("child_process");
var path = require("path");
var exec = path.resolve(__dirname + '/lib/run.sh');
var executable = path.resolve(__dirname, 'lib', 'run.sh');

exports.run = function ($commands, args) {
var commands = $commands.map(function (c) {
return String(c).trim();
if (!Array.isArray($commands)) {
throw new TypeError('generic-subshell.run requires an array of shell commands.');
}

var commands = $commands.map(function (command, index) {
var normalized = String(command).trim();
if (!normalized) {
throw new TypeError('generic-subshell command at index ' + index + ' is empty.');
}
return normalized;
});
return cp.spawn(exec, (args || []), {

if (commands.length < 1) {
throw new TypeError('generic-subshell.run requires at least one command.');
}

if (args !== undefined && !Array.isArray(args)) {
throw new TypeError('generic-subshell args must be an array when provided.');
}

return cp.spawn(executable, args || [], {
env: Object.assign({}, process.env, {
GENERIC_SUBSHELL_COMMANDS: commands.join('\n')
})
}),
stdio: ['ignore', 'pipe', 'pipe']
});
};
77 changes: 47 additions & 30 deletions lib/run.sh
Original file line number Diff line number Diff line change
@@ -1,40 +1,57 @@
#!/usr/bin/env bash

git config --global url."https://".insteadOf git://

set -m # allow for job control
EXIT_CODE=0; # exit code of overall script

function handleJobs() {
for job in `jobs -p`; do
echo "PID => ${job}"
# if ! wait ${job} ; then
CODE=0;
wait ${job} || CODE=$?
if [[ "${CODE}" != "0" ]]; then
echo "At least one test failed with exit code => ${CODE}" ;
EXIT_CODE=1;
fi
done
}

trap 'handleJobs' CHLD
DIRN=$(dirname "$0");
set -u
set -m

EXIT_CODE=0
commands=()
pids=()

while IFS= read -r line; do
if [[ -n "${line//[[:space:]]/}" ]]; then
commands+=("$line")
fi
done <<< "${GENERIC_SUBSHELL_COMMANDS:-}"

if [[ "${#commands[@]}" -lt 1 ]]; then
echo "generic-subshell: no commands were supplied" >&2
exit 64
fi

function terminateChildren() {
local signal_exit_code="$1"
trap - INT TERM

for pid in "${pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
fi
done

for pid in "${pids[@]}"; do
wait "$pid" 2>/dev/null || true
done

exit "$signal_exit_code"
}

while read -r line; do
commands+=("$line")
done <<< "${GENERIC_SUBSHELL_COMMANDS}"

clen=`expr "${#commands[@]}" - 1` # get length of commands - 1
trap 'terminateChildren 130' INT
trap 'terminateChildren 143' TERM

for i in `seq 0 "$clen"`; do
(echo "${commands[$i]}" | bash) & # run the command via bash in subshell
echo "$i ith command has been issued as a background job"
for i in "${!commands[@]}"; do
bash -c "${commands[$i]}" &
pids[$i]=$!
printf 'GENERIC_SUBSHELL_STARTED index=%s pid=%s\n' "$i" "${pids[$i]}"
done

for i in "${!pids[@]}"; do
code=0
wait "${pids[$i]}" || code=$?
printf 'GENERIC_SUBSHELL_RESULT index=%s exit_code=%s\n' "$i" "$code"
if [[ "$code" -ne 0 ]]; then
EXIT_CODE=1
fi
done

wait; # wait for all subshells to finish
echo "=> generic-subshell process exit code => $EXIT_CODE"
printf 'GENERIC_SUBSHELL_EXIT_CODE=%s\n' "$EXIT_CODE"
exit "$EXIT_CODE"
135 changes: 135 additions & 0 deletions test/process-contracts.e2e.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
'use strict';

const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');

const {run} = require('../index.js');

const quote = value => `'${String(value).replace(/'/g, `'"'"'`)}'`;

function fixtureDir(t, prefix) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
t.after(() => fs.rmSync(directory, {recursive: true, force: true}));
return directory;
}

function collect(child, timeoutMillis = 5000) {
return new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';
const timeout = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`generic-subshell did not exit within ${timeoutMillis}ms`));
}, timeoutMillis);

child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', chunk => {
stdout += chunk;
});
child.stderr.on('data', chunk => {
stderr += chunk;
});
child.once('error', error => {
clearTimeout(timeout);
reject(error);
});
child.once('close', (code, signal) => {
clearTimeout(timeout);
resolve({code, signal, stdout, stderr});
});
});
}

test('commands start concurrently and each successful exit is reported', async t => {
const directory = fixtureDir(t, 'generic-subshell-barrier-');
const script = path.join(directory, 'barrier.js');
const firstMarker = path.join(directory, 'first.ready');
const secondMarker = path.join(directory, 'second.ready');
fs.writeFileSync(script, `
const fs = require('node:fs');
const [own, peer, label] = process.argv.slice(2);
fs.writeFileSync(own, 'ready');
const deadline = Date.now() + 2000;
(function poll() {
if (fs.existsSync(peer)) {
console.log(label);
process.exit(0);
}
if (Date.now() >= deadline) {
console.error('peer command never started');
process.exit(9);
}
setTimeout(poll, 10);
})();
`);

const command = (own, peer, label) => [
quote(process.execPath),
quote(script),
quote(own),
quote(peer),
quote(label),
].join(' ');
const result = await collect(run([
command(firstMarker, secondMarker, 'first-observed-peer'),
command(secondMarker, firstMarker, 'second-observed-peer'),
]));

assert.equal(result.code, 0, result.stderr || result.stdout);
assert.match(result.stdout, /first-observed-peer/);
assert.match(result.stdout, /second-observed-peer/);
assert.match(result.stdout, /GENERIC_SUBSHELL_RESULT index=0 exit_code=0/);
assert.match(result.stdout, /GENERIC_SUBSHELL_RESULT index=1 exit_code=0/);
assert.match(result.stdout, /GENERIC_SUBSHELL_EXIT_CODE=0/);
});

test('one failed command makes the parent fail after all peers finish', async t => {
const directory = fixtureDir(t, 'generic-subshell-failure-');
const completionMarker = path.join(directory, 'peer-finished');
const failScript = path.join(directory, 'fail.js');
const successScript = path.join(directory, 'success.js');
fs.writeFileSync(failScript, 'setTimeout(() => process.exit(7), 25);\n');
fs.writeFileSync(
successScript,
`const fs = require('node:fs'); setTimeout(() => { fs.writeFileSync(${JSON.stringify(completionMarker)}, 'done'); process.exit(0); }, 100);\n`,
);

const result = await collect(run([
`${quote(process.execPath)} ${quote(failScript)}`,
`${quote(process.execPath)} ${quote(successScript)}`,
]));

assert.equal(result.code, 1);
assert.equal(fs.readFileSync(completionMarker, 'utf8'), 'done');
assert.match(result.stdout, /GENERIC_SUBSHELL_RESULT index=0 exit_code=7/);
assert.match(result.stdout, /GENERIC_SUBSHELL_RESULT index=1 exit_code=0/);
assert.match(result.stdout, /GENERIC_SUBSHELL_EXIT_CODE=1/);
});

test('execution leaves global Git configuration untouched and rejects empty work', async t => {
const home = fixtureDir(t, 'generic-subshell-home-');
const previousHome = process.env.HOME;
process.env.HOME = home;
let child;
try {
child = run([`${quote(process.execPath)} -e ${quote("process.stdout.write('ok')")}`]);
}
finally {
if (previousHome === undefined) {
delete process.env.HOME;
}
else {
process.env.HOME = previousHome;
}
}

const result = await collect(child);
assert.equal(result.code, 0, result.stderr || result.stdout);
assert.equal(fs.existsSync(path.join(home, '.gitconfig')), false);
assert.throws(() => run([]), /at least one command/);
assert.throws(() => run([' ']), /command at index 0 is empty/);
});
Loading